feat(chat): 채팅방 메시지 전송 API 구현
This commit is contained in:
parent
002f2c2834
commit
4b3463e97c
|
@ -1,6 +1,7 @@
|
||||||
package kr.co.vividnext.sodalive.chat.room.controller
|
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.CreateChatRoomRequest
|
||||||
|
import kr.co.vividnext.sodalive.chat.room.dto.SendChatMessageRequest
|
||||||
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
|
||||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||||
import kr.co.vividnext.sodalive.common.SodaException
|
import kr.co.vividnext.sodalive.common.SodaException
|
||||||
|
@ -116,4 +117,22 @@ class ChatRoomController(
|
||||||
chatRoomService.leaveChatRoom(member, chatRoomId)
|
chatRoomService.leaveChatRoom(member, chatRoomId)
|
||||||
ApiResponse.ok(true)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,3 +93,44 @@ data class ExternalCharacterData(
|
||||||
val age: String,
|
val age: String,
|
||||||
val gender: 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
|
||||||
|
)
|
||||||
|
|
|
@ -18,6 +18,14 @@ interface CharacterChatParticipantRepository : JpaRepository<CharacterChatPartic
|
||||||
member: Member
|
member: Member
|
||||||
): CharacterChatParticipant?
|
): CharacterChatParticipant?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 채팅방에 특정 타입(CHARACTER/USER)으로 활성 상태인 참여자 찾기
|
||||||
|
*/
|
||||||
|
fun findByChatRoomAndParticipantTypeAndIsActiveTrue(
|
||||||
|
chatRoom: CharacterChatRoom,
|
||||||
|
participantType: ParticipantType
|
||||||
|
): CharacterChatParticipant?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 채팅방의 활성 USER 참여자 수
|
* 특정 채팅방의 활성 USER 참여자 수
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.room.service
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
|
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.CharacterChatParticipant
|
||||||
import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom
|
import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom
|
||||||
import kr.co.vividnext.sodalive.chat.room.ParticipantType
|
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.ChatRoomListItemDto
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ChatRoomListQueryDto
|
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.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.ExternalChatSessionCreateResponse
|
||||||
import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionGetResponse
|
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.CharacterChatMessageRepository
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository
|
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository
|
||||||
import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue