캐릭터 챗봇 #338
| @@ -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)) | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| ) | ||||
|   | ||||
| @@ -18,6 +18,14 @@ interface CharacterChatParticipantRepository : JpaRepository<CharacterChatPartic | ||||
|         member: Member | ||||
|     ): CharacterChatParticipant? | ||||
|  | ||||
|     /** | ||||
|      * 특정 채팅방에 특정 타입(CHARACTER/USER)으로 활성 상태인 참여자 찾기 | ||||
|      */ | ||||
|     fun findByChatRoomAndParticipantTypeAndIsActiveTrue( | ||||
|         chatRoom: CharacterChatRoom, | ||||
|         participantType: ParticipantType | ||||
|     ): CharacterChatParticipant? | ||||
|  | ||||
|     /** | ||||
|      * 특정 채팅방의 활성 USER 참여자 수 | ||||
|      */ | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user