feat(chat): 채팅방 메시지 전송 API 구현

This commit is contained in:
Klaus 2025-08-08 16:39:12 +09:00
parent 002f2c2834
commit 4b3463e97c
4 changed files with 198 additions and 0 deletions

View File

@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.room.controller
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException
@ -116,4 +117,22 @@ class ChatRoomController(
chatRoomService.leaveChatRoom(member, chatRoomId)
ApiResponse.ok(true)
}
/**
* 채팅방 메시지 전송 API
* - 참여 여부 검증(미참여시 "잘못된 접근입니다")
* - 외부 API 호출 (/api/chat, POST) 재시도 최대 3
* - 성공 메시지/캐릭터 메시지 저장 캐릭터 메시지 리스트 반환
*/
@PostMapping("/{chatRoomId}/send")
fun sendMessage(
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?,
@PathVariable chatRoomId: Long,
@RequestBody request: SendChatMessageRequest
) = run {
if (member == null) throw SodaException("로그인 정보를 확인해주세요.")
if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.")
ApiResponse.ok(chatRoomService.sendMessage(member, chatRoomId, request.message))
}
}

View File

@ -93,3 +93,44 @@ data class ExternalCharacterData(
val age: String,
val gender: String
)
/**
* 채팅 메시지 전송 요청 DTO
*/
data class SendChatMessageRequest(
val message: String
)
/**
* 채팅 메시지 전송 응답 DTO (캐릭터 메시지 리스트)
*/
data class SendChatMessageResponse(
val characterMessages: List<ChatMessageItemDto>
)
/**
* 외부 API 채팅 전송 응답 DTO
*/
data class ExternalChatSendResponse(
val success: Boolean,
val message: String?,
val data: ExternalChatSendData?
)
/**
* 외부 API 채팅 전송 데이터 DTO
*/
data class ExternalChatSendData(
val sessionId: String,
val characterResponse: ExternalCharacterMessage
)
/**
* 외부 API 캐릭터 메시지 DTO
*/
data class ExternalCharacterMessage(
val id: String,
val content: String,
val timestamp: String,
val messageType: String
)

View File

@ -18,6 +18,14 @@ interface CharacterChatParticipantRepository : JpaRepository<CharacterChatPartic
member: Member
): CharacterChatParticipant?
/**
* 특정 채팅방에 특정 타입(CHARACTER/USER)으로 활성 상태인 참여자 찾기
*/
fun findByChatRoomAndParticipantTypeAndIsActiveTrue(
chatRoom: CharacterChatRoom,
participantType: ParticipantType
): CharacterChatParticipant?
/**
* 특정 채팅방의 활성 USER 참여자
*/

View File

@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.room.service
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.room.CharacterChatMessage
import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant
import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
@ -9,8 +10,10 @@ import kr.co.vividnext.sodalive.chat.room.dto.ChatMessageItemDto
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListItemDto
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto
import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSendResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionCreateResponse
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageResponse
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatMessageRepository
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository
@ -357,4 +360,131 @@ class ChatRoomService(
)
}
}
@Transactional
fun sendMessage(member: Member, chatRoomId: Long, message: String): SendChatMessageResponse {
// 1) 방 존재 확인
val room = chatRoomRepository.findById(chatRoomId).orElseThrow {
SodaException("채팅방을 찾을 수 없습니다.")
}
// 2) 참여 여부 확인 (USER)
val myParticipant = participantRepository.findByChatRoomAndMemberAndIsActiveTrue(room, member)
?: throw SodaException("잘못된 접근입니다")
// 3) 캐릭터 참여자 조회
val characterParticipant = participantRepository.findByChatRoomAndParticipantTypeAndIsActiveTrue(
room,
ParticipantType.CHARACTER
) ?: throw SodaException("잘못된 접근입니다")
val character = characterParticipant.character
?: throw SodaException("오류가 발생했습니다. 다시 시도해 주세요.")
// 4) 외부 API 호출 준비
val userId = generateUserId(member.id!!)
val sessionId = room.sessionId
val characterUUID = character.characterUUID
// 5) 외부 API 호출 (최대 3회 재시도)
val characterReply = callExternalApiForChatSendWithRetry(userId, characterUUID, message, sessionId)
// 6) 내 메시지 저장
val myMsgEntity = CharacterChatMessage(
message = message,
chatRoom = room,
participant = myParticipant,
isActive = true
)
messageRepository.save(myMsgEntity)
// 7) 캐릭터 메시지 저장
val characterMsgEntity = CharacterChatMessage(
message = characterReply,
chatRoom = room,
participant = characterParticipant,
isActive = true
)
val savedCharacterMsg = messageRepository.save(characterMsgEntity)
// 8) 응답 DTO 구성 (캐릭터 메시지 리스트 반환 - 단일 요소)
val profilePath = characterParticipant.character?.imagePath
val defaultPath = profilePath ?: "profile/default-profile.png"
val imageUrl = "$imageHost/$defaultPath"
val dto = ChatMessageItemDto(
messageId = savedCharacterMsg.id!!,
message = savedCharacterMsg.message,
profileImageUrl = imageUrl,
mine = false
)
return SendChatMessageResponse(characterMessages = listOf(dto))
}
private fun callExternalApiForChatSendWithRetry(
userId: String,
characterUUID: String,
message: String,
sessionId: String
): String {
val maxAttempts = 3
var attempt = 0
while (attempt < maxAttempts) {
attempt++
try {
return callExternalApiForChatSend(userId, characterUUID, message, sessionId)
} catch (e: Exception) {
log.warn("[chat] 외부 채팅 전송 실패 attempt={}, error={}", attempt, e.message)
}
}
log.error("[chat] 외부 채팅 전송 최종 실패 attempts={}", maxAttempts)
throw SodaException("메시지 전송을 실패했습니다.")
}
private fun callExternalApiForChatSend(
userId: String,
characterUUID: String,
message: String,
sessionId: String
): String {
val factory = SimpleClientHttpRequestFactory()
factory.setConnectTimeout(20000)
factory.setReadTimeout(20000)
val restTemplate = RestTemplate(factory)
val headers = HttpHeaders()
headers.set("x-api-key", apiKey)
headers.contentType = MediaType.APPLICATION_JSON
val requestBody = mapOf(
"userId" to userId,
"characterId" to characterUUID,
"message" to message,
"sessionId" to sessionId
)
val httpEntity = HttpEntity(requestBody, headers)
val response = restTemplate.exchange(
"$apiUrl/api/chat",
HttpMethod.POST,
httpEntity,
String::class.java
)
val objectMapper = ObjectMapper()
val apiResponse = objectMapper.readValue(
response.body,
ExternalChatSendResponse::class.java
)
if (!apiResponse.success) {
throw SodaException(apiResponse.message ?: "메시지 전송을 실패했습니다.")
}
val data = apiResponse.data ?: throw SodaException("메시지 전송을 실패했습니다.")
val characterContent = data.characterResponse.content
if (characterContent.isBlank()) {
throw SodaException("메시지 전송을 실패했습니다.")
}
return characterContent
}
}