diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt new file mode 100644 index 00000000..a7ddbbfd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceService.kt @@ -0,0 +1,104 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.Instant + +@Service +class UserCreatorChatPresenceService( + private val stringRedisTemplate: StringRedisTemplate, + private val objectMapper: ObjectMapper, + @Qualifier("userCreatorChatWebSocketServerId") private val serverId: String +) { + fun markJoined(roomId: Long, memberId: Long, sessionId: String) { + stringRedisTemplate.opsForValue().set( + presenceKey(roomId, memberId, sessionId), + presenceJson(roomId, memberId, sessionId), + PRESENCE_TTL + ) + stringRedisTemplate.opsForSet().add(memberSessionsKey(roomId, memberId), sessionId) + stringRedisTemplate.expire(memberSessionsKey(roomId, memberId), PRESENCE_TTL) + stringRedisTemplate.opsForSet().add(roomKey(roomId), memberId.toString()) + stringRedisTemplate.expire(roomKey(roomId), PRESENCE_TTL) + } + + fun refresh(roomId: Long, memberId: Long, sessionId: String) { + stringRedisTemplate.opsForValue().set( + presenceKey(roomId, memberId, sessionId), + presenceJson(roomId, memberId, sessionId), + PRESENCE_TTL + ) + stringRedisTemplate.expire(memberSessionsKey(roomId, memberId), PRESENCE_TTL) + stringRedisTemplate.expire(roomKey(roomId), PRESENCE_TTL) + } + + fun markLeft(roomId: Long, memberId: Long, sessionId: String) { + stringRedisTemplate.delete(presenceKey(roomId, memberId, sessionId)) + stringRedisTemplate.opsForSet().remove(memberSessionsKey(roomId, memberId), sessionId) + + removeMemberPresenceIfNoLiveSession(roomId, memberId) + } + + fun hasPresence(roomId: Long, memberId: Long): Boolean { + return findLiveSessionIds(roomId, memberId).isNotEmpty() + } + + private fun removeMemberPresenceIfNoLiveSession(roomId: Long, memberId: Long) { + if (findLiveSessionIds(roomId, memberId).isNotEmpty()) { + return + } + + stringRedisTemplate.delete(memberSessionsKey(roomId, memberId)) + stringRedisTemplate.opsForSet().remove(roomKey(roomId), memberId.toString()) + } + + private fun findLiveSessionIds(roomId: Long, memberId: Long): Set { + val sessionIds = stringRedisTemplate.opsForSet().members(memberSessionsKey(roomId, memberId)) ?: emptySet() + return sessionIds.filterTo(mutableSetOf()) { sessionId -> + val isLive = stringRedisTemplate.hasKey(presenceKey(roomId, memberId, sessionId)) == true + if (!isLive) { + stringRedisTemplate.opsForSet().remove(memberSessionsKey(roomId, memberId), sessionId) + } + isLive + } + } + + private fun presenceJson(roomId: Long, memberId: Long, sessionId: String): String { + return objectMapper.writeValueAsString( + UserCreatorChatRedisPresence( + serverId = serverId, + memberId = memberId, + roomId = roomId, + sessionId = sessionId, + lastSeenAt = Instant.now() + ) + ) + } + + companion object { + private val PRESENCE_TTL = Duration.ofSeconds(90) + + fun presenceKey(roomId: Long, memberId: Long, sessionId: String): String { + return "v2:user-creator-chat:ws:presence:$roomId:$memberId:$sessionId" + } + + fun memberSessionsKey(roomId: Long, memberId: Long): String { + return "v2:user-creator-chat:ws:room:$roomId:member:$memberId:sessions" + } + + fun roomKey(roomId: Long): String { + return "v2:user-creator-chat:ws:room:$roomId" + } + } +} + +data class UserCreatorChatRedisPresence( + val serverId: String, + val memberId: Long, + val roomId: Long, + val sessionId: String, + val lastSeenAt: Instant +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketServerIdConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketServerIdConfig.kt new file mode 100644 index 00000000..5bc2e555 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatWebSocketServerIdConfig.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.util.UUID + +@Configuration +class UserCreatorChatWebSocketServerIdConfig { + @Bean + fun userCreatorChatWebSocketServerId( + @Value("\${user-creator-chat.websocket.server-id:}") configuredServerId: String + ): String { + return configuredServerId.takeIf { it.isNotBlank() } ?: UUID.randomUUID().toString() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt new file mode 100644 index 00000000..067014b3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/websocket/UserCreatorChatPresenceServiceTest.kt @@ -0,0 +1,128 @@ +package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.Mockito +import org.springframework.data.redis.core.SetOperations +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.data.redis.core.ValueOperations +import java.time.Duration +import java.time.Instant + +class UserCreatorChatPresenceServiceTest { + private val stringRedisTemplate = Mockito.mock(StringRedisTemplate::class.java) + private val valueOperations = Mockito.mock(ValueOperations::class.java) as ValueOperations + private val setOperations = Mockito.mock(SetOperations::class.java) as SetOperations + private val objectMapper = ObjectMapper() + .findAndRegisterModules() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + private val service = UserCreatorChatPresenceService(stringRedisTemplate, objectMapper, "test-server") + + @Test + @DisplayName("join 시 session presence와 member session index에 TTL을 설정한다") + fun shouldMarkJoinedWithTtl() { + givenRedisOperations() + + service.markJoined(roomId = 10L, memberId = 20L, sessionId = "session-1") + + val presenceJsonCaptor = ArgumentCaptor.forClass(String::class.java) + Mockito.verify(valueOperations).set( + Mockito.eq("v2:user-creator-chat:ws:presence:10:20:session-1"), + presenceJsonCaptor.capture(), + Mockito.eq(Duration.ofSeconds(90)) + ) + assertPresenceJson(presenceJsonCaptor.value) + Mockito.verify(setOperations).add("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-1") + Mockito.verify(stringRedisTemplate).expire( + "v2:user-creator-chat:ws:room:10:member:20:sessions", + Duration.ofSeconds(90) + ) + Mockito.verify(setOperations).add("v2:user-creator-chat:ws:room:10", "20") + Mockito.verify(stringRedisTemplate).expire("v2:user-creator-chat:ws:room:10", Duration.ofSeconds(90)) + } + + @Test + @DisplayName("refresh 시 기존 session presence와 index TTL을 갱신한다") + fun shouldRefreshPresenceTtl() { + givenRedisOperations() + + service.refresh(roomId = 10L, memberId = 20L, sessionId = "session-1") + + val presenceJsonCaptor = ArgumentCaptor.forClass(String::class.java) + Mockito.verify(valueOperations).set( + Mockito.eq("v2:user-creator-chat:ws:presence:10:20:session-1"), + presenceJsonCaptor.capture(), + Mockito.eq(Duration.ofSeconds(90)) + ) + assertPresenceJson(presenceJsonCaptor.value) + Mockito.verify(stringRedisTemplate).expire( + "v2:user-creator-chat:ws:room:10:member:20:sessions", + Duration.ofSeconds(90) + ) + Mockito.verify(stringRedisTemplate).expire("v2:user-creator-chat:ws:room:10", Duration.ofSeconds(90)) + } + + @Test + @DisplayName("leave 시 session presence를 삭제하고 마지막 session이면 member presence를 제거한다") + fun shouldMarkLeftAndRemoveMemberPresenceWhenLastSessionLeaves() { + givenRedisOperations() + Mockito.`when`(setOperations.size("v2:user-creator-chat:ws:room:10:member:20:sessions")).thenReturn(0L) + + service.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1") + + Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:presence:10:20:session-1") + Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-1") + Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:room:10:member:20:sessions") + Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10", "20") + } + + @Test + @DisplayName("leave 후 남은 session id가 stale이면 member presence를 제거한다") + fun shouldRemoveMemberPresenceWhenRemainingSessionIdsAreStale() { + givenRedisOperations() + Mockito.`when`(setOperations.size("v2:user-creator-chat:ws:room:10:member:20:sessions")).thenReturn(1L) + Mockito.`when`(setOperations.members("v2:user-creator-chat:ws:room:10:member:20:sessions")) + .thenReturn(setOf("session-2")) + Mockito.`when`(stringRedisTemplate.hasKey("v2:user-creator-chat:ws:presence:10:20:session-2")) + .thenReturn(false) + + service.markLeft(roomId = 10L, memberId = 20L, sessionId = "session-1") + + Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10:member:20:sessions", "session-2") + Mockito.verify(stringRedisTemplate).delete("v2:user-creator-chat:ws:room:10:member:20:sessions") + Mockito.verify(setOperations).remove("v2:user-creator-chat:ws:room:10", "20") + } + + @Test + @DisplayName("member session index에 live session key가 있으면 room presence가 있다고 판단한다") + fun shouldReturnPresenceFromMemberSessionIndex() { + givenRedisOperations() + Mockito.`when`(setOperations.members("v2:user-creator-chat:ws:room:10:member:20:sessions")) + .thenReturn(setOf("session-1")) + Mockito.`when`(stringRedisTemplate.hasKey("v2:user-creator-chat:ws:presence:10:20:session-1")) + .thenReturn(true) + + val hasPresence = service.hasPresence(roomId = 10L, memberId = 20L) + + org.junit.jupiter.api.Assertions.assertEquals(true, hasPresence, "Expected member presence to be true") + } + + private fun givenRedisOperations() { + Mockito.`when`(stringRedisTemplate.opsForValue()).thenReturn(valueOperations) + Mockito.`when`(stringRedisTemplate.opsForSet()).thenReturn(setOperations) + } + + private fun assertPresenceJson(json: String) { + val presence = objectMapper.readTree(json) + assertEquals("test-server", presence["serverId"].asText()) + assertEquals(20L, presence["memberId"].asLong()) + assertEquals(10L, presence["roomId"].asLong()) + assertEquals("session-1", presence["sessionId"].asText()) + assertNotNull(Instant.parse(presence["lastSeenAt"].asText())) + } +}