feat(user-creator-chat): WebSocket room handler를 구현한다

This commit is contained in:
2026-06-18 23:00:43 +09:00
parent 2d13f8dee7
commit 7080a03166
5 changed files with 396 additions and 9 deletions

View File

@@ -26,6 +26,9 @@ import kr.co.vividnext.sodalive.v2.usercreatorchat.dto.UserCreatorChatRoomOpenRe
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatMessageRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatParticipantRepository
import kr.co.vividnext.sodalive.v2.usercreatorchat.repository.UserCreatorChatRoomRepository
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.springframework.beans.factory.annotation.Value
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
@@ -43,6 +46,8 @@ class UserCreatorChatService(
private val memberRepository: MemberRepository,
private val blockMemberRepository: BlockMemberRepository,
private val realtimeService: UserCreatorChatRealtimeService,
private val presenceService: UserCreatorChatPresenceService,
private val roomMessageBroker: UserCreatorChatRoomMessageBroker,
private val applicationEventPublisher: ApplicationEventPublisher,
private val objectMapper: ObjectMapper,
private val s3Uploader: S3Uploader,
@@ -114,17 +119,30 @@ class UserCreatorChatService(
): SendUserCreatorChatMessageResponse {
if (request.textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
val context = resolveSendContext(member, roomId)
val message = messageRepository.save(
UserCreatorChatMessage(
chatRoom = context.room,
participant = context.senderParticipant,
messageType = UserCreatorChatMessageType.TEXT,
textMessage = request.textMessage
)
)
val message = saveTextMessage(context, request.textMessage)
return deliverMessage(message, member, context.opponentParticipant)
}
@Transactional
fun sendTextMessageByWebSocket(memberId: Long, roomId: Long, textMessage: String): UserCreatorChatMessageItemDto {
if (textMessage.isBlank()) throw SodaException(messageKey = "common.error.invalid_request")
val senderParticipant = validateParticipant(roomId, memberId)
val sender = senderParticipant.member
val context = resolveSendContext(sender, roomId)
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)
)
}
return senderMessage
}
@Transactional
fun sendVoiceMessage(
member: Member,
@@ -162,6 +180,21 @@ class UserCreatorChatService(
realtimeService.disconnect(roomId, member.id!!)
}
fun validateParticipant(roomId: Long, memberId: Long): UserCreatorChatParticipant {
return requireParticipant(roomId, memberId)
}
private fun saveTextMessage(context: SendContext, textMessage: String): UserCreatorChatMessage {
return messageRepository.save(
UserCreatorChatMessage(
chatRoom = context.room,
participant = context.senderParticipant,
messageType = UserCreatorChatMessageType.TEXT,
textMessage = textMessage
)
)
}
private fun resolveSendContext(member: Member, roomId: Long): SendContext {
val room = findRoom(roomId)
val senderParticipant = requireParticipant(roomId, member.id!!)
@@ -208,6 +241,21 @@ class UserCreatorChatService(
)
}
private fun websocketMessagePayload(
type: UserCreatorChatWebSocketMessageType,
roomId: Long,
payload: UserCreatorChatMessageItemDto
): String {
return objectMapper.writeValueAsString(
mapOf(
"type" to type,
"requestId" to null,
"roomId" to roomId,
"payload" to payload
)
)
}
private fun validateRecipient(sender: Member, recipient: Member) {
if (!recipient.isActive) throw SodaException(messageKey = "message.error.recipient_inactive")
if (recipient.memberKind == MemberKind.AI_CHARACTER) {

View File

@@ -1,7 +1,117 @@
package kr.co.vividnext.sodalive.v2.usercreatorchat.websocket
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.v2.usercreatorchat.service.UserCreatorChatService
import org.springframework.stereotype.Component
import org.springframework.web.socket.CloseStatus
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import org.springframework.web.socket.handler.TextWebSocketHandler
@Component
class UserCreatorChatWebSocketHandler : TextWebSocketHandler()
class UserCreatorChatWebSocketHandler(
private val service: UserCreatorChatService,
private val presenceService: UserCreatorChatPresenceService,
private val sessionRegistry: UserCreatorChatWebSocketSessionRegistry,
private val objectMapper: ObjectMapper
) : TextWebSocketHandler() {
override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
val request = objectMapper.readValue(message.payload, UserCreatorChatWebSocketMessage::class.java)
when (request.type) {
UserCreatorChatWebSocketMessageType.JOIN_ROOM -> handleJoinRoom(session, request)
UserCreatorChatWebSocketMessageType.SEND_TEXT -> handleSendText(session, request)
else -> sendError(session, request, "common.error.invalid_request")
}
}
private fun handleJoinRoom(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
try {
val memberId = memberId(session)
service.validateParticipant(roomId = request.roomId, memberId = memberId)
clearJoinedRoom(session)
sessionRegistry.register(roomId = request.roomId, memberId = memberId, session = session)
presenceService.markJoined(roomId = request.roomId, memberId = memberId, sessionId = session.id)
session.attributes[JOINED_ROOM_ID_ATTRIBUTE] = request.roomId
sendEnvelope(
session,
UserCreatorChatWebSocketMessageType.JOINED,
request.requestId,
request.roomId,
emptyMap<String, Any>()
)
} catch (e: SodaException) {
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
session.close(CloseStatus.POLICY_VIOLATION)
}
}
private fun handleSendText(session: WebSocketSession, request: UserCreatorChatWebSocketMessage) {
try {
if (session.attributes[JOINED_ROOM_ID_ATTRIBUTE] != request.roomId) {
throw SodaException(messageKey = "chat.room.join_required")
}
val textMessage = request.payload["textMessage"]?.asText().orEmpty()
val message = service.sendTextMessageByWebSocket(
memberId = memberId(session),
roomId = request.roomId,
textMessage = textMessage
)
sendEnvelope(session, UserCreatorChatWebSocketMessageType.SEND_ACK, request.requestId, request.roomId, message)
} catch (e: SodaException) {
sendError(session, request, e.messageKey ?: "common.error.invalid_request")
}
}
override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
clearJoinedRoom(session)
}
private fun memberId(session: WebSocketSession): Long {
return session.attributes[UserCreatorChatWebSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE] as? Long
?: throw SodaException(messageKey = "common.error.bad_credentials")
}
private fun clearJoinedRoom(session: WebSocketSession) {
val roomId = session.attributes.remove(JOINED_ROOM_ID_ATTRIBUTE) as? Long
if (roomId != null) {
presenceService.markLeft(roomId = roomId, memberId = memberId(session), sessionId = session.id)
}
sessionRegistry.remove(session.id)
}
private fun sendError(session: WebSocketSession, request: UserCreatorChatWebSocketMessage, messageKey: String) {
sendEnvelope(
session,
UserCreatorChatWebSocketMessageType.ERROR,
request.requestId,
request.roomId,
mapOf("messageKey" to messageKey)
)
}
private fun sendEnvelope(
session: WebSocketSession,
type: UserCreatorChatWebSocketMessageType,
requestId: String?,
roomId: Long,
payload: Any
) {
session.sendMessage(
TextMessage(
objectMapper.writeValueAsString(
mapOf(
"type" to type,
"requestId" to requestId,
"roomId" to roomId,
"payload" to payload
)
)
)
)
}
companion object {
private const val JOINED_ROOM_ID_ATTRIBUTE = "userCreatorChatJoinedRoomId"
}
}