From 54c9a7d5a5b60b81debabbb5d79785e44dbc3ed3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Jun 2026 01:55:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(user-creator-chat):=20WebSocket=20?= =?UTF-8?q?=ED=87=B4=EC=9E=A5=EA=B3=BC=20heartbeat=EB=A5=BC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserCreatorChatWebSocketHandler.kt | 54 ++++++++++++- .../UserCreatorChatWebSocketHandlerTest.kt | 80 +++++++++++++++++++ 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt index db7d244f..4ee1edb0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandler.kt @@ -17,10 +17,17 @@ class UserCreatorChatWebSocketHandler( private val objectMapper: ObjectMapper ) : TextWebSocketHandler() { override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { - val request = objectMapper.readValue(message.payload, UserCreatorChatWebSocketMessage::class.java) + val request = try { + objectMapper.readValue(message.payload, UserCreatorChatWebSocketMessage::class.java) + } catch (e: Exception) { + sendProtocolError(session) + return + } when (request.type) { UserCreatorChatWebSocketMessageType.JOIN_ROOM -> handleJoinRoom(session, request) UserCreatorChatWebSocketMessageType.SEND_TEXT -> handleSendText(session, request) + UserCreatorChatWebSocketMessageType.LEAVE_ROOM -> handleLeaveRoom(session, request) + UserCreatorChatWebSocketMessageType.PING -> handlePing(session, request) else -> sendError(session, request, "common.error.invalid_request") } } @@ -48,9 +55,7 @@ class UserCreatorChatWebSocketHandler( private fun handleSendText(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) { try { - if (session.attributes[JOINED_ROOM_ID_ATTRIBUTE] != request.roomId) { - throw SodaException(messageKey = "chat.room.join_required") - } + requireJoinedRoom(session, request.roomId) val textMessage = request.payload["textMessage"]?.asText().orEmpty() val message = service.sendTextMessageByWebSocket( memberId = memberId(session), @@ -63,6 +68,31 @@ class UserCreatorChatWebSocketHandler( } } + private fun handleLeaveRoom(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) { + try { + requireJoinedRoom(session, request.roomId) + clearJoinedRoom(session) + } catch (e: SodaException) { + sendError(session, request, e.messageKey ?: "common.error.invalid_request") + } + } + + private fun handlePing(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) { + try { + requireJoinedRoom(session, request.roomId) + presenceService.refresh(roomId = request.roomId, memberId = memberId(session), sessionId = session.id) + sendEnvelope( + session, + UserCreatorChatWebSocketMessageType.PONG, + request.requestId, + request.roomId, + emptyMap() + ) + } catch (e: SodaException) { + sendError(session, request, e.messageKey ?: "common.error.invalid_request") + } + } + override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { clearJoinedRoom(session) } @@ -80,6 +110,12 @@ class UserCreatorChatWebSocketHandler( sessionRegistry.remove(session.id) } + private fun requireJoinedRoom(session: WebSocketSession, roomId: Long) { + if (session.attributes[JOINED_ROOM_ID_ATTRIBUTE] != roomId) { + throw SodaException(messageKey = "chat.room.join_required") + } + } + private fun sendError(session: WebSocketSession, request: UserCreatorChatWebSocketMessage, messageKey: String) { sendEnvelope( session, @@ -90,6 +126,16 @@ class UserCreatorChatWebSocketHandler( ) } + private fun sendProtocolError(session: WebSocketSession) { + sendEnvelope( + session, + UserCreatorChatWebSocketMessageType.ERROR, + null, + 0L, + mapOf("messageKey" to "common.error.invalid_request") + ) + } + private fun sendEnvelope( session: WebSocketSession, type: UserCreatorChatWebSocketMessageType, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt index 74765dab..086aaba7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketHandlerTest.kt @@ -133,6 +133,86 @@ class UserCreatorChatWebSocketHandlerTest { assertEquals(emptyList(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L)) } + @Test + @DisplayName("LEAVE_ROOM은 local session과 Redis presence를 제거한다") + fun shouldRemoveLocalSessionAndPresenceWhenLeaveRoomReceived() { + val session = session("session-1", memberId = 1L) + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) + Mockito.clearInvocations(session, presenceService) + + handler.handleMessage(session, textMessage("LEAVE_ROOM", "leave-1", 10L, "{}")) + + Mockito.verify(presenceService).markLeft(roomId = 10L, memberId = 1L, sessionId = "session-1") + assertEquals(emptyList(), sessionRegistry.findSessions(roomId = 10L, memberId = 1L)) + } + + @Test + @DisplayName("LEAVE_ROOM은 joined room과 요청 roomId가 다르면 presence를 제거하지 않고 ERROR를 응답한다") + fun shouldRejectLeaveRoomWhenRequestRoomDoesNotMatchJoinedRoom() { + val session = session("session-1", memberId = 1L) + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) + Mockito.clearInvocations(session, presenceService) + + handler.handleMessage(session, textMessage("LEAVE_ROOM", "leave-1", 20L, "{}")) + + Mockito.verify(presenceService, Mockito.never()).markLeft( + roomId = 10L, + memberId = 1L, + sessionId = "session-1" + ) + assertEquals(listOf(session), sessionRegistry.findSessions(roomId = 10L, memberId = 1L)) + val response = sentJson(session) + assertEquals("ERROR", response["type"].asText()) + assertEquals("leave-1", response["requestId"].asText()) + assertEquals(20L, response["roomId"].asLong()) + assertEquals("chat.room.join_required", response["payload"]["messageKey"].asText()) + } + + @Test + @DisplayName("PING은 joined room의 presence TTL을 갱신하고 PONG을 응답한다") + fun shouldRefreshPresenceAndSendPongWhenPingReceived() { + val session = session("session-1", memberId = 1L) + handler.handleMessage(session, textMessage("JOIN_ROOM", "join-1", 10L, "{}")) + Mockito.clearInvocations(session, presenceService) + + handler.handleMessage(session, textMessage("PING", "ping-1", 10L, "{}")) + + Mockito.verify(presenceService).refresh(roomId = 10L, memberId = 1L, sessionId = "session-1") + val response = sentJson(session) + assertEquals("PONG", response["type"].asText()) + assertEquals("ping-1", response["requestId"].asText()) + assertEquals(10L, response["roomId"].asLong()) + assertTrue(response["payload"].isObject) + } + + @Test + @DisplayName("잘못된 JSON 메시지는 handler 밖으로 예외를 전파하지 않고 ERROR를 응답한다") + fun shouldSendErrorWhenMessagePayloadIsMalformedJson() { + val session = session("session-1", memberId = 1L) + + handler.handleMessage(session, TextMessage("{")) + + val response = sentJson(session) + assertEquals("ERROR", response["type"].asText()) + assertTrue(response["requestId"].isNull) + assertEquals(0L, response["roomId"].asLong()) + assertEquals("common.error.invalid_request", response["payload"]["messageKey"].asText()) + } + + @Test + @DisplayName("알 수 없는 type 메시지는 handler 밖으로 예외를 전파하지 않고 ERROR를 응답한다") + fun shouldSendErrorWhenMessageTypeIsUnknown() { + val session = session("session-1", memberId = 1L) + + handler.handleMessage(session, textMessage("UNKNOWN", "unknown-1", 10L, "{}")) + + val response = sentJson(session) + assertEquals("ERROR", response["type"].asText()) + assertTrue(response["requestId"].isNull) + assertEquals(0L, response["roomId"].asLong()) + assertEquals("common.error.invalid_request", response["payload"]["messageKey"].asText()) + } + private fun textMessage(type: String, requestId: String, roomId: Long, payload: String): TextMessage { return TextMessage( """