fix(user-creator-chat): Redis 전달 예외 fallback 범위를 좁힌다

This commit is contained in:
2026-06-19 05:35:53 +09:00
parent 07b93f3219
commit 6c252ee008
3 changed files with 130 additions and 20 deletions

View File

@@ -29,8 +29,10 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoo
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPresenceService
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageType
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.dao.DataAccessException
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@@ -119,14 +121,8 @@ class UserCreatorChatService(
val message = saveTextMessage(context, textMessage)
val senderMessage = toMessageItemDto(message, sender)
val opponent = context.opponentParticipant.member
if (presenceService.hasPresence(roomId, opponent.id!!)) {
val opponentMessage = toMessageItemDto(message, opponent)
roomMessageBroker.publish(
roomId = roomId,
memberId = opponent.id!!,
payload = websocketMessagePayload(UserCreatorChatWebSocketMessageType.MESSAGE, roomId, opponentMessage)
)
} else {
val deliveredRealtime = deliverRealtime(message, opponent)
if (!deliveredRealtime) {
publishMessagePush(message, sender, opponent)
}
return senderMessage
@@ -190,18 +186,8 @@ class UserCreatorChatService(
): SendUserCreatorChatMessageResponse {
val opponent = opponentParticipant.member
val item = toMessageItemDto(message, member)
val opponentPresent = presenceService.hasPresence(message.chatRoom.id!!, opponent.id!!)
if (opponentPresent) {
val opponentMessage = toMessageItemDto(message, opponent)
roomMessageBroker.publish(
roomId = message.chatRoom.id!!,
memberId = opponent.id!!,
payload = websocketMessagePayload(
UserCreatorChatWebSocketMessageType.MESSAGE,
message.chatRoom.id!!,
opponentMessage
)
)
val deliveredRealtime = deliverRealtime(message, opponent)
if (deliveredRealtime) {
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = true, pushSent = false)
}
@@ -209,6 +195,31 @@ class UserCreatorChatService(
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true)
}
private fun deliverRealtime(message: UserCreatorChatMessage, opponent: Member): Boolean {
val roomId = message.chatRoom.id!!
val opponentId = opponent.id!!
return try {
if (!presenceService.hasPresence(roomId, opponentId)) {
return false
}
val opponentMessage = toMessageItemDto(message, opponent)
roomMessageBroker.publish(
roomId = roomId,
memberId = opponentId,
payload = websocketMessagePayload(UserCreatorChatWebSocketMessageType.MESSAGE, roomId, opponentMessage)
)
true
} catch (e: DataAccessException) {
logger.warn(
"유저-크리에이터 채팅 실시간 전달 Redis 오류로 푸시 fail-open 처리: roomId={}, opponentId={}, cause={}",
roomId,
opponentId,
e.message
)
false
}
}
private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) {
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
"message.fcm.voice_received"
@@ -287,4 +298,8 @@ class UserCreatorChatService(
val senderParticipant: UserCreatorChatParticipant,
val opponentParticipant: UserCreatorChatParticipant
)
companion object {
private val logger = LoggerFactory.getLogger(UserCreatorChatService::class.java)
}
}

View File

@@ -18,6 +18,7 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPres
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
@@ -25,6 +26,7 @@ import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.context.ApplicationEventPublisher
import org.springframework.dao.DataAccessResourceFailureException
import org.springframework.data.domain.PageRequest
import org.springframework.mock.web.MockMultipartFile
import java.io.ByteArrayInputStream
@@ -204,6 +206,33 @@ class UserCreatorChatServiceTest {
Mockito.verifyNoInteractions(roomMessageBroker)
}
@Test
@DisplayName("WebSocket 텍스트 전송은 Redis presence 확인 실패 시 메시지를 저장하고 푸시 이벤트를 발행한다")
fun shouldPublishPushEventWhenPresenceCheckFailsDuringWebSocketTextMessage() {
val user = member(1L, "user")
val creator = member(2L, "creator")
val room = room(10L)
val senderParticipant = participant(100L, room, user)
val recipientParticipant = participant(101L, room, creator)
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(presenceService.hasPresence(10L, 2L))
.thenThrow(DataAccessResourceFailureException("redis down"))
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 207L }
}
val response = service.sendTextMessageByWebSocket(memberId = 1L, roomId = 10L, textMessage = "hello")
assertEquals(207L, response.messageId)
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
assertEquals(listOf(2L), eventCaptor.value.recipients)
Mockito.verifyNoInteractions(roomMessageBroker)
}
@Test
@DisplayName("음성 메시지 REST 전송은 상대방 presence가 있으면 WebSocket broker로 MESSAGE를 발행하고 푸시를 보내지 않는다")
fun shouldPublishVoiceMessageToWebSocketWhenOpponentPresenceExists() {
@@ -266,6 +295,63 @@ class UserCreatorChatServiceTest {
Mockito.verifyNoInteractions(roomMessageBroker)
}
@Test
@DisplayName("음성 메시지 REST 전송은 Redis broker 발행 실패 시 푸시 이벤트를 발행한다")
fun shouldPublishPushEventWhenBrokerPublishFailsDuringVoiceMessage() {
val user = member(1L, "user")
val creator = member(2L, "creator")
val room = room(10L)
val senderParticipant = participant(100L, room, user)
val recipientParticipant = participant(101L, room, creator)
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
Mockito.doThrow(DataAccessResourceFailureException("redis publish down"))
.`when`(roomMessageBroker)
.publish(Mockito.eq(10L), Mockito.eq(2L), Mockito.anyString())
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 208L }
}
givenVoiceUploadReturns("voice/208.m4a")
val response = service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
assertEquals(208L, response.message.messageId)
assertFalse(response.deliveredRealtime)
assertTrue(response.pushSent)
val eventCaptor = ArgumentCaptor.forClass(FcmEvent::class.java)
Mockito.verify(eventPublisher).publishEvent(eventCaptor.capture())
assertEquals(FcmEventType.INDIVIDUAL, eventCaptor.value.type)
assertEquals(listOf(2L), eventCaptor.value.recipients)
}
@Test
@DisplayName("음성 메시지 REST 전송은 Redis 계층이 아닌 broker 예외를 푸시로 숨기지 않는다")
fun shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage() {
val user = member(1L, "user")
val creator = member(2L, "creator")
val room = room(10L)
val senderParticipant = participant(100L, room, user)
val recipientParticipant = participant(101L, room, creator)
Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(senderParticipant)
Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(recipientParticipant)
Mockito.`when`(presenceService.hasPresence(10L, 2L)).thenReturn(true)
Mockito.doThrow(IllegalStateException("programming error"))
.`when`(roomMessageBroker)
.publish(Mockito.eq(10L), Mockito.eq(2L), Mockito.anyString())
Mockito.`when`(messageRepository.save(Mockito.any(UserCreatorChatMessage::class.java))).thenAnswer { invocation ->
(invocation.arguments[0] as UserCreatorChatMessage).apply { id = 209L }
}
givenVoiceUploadReturns("voice/209.m4a")
assertThrows(IllegalStateException::class.java) {
service.sendVoiceMessage(user, 10L, voiceFile(), "{}")
}
Mockito.verifyNoInteractions(eventPublisher)
}
@Test
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {