diff --git a/assets/stomp-test.html b/assets/stomp-test.html index 91fdbeb..d03f1fb 100644 --- a/assets/stomp-test.html +++ b/assets/stomp-test.html @@ -461,8 +461,8 @@ }); stompClient.send("/app/chat.join", {}, JSON.stringify({ roomId: Number(roomIdValue()), - senderUserId: myUserId(), - senderNickname: myNickname() + userId: myUserId(), + nickname: myNickname() })); setSub("subscribed: " + dest); debug("[SUBSCRIBED] " + dest); @@ -503,8 +503,8 @@ if (stompClient && stompClient.connected) { stompClient.send("/app/chat.leave", {}, JSON.stringify({ roomId: Number(roomIdValue()), - senderUserId: myUserId(), - senderNickname: myNickname() + userId: myUserId(), + nickname: myNickname() })); } diff --git a/src/main/java/com/be/sportizebe/domain/chat/dto/ChatPresenceRequest.java b/src/main/java/com/be/sportizebe/domain/chat/dto/ChatPresenceRequest.java new file mode 100644 index 0000000..1f249f6 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/chat/dto/ChatPresenceRequest.java @@ -0,0 +1,12 @@ +package com.be.sportizebe.domain.chat.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChatPresenceRequest { + private Long roomId; + private Long userId; + private String nickname; +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/chat/entity/ChatMessage.java b/src/main/java/com/be/sportizebe/domain/chat/entity/ChatMessage.java index 6f26653..1715347 100644 --- a/src/main/java/com/be/sportizebe/domain/chat/entity/ChatMessage.java +++ b/src/main/java/com/be/sportizebe/domain/chat/entity/ChatMessage.java @@ -87,4 +87,23 @@ public static ChatMessage system(ChatRoom room, String content) { .content(content) .build(); } + public static ChatMessage join(ChatRoom room, Long senderUserId, String senderNickname) { + return ChatMessage.builder() + .room(room) + .senderUserId(senderUserId) + .senderNickname(senderNickname) + .type(Type.JOIN) + .content(null) + .build(); + } + + public static ChatMessage leave(ChatRoom room, Long senderUserId, String senderNickname) { + return ChatMessage.builder() + .room(room) + .senderUserId(senderUserId) + .senderNickname(senderNickname) + .type(Type.LEAVE) + .content(null) + .build(); + } } diff --git a/src/main/java/com/be/sportizebe/domain/chat/service/ChatMessageService.java b/src/main/java/com/be/sportizebe/domain/chat/service/ChatMessageService.java index ea7efcf..5feaf3c 100644 --- a/src/main/java/com/be/sportizebe/domain/chat/service/ChatMessageService.java +++ b/src/main/java/com/be/sportizebe/domain/chat/service/ChatMessageService.java @@ -64,4 +64,28 @@ public CursorPageResponse list(Long roomId, Long beforeId, .build(); } + // 채팅이 아닌 시스템 메시지 전용! + @Transactional + public ChatMessage saveSystem(ChatRoom room, ChatMessage.Type type, String content) { + ChatMessage msg = ChatMessage.builder() + .room(room) + .senderUserId(0L) + .senderNickname("SYSTEM") + .type(type) // JOIN / LEAVE / SYSTEM + .content(content) + .build(); + + return messageRepository.save(msg); + } + + @Transactional + public ChatMessage saveJoinLeave(ChatRoom room, ChatMessage.Type type, Long userId, String nickname, String content) { + return messageRepository.save(ChatMessage.builder() + .room(room) + .senderUserId(userId) + .senderNickname(nickname) + .type(type) // JOIN / LEAVE + .content(content) + .build()); + } } diff --git a/src/main/java/com/be/sportizebe/domain/chat/websocket/ChatSessionRegistry.java b/src/main/java/com/be/sportizebe/domain/chat/websocket/ChatSessionRegistry.java new file mode 100644 index 0000000..5d90ad5 --- /dev/null +++ b/src/main/java/com/be/sportizebe/domain/chat/websocket/ChatSessionRegistry.java @@ -0,0 +1,26 @@ +package com.be.sportizebe.domain.chat.websocket; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.springframework.stereotype.Component; + +@Component +public class ChatSessionRegistry { + + public record Presence(Long roomId, Long userId, String nickname) {} + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + public void put(String sessionId, Long roomId, Long userId, String nickname) { + store.put(sessionId, new Presence(roomId, userId, nickname)); + } + + public Presence remove(String sessionId) { + return store.remove(sessionId); + } + + public Presence get(String sessionId) { + return store.get(sessionId); + } +} \ No newline at end of file diff --git a/src/main/java/com/be/sportizebe/domain/chat/websocket/ChatStompController.java b/src/main/java/com/be/sportizebe/domain/chat/websocket/ChatStompController.java index 358ac10..e8ad239 100644 --- a/src/main/java/com/be/sportizebe/domain/chat/websocket/ChatStompController.java +++ b/src/main/java/com/be/sportizebe/domain/chat/websocket/ChatStompController.java @@ -1,5 +1,5 @@ package com.be.sportizebe.domain.chat.websocket; - +import com.be.sportizebe.domain.chat.dto.ChatPresenceRequest; import com.be.sportizebe.domain.chat.dto.response.ChatMessageResponse; import com.be.sportizebe.domain.chat.dto.request.ChatSendRequest; import com.be.sportizebe.domain.chat.entity.ChatMessage; @@ -8,6 +8,7 @@ import com.be.sportizebe.domain.chat.service.ChatRoomService; import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; @@ -17,6 +18,7 @@ public class ChatStompController { private final ChatMessageService chatMessageService; private final ChatRoomService chatRoomService; // ✅ 추가 private final SimpMessagingTemplate messagingTemplate; + private final ChatSessionRegistry registry; @MessageMapping("/chat.send") public void send(ChatSendRequest req){ @@ -33,4 +35,43 @@ public void send(ChatSendRequest req){ ChatMessageResponse.from(saved) ); } + + @MessageMapping("/chat.join") + public void join(ChatPresenceRequest req, SimpMessageHeaderAccessor headerAccessor) { + String sessionId = headerAccessor.getSessionId(); + + ChatRoom room = chatRoomService.getOrThrow(req.getRoomId()); + + // 1) 세션에 “이 탭이 어느 방에 있는지” 저장 + registry.put(sessionId, req.getRoomId(), req.getUserId(), req.getNickname()); + + // 2) 시스템 메시지 생성 + (옵션) DB 저장 + String content = req.getNickname() + " 님이 입장했습니다."; + ChatMessage msg = chatMessageService.saveJoinLeave( + room, + ChatMessage.Type.JOIN, + req.getUserId(), + req.getNickname(), + content + ); + // 3) 브로드캐스트 + messagingTemplate.convertAndSend("/topic/chat/rooms/" + req.getRoomId(), msg); + } + @MessageMapping("/chat.leave") + public void leave(ChatPresenceRequest req, SimpMessageHeaderAccessor headerAccessor) { + String sessionId = headerAccessor.getSessionId(); + + ChatRoom room = chatRoomService.getOrThrow(req.getRoomId()); + registry.remove(sessionId); // 명시적으로 나가면 제거 + + String content = req.getNickname() + " 님이 퇴장했습니다."; + ChatMessage msg = chatMessageService.saveJoinLeave( + room, + ChatMessage.Type.LEAVE, + req.getUserId(), + req.getNickname(), + content + ); + messagingTemplate.convertAndSend("/topic/chat/rooms/" + req.getRoomId(), msg); + } } diff --git a/src/main/java/com/be/sportizebe/test/stomp-test.html b/src/main/java/com/be/sportizebe/test/stomp-test.html new file mode 100644 index 0000000..91fdbeb --- /dev/null +++ b/src/main/java/com/be/sportizebe/test/stomp-test.html @@ -0,0 +1,547 @@ + + + + + + Group Chat UI (SockJS + STOMP) + + + + + + + + + + + + + + + 채팅 설정 + 멀티창 테스트용 (각 창에서 닉네임 다르게) + + Disconnect + + + + + + roomId + + + + senderUserId + + + + + + senderNickname + + + + + Connect + Subscribe + + + + + + + DISCONNECTED + + not subscribed + + + + + + + + 단체 채팅방 + room: 1 + + Enter로 전송 / Subscribe 후 수신 + + + + + + + Send + + + + 디버그 로그 보기 + + + + + + + + + \ No newline at end of file