feat: 실시간 인앱 채팅 (STOMP over WebSocket)
- 스팟별 채팅방(/topic/spots/{id}), 전송 /app/spots/{id}/chat, 엔드포인트 /ws
- STOMP CONNECT Bearer 토큰 인증(ChannelInterceptor) → 미인증 전송 무시
- ChatRoom/ChatMessage 도메인, 방 없으면 최초 메시지 시 생성, 발신자 닉네임 포함
- GET /api/chat/spots/{id}/messages 히스토리(보호), 전역 WebSocket 브로커(인메모리)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
package com.dog.dognation.chat;
|
||||
|
||||
import com.dog.dognation.api.dto.ChatDtos.ChatMessageResponse;
|
||||
import com.dog.dognation.api.dto.ChatDtos.ChatSendRequest;
|
||||
import com.dog.dognation.domain.chat.ChatService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.handler.annotation.DestinationVariable;
|
||||
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||
import org.springframework.messaging.handler.annotation.Payload;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Controller;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/** 스팟 채팅 메시지 수신 → 저장 → 구독자에게 브로드캐스트. */
|
||||
@Controller
|
||||
public class ChatSocketController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ChatSocketController.class);
|
||||
|
||||
private final ChatService chatService;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public ChatSocketController(ChatService chatService, SimpMessagingTemplate messagingTemplate) {
|
||||
this.chatService = chatService;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
@MessageMapping("/spots/{spotId}/chat")
|
||||
public void send(@DestinationVariable Long spotId,
|
||||
@Valid @Payload ChatSendRequest req,
|
||||
Principal principal) {
|
||||
if (!(principal instanceof StompPrincipal user)) {
|
||||
log.warn("[chat] 미인증 전송 시도 spot={}", spotId);
|
||||
return; // 인증되지 않은 연결은 무시
|
||||
}
|
||||
ChatMessageResponse res = chatService.post(spotId, user.userId(), req.content());
|
||||
messagingTemplate.convertAndSend("/topic/spots/" + spotId, res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.dog.dognation.chat;
|
||||
|
||||
import com.dog.dognation.auth.AuthService;
|
||||
import com.dog.dognation.auth.SessionUser;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageChannel;
|
||||
import org.springframework.messaging.simp.stomp.StompCommand;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||
import org.springframework.messaging.support.ChannelInterceptor;
|
||||
import org.springframework.messaging.support.MessageHeaderAccessor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* STOMP CONNECT 시 Authorization: Bearer 토큰을 검증해 인증 주체를 세션에 심는다.
|
||||
* 이후 SEND 프레임에서 Principal 로 발신자를 식별한다.
|
||||
*/
|
||||
@Component
|
||||
public class StompAuthChannelInterceptor implements ChannelInterceptor {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public StompAuthChannelInterceptor(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
||||
StompHeaderAccessor accessor =
|
||||
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
|
||||
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
|
||||
String bearer = accessor.getFirstNativeHeader("Authorization");
|
||||
String token = (bearer != null && bearer.startsWith("Bearer ")) ? bearer.substring(7) : null;
|
||||
SessionUser user = authService.getSession(token);
|
||||
if (user != null) {
|
||||
accessor.setUser(new StompPrincipal(String.valueOf(user.id()), user.role()));
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.dog.dognation.chat;
|
||||
|
||||
import java.security.Principal;
|
||||
|
||||
/** WebSocket 세션의 인증 주체. name 에 userId 문자열을 담는다. */
|
||||
public record StompPrincipal(String name, String role) implements Principal {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Long userId() {
|
||||
return Long.valueOf(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.dog.dognation.chat;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.ChannelRegistration;
|
||||
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;
|
||||
|
||||
/**
|
||||
* STOMP over WebSocket 설정.
|
||||
* 엔드포인트 : /ws
|
||||
* 구독 : /topic/spots/{spotId}
|
||||
* 전송 : /app/spots/{spotId}/chat
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
private final StompAuthChannelInterceptor authChannelInterceptor;
|
||||
|
||||
public WebSocketConfig(StompAuthChannelInterceptor authChannelInterceptor) {
|
||||
this.authChannelInterceptor = authChannelInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns(
|
||||
"http://localhost:9000", "http://localhost",
|
||||
"capacitor://localhost", "ionic://localhost");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry registry) {
|
||||
registry.enableSimpleBroker("/topic"); // 인메모리 브로커
|
||||
registry.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureClientInboundChannel(ChannelRegistration registration) {
|
||||
registration.interceptors(authChannelInterceptor); // CONNECT 인증
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user