캐릭터 챗봇 #338
| @@ -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 | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user