diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageController.java new file mode 100644 index 00000000..8929b55a --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatMessageController.java @@ -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); + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatStompDocsController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatStompDocsController.java new file mode 100644 index 00000000..ed625105 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/controller/ChatStompDocsController.java @@ -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 + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageRequest.java new file mode 100644 index 00000000..06bfc514 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageRequest.java @@ -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; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageResponse.java new file mode 100644 index 00000000..d7e7d8f4 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/dto/ChatMessageResponse.java @@ -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; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageService.java new file mode 100644 index 00000000..77af3a29 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/chats/service/ChatMessageService.java @@ -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 + ); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/config/WebSocketConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/config/WebSocketConfig.java new file mode 100644 index 00000000..5a04bb5c --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/config/WebSocketConfig.java @@ -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"); + + } + +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java index 28312cf5..2a0c3b17 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java @@ -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; @@ -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 @@ -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 diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/websocket/JwtHandshakeHandler.java b/src/main/java/com/back/web7_9_codecrete_be/global/websocket/JwtHandshakeHandler.java new file mode 100644 index 00000000..78eba1bc --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/websocket/JwtHandshakeHandler.java @@ -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 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); + } +} +