Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.back.web7_9_codecrete_be.domain.chats.controller;

import java.security.Principal;

import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;

import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest;
import com.back.web7_9_codecrete_be.domain.chats.service.ChatMessageService;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Controller
@RequiredArgsConstructor
public class ChatMessageController {

private final ChatMessageService chatMessageService;

@MessageMapping("/chat/send")
public void send(ChatMessageRequest message, Principal principal) {

if (principal == null) {
throw new IllegalStateException("Unauthenticated WebSocket access");
}

chatMessageService.sendMessage(message, principal);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.back.web7_9_codecrete_be.domain.chats.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest;
import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;

@RestController
@RequestMapping("/docs/chat")
@Tag(name = "Chat STOMP", description = "WebSocket / STOMP 채팅 프로토콜 문서. 문서용 API. 사용X")
public class ChatStompDocsController {

@Operation(
summary = "채팅 메시지 전송 (STOMP)",
description = """
### 📡 WebSocket STOMP 채팅 메시지 전송

#### 1️⃣ WebSocket Endpoint
```
ws://localhost:8080/ws-chat
or
wss://www.naeconcertbutakhae.shop/ws-chat
```

#### 2️⃣ SEND Destination
```
/app/chat/send
```

#### 3️⃣ SUBSCRIBE Destination
```
/topic/chat/{concertId}
```

#### 4️⃣ SEND Payload
```json
{
"concertId": 1,
"content": "안녕하세요!"
}
```

#### 5️⃣ SUBSCRIBE Response
```json
{
"concertId": 1,
"senderId": 10,
"senderName": "테스트 유저",
"content": "안녕하세요!",
"sentAt": "2025-12-23T15:30:00"
}
```
"""
)
@GetMapping("/stomp")
public void stompChatGuide() {}

@Operation(
summary = "STOMP 채팅 메시지 전송 규격",
description = """
WebSocket + STOMP 기반 채팅 메시지 전송 규격입니다.

- 실제 사용되는 HTTP API 아닙니다.
- Swagger 문서용
""",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "STOMP 메세지 SEND하면 전달되는 요청 데이터",
required = true,
content = @Content(
schema = @Schema(implementation = ChatMessageRequest.class)
)
),
responses = {
@ApiResponse(
responseCode = "200",
description = "STOMP SUBSCRIBE로 수신되는 메시지",
content = @Content(
schema = @Schema(implementation = ChatMessageResponse.class)
)
)
}
)
@GetMapping("/message-schema")
public ChatMessageResponse messageSchema() {
return null; // 실제 반환 목적 X
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.back.web7_9_codecrete_be.domain.chats.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "채팅 메시지 전송 요청")
public class ChatMessageRequest {

@Schema(description = "공연 ID", example = "1")
private Long concertId;
@Schema(description = "메시지 내용", example = "안녕하세요")
private String content;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.back.web7_9_codecrete_be.domain.chats.dto;

import java.time.LocalDateTime;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "채팅 메시지 응답")
public class ChatMessageResponse {

@Schema(description = "공연 ID", example = "1")
private Long concertId;
@Schema(description = "발신자 ID", example = "2")
private Long senderId;
@Schema(description = "발신자 닉네임", example = "테스트 유저")
private String senderName;
@Schema(description = "메시지 내용", example = "안녕하세요")
private String content;
@Schema(description = "전송 시각", example = "2025-12-23T16:28:07.8806432")
private LocalDateTime sentDate;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.back.web7_9_codecrete_be.domain.chats.service;

import java.security.Principal;
import java.time.LocalDateTime;

import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageRequest;
import com.back.web7_9_codecrete_be.domain.chats.dto.ChatMessageResponse;
import com.back.web7_9_codecrete_be.domain.users.entity.User;
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode;
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatMessageService {

private final UserRepository userRepository;
private final SimpMessagingTemplate messagingTemplate;

public void sendMessage(ChatMessageRequest message, Principal principal) {

String email = principal.getName();

// TODO: 캐싱처리
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND));

Long senderId = user.getId();
String senderName = user.getNickname();

ChatMessageResponse response = new ChatMessageResponse(
message.getConcertId(),
senderId,
senderName,
message.getContent(),
LocalDateTime.now()
);

log.info("[SEND MESSAGE] From User ID: {}, Content: {}", senderId, message.getContent());

messagingTemplate.convertAndSend(
"/topic/chat/" + message.getConcertId(),
response
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.back.web7_9_codecrete_be.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

import com.back.web7_9_codecrete_be.global.websocket.JwtHandshakeHandler;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final JwtHandshakeHandler jwtHandshakeHandler;

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {

config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");

}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {

log.info("WebSocket 엔드포인트 등록: /ws-chat");

registry.addEndpoint("/ws-chat")
.setHandshakeHandler(jwtHandshakeHandler)
.setAllowedOriginPatterns("http://localhost:3000",
"https://www.naeconcertbutakhae.shop");

}

}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.back.web7_9_codecrete_be.global.security;

import com.back.web7_9_codecrete_be.domain.auth.service.TokenService;
import lombok.RequiredArgsConstructor;
import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
Expand All @@ -14,7 +14,9 @@
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;
import com.back.web7_9_codecrete_be.domain.auth.service.TokenService;

import lombok.RequiredArgsConstructor;

@Configuration
@RequiredArgsConstructor
Expand Down Expand Up @@ -48,6 +50,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
// Authorization 설정
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/ws-chat/**",
"/actuator/**",
"/api/v1/auth/**", // 로그인/회원가입은 허용
"/v3/api-docs/**", // Swagger
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.back.web7_9_codecrete_be.global.websocket;

import java.security.Principal;
import java.util.Map;

import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;

import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode;
import com.back.web7_9_codecrete_be.global.error.exception.BusinessException;
import com.back.web7_9_codecrete_be.global.security.JwtTokenProvider;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;

/**
* WebSocket Handshake 단계에서 쿠키에 포함된 JWT를 추출하여
* 사용자 Authentication(Principal)을 설정하는 HandshakeHandler.
*
* HTTP 필터 체인이 적용되지 않는 WebSocket 연결 단계에서
* 별도의 인증 처리를 담당
*/
@Component
@RequiredArgsConstructor
public class JwtHandshakeHandler extends DefaultHandshakeHandler {

private final JwtTokenProvider jwtTokenProvider;

@Override
protected Principal determineUser(
ServerHttpRequest request,
WebSocketHandler wsHandler,
Map<String, Object> attributes) {

if (!(request instanceof ServletServerHttpRequest servletRequest)) {
throw new BusinessException(AuthErrorCode.UNAUTHORIZED_USER);
}

HttpServletRequest httpRequest = servletRequest.getServletRequest();

if (httpRequest.getCookies() == null) {
throw new BusinessException(AuthErrorCode.UNAUTHORIZED_USER);
}

for (Cookie cookie : httpRequest.getCookies()) {
if ("ACCESS_TOKEN".equals(cookie.getName())) {
String token = cookie.getValue();

return jwtTokenProvider.getAuthentication(token);
}
}

throw new BusinessException(AuthErrorCode.UNAUTHORIZED_USER);
}
}