fix(user-creator-chat): Redis 전달 예외 fallback 범위를 좁힌다
This commit is contained in:
@@ -803,6 +803,15 @@ spring:
|
|||||||
- Fresh lint Result: `BUILD SUCCESSFUL in 7s`.
|
- Fresh lint Result: `BUILD SUCCESSFUL in 7s`.
|
||||||
- Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
- Fresh 전체 검증 Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process`
|
||||||
- Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 52s`.
|
- Fresh 전체 검증 Result: `BUILD SUCCESSFUL in 1m 52s`.
|
||||||
|
- 잔여 리스크 개선:
|
||||||
|
- 대상: `UserCreatorChatService.deliverRealtime`
|
||||||
|
- 무엇: Redis/WebSocket 전달 경계의 fail-open 처리 범위를 전체 `Exception`에서 Redis 접근 예외인 `DataAccessException`으로 좁히고, Redis 오류는 warn 로그를 남긴 뒤 푸시 발송으로 fail-open 하도록 정리했다. Redis 계층이 아닌 broker 예외는 숨기지 않고 전파한다.
|
||||||
|
- 왜: Redis 장애 시 메시지 저장 후 푸시 발송 요구사항은 유지하되, 프로그래밍 오류나 예상하지 못한 런타임 오류까지 푸시 fallback으로 숨기지 않기 위해서다.
|
||||||
|
- RED: `UserCreatorChatServiceTest.shouldPropagateNonRedisBrokerExceptionDuringVoiceMessage`를 추가했다. 기존 `runCatching` 구현에서는 `IllegalStateException`이 전파되지 않아 `AssertionFailedError`로 실패했다.
|
||||||
|
- GREEN: `DataAccessException`만 catch하도록 수정한 뒤 `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`가 `BUILD SUCCESSFUL in 3m 33s`로 통과했다.
|
||||||
|
- 인접 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.fcm.FcmServiceTest --tests 'kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.*'`가 `BUILD SUCCESSFUL in 38s`로 통과했다.
|
||||||
|
- Lint: import 정렬 수정 후 `./gradlew --no-daemon ktlintCheck`가 `BUILD SUCCESSFUL in 21s`로 통과했다.
|
||||||
|
- 전체 회귀: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process -Dspring.jpa.open-in-view=false`가 `BUILD SUCCESSFUL in 4m 39s`로 통과했다.
|
||||||
- Phase 3:
|
- Phase 3:
|
||||||
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
- Run: `./gradlew --no-daemon test -Dkotlin.compiler.execution.strategy=in-process --tests kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRedisIntegrationTest`
|
||||||
- RED Result: 테스트 파일 부재 상태에서 `No tests found for given includes`로 실패했다.
|
- RED Result: 테스트 파일 부재 상태에서 `No tests found for given includes`로 실패했다.
|
||||||
|
|||||||
@@ -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.UserCreatorChatPresenceService
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
|
||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageType
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatWebSocketMessageType
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.dao.DataAccessException
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
@@ -119,14 +121,8 @@ class UserCreatorChatService(
|
|||||||
val message = saveTextMessage(context, textMessage)
|
val message = saveTextMessage(context, textMessage)
|
||||||
val senderMessage = toMessageItemDto(message, sender)
|
val senderMessage = toMessageItemDto(message, sender)
|
||||||
val opponent = context.opponentParticipant.member
|
val opponent = context.opponentParticipant.member
|
||||||
if (presenceService.hasPresence(roomId, opponent.id!!)) {
|
val deliveredRealtime = deliverRealtime(message, opponent)
|
||||||
val opponentMessage = toMessageItemDto(message, opponent)
|
if (!deliveredRealtime) {
|
||||||
roomMessageBroker.publish(
|
|
||||||
roomId = roomId,
|
|
||||||
memberId = opponent.id!!,
|
|
||||||
payload = websocketMessagePayload(UserCreatorChatWebSocketMessageType.MESSAGE, roomId, opponentMessage)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
publishMessagePush(message, sender, opponent)
|
publishMessagePush(message, sender, opponent)
|
||||||
}
|
}
|
||||||
return senderMessage
|
return senderMessage
|
||||||
@@ -190,18 +186,8 @@ class UserCreatorChatService(
|
|||||||
): SendUserCreatorChatMessageResponse {
|
): SendUserCreatorChatMessageResponse {
|
||||||
val opponent = opponentParticipant.member
|
val opponent = opponentParticipant.member
|
||||||
val item = toMessageItemDto(message, member)
|
val item = toMessageItemDto(message, member)
|
||||||
val opponentPresent = presenceService.hasPresence(message.chatRoom.id!!, opponent.id!!)
|
val deliveredRealtime = deliverRealtime(message, opponent)
|
||||||
if (opponentPresent) {
|
if (deliveredRealtime) {
|
||||||
val opponentMessage = toMessageItemDto(message, opponent)
|
|
||||||
roomMessageBroker.publish(
|
|
||||||
roomId = message.chatRoom.id!!,
|
|
||||||
memberId = opponent.id!!,
|
|
||||||
payload = websocketMessagePayload(
|
|
||||||
UserCreatorChatWebSocketMessageType.MESSAGE,
|
|
||||||
message.chatRoom.id!!,
|
|
||||||
opponentMessage
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = true, pushSent = false)
|
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = true, pushSent = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +195,31 @@ class UserCreatorChatService(
|
|||||||
return SendUserCreatorChatMessageResponse(message = item, deliveredRealtime = false, pushSent = true)
|
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) {
|
private fun publishMessagePush(message: UserCreatorChatMessage, sender: Member, opponent: Member) {
|
||||||
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
|
val messageKey = if (message.messageType == UserCreatorChatMessageType.VOICE) {
|
||||||
"message.fcm.voice_received"
|
"message.fcm.voice_received"
|
||||||
@@ -287,4 +298,8 @@ class UserCreatorChatService(
|
|||||||
val senderParticipant: UserCreatorChatParticipant,
|
val senderParticipant: UserCreatorChatParticipant,
|
||||||
val opponentParticipant: UserCreatorChatParticipant
|
val opponentParticipant: UserCreatorChatParticipant
|
||||||
)
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val logger = LoggerFactory.getLogger(UserCreatorChatService::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatPres
|
|||||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
|
import kr.co.vividnext.sodalive.v2.usercreatorchat.websocket.UserCreatorChatRoomMessageBroker
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
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.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
@@ -25,6 +26,7 @@ import org.junit.jupiter.api.Test
|
|||||||
import org.mockito.ArgumentCaptor
|
import org.mockito.ArgumentCaptor
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
|
import org.springframework.dao.DataAccessResourceFailureException
|
||||||
import org.springframework.data.domain.PageRequest
|
import org.springframework.data.domain.PageRequest
|
||||||
import org.springframework.mock.web.MockMultipartFile
|
import org.springframework.mock.web.MockMultipartFile
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
@@ -204,6 +206,33 @@ class UserCreatorChatServiceTest {
|
|||||||
Mockito.verifyNoInteractions(roomMessageBroker)
|
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
|
@Test
|
||||||
@DisplayName("음성 메시지 REST 전송은 상대방 presence가 있으면 WebSocket broker로 MESSAGE를 발행하고 푸시를 보내지 않는다")
|
@DisplayName("음성 메시지 REST 전송은 상대방 presence가 있으면 WebSocket broker로 MESSAGE를 발행하고 푸시를 보내지 않는다")
|
||||||
fun shouldPublishVoiceMessageToWebSocketWhenOpponentPresenceExists() {
|
fun shouldPublishVoiceMessageToWebSocketWhenOpponentPresenceExists() {
|
||||||
@@ -266,6 +295,63 @@ class UserCreatorChatServiceTest {
|
|||||||
Mockito.verifyNoInteractions(roomMessageBroker)
|
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
|
@Test
|
||||||
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
|
@DisplayName("커서가 있으면 기본 20개 기준으로 이전 메시지를 조회한다")
|
||||||
fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {
|
fun shouldGetPreviousMessagesWithDefaultLimitWhenCursorExists() {
|
||||||
|
|||||||
Reference in New Issue
Block a user