캐릭터 챗봇 #338
| @@ -0,0 +1,45 @@ | ||||
| package kr.co.vividnext.sodalive.chat.room.controller | ||||
|  | ||||
| import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomRequest | ||||
| import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService | ||||
| import kr.co.vividnext.sodalive.common.ApiResponse | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import org.springframework.security.core.annotation.AuthenticationPrincipal | ||||
| import org.springframework.web.bind.annotation.PostMapping | ||||
| import org.springframework.web.bind.annotation.RequestBody | ||||
| import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RestController | ||||
|  | ||||
| @RestController | ||||
| @RequestMapping("/api/chat/room") | ||||
| class ChatRoomController( | ||||
|     private val chatRoomService: ChatRoomService | ||||
| ) { | ||||
|  | ||||
|     /** | ||||
|      * 채팅방 생성 API | ||||
|      * | ||||
|      * 1. 캐릭터 ID, 유저 ID가 참여 중인 채팅방이 있는지 확인 | ||||
|      * 2. 있으면 채팅방 ID 반환 | ||||
|      * 3. 없으면 외부 API 호출 | ||||
|      * 4. 성공시 외부 API에서 가져오는 sessionId를 포함하여 채팅방 생성 | ||||
|      * 5. 채팅방 참여자로 캐릭터와 유저 추가 | ||||
|      * 6. 채팅방 ID 반환 | ||||
|      * | ||||
|      * @param member 인증된 사용자 | ||||
|      * @param request 채팅방 생성 요청 DTO | ||||
|      * @return 채팅방 ID | ||||
|      */ | ||||
|     @PostMapping("/create") | ||||
|     fun createChatRoom( | ||||
|         @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?, | ||||
|         @RequestBody request: CreateChatRoomRequest | ||||
|     ) = run { | ||||
|         if (member == null) throw SodaException("로그인 정보를 확인해주세요.") | ||||
|         if (member.auth == null) throw SodaException("본인인증을 하셔야 합니다.") | ||||
|  | ||||
|         val response = chatRoomService.createOrGetChatRoom(member, request.characterId) | ||||
|         ApiResponse.ok(response) | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,46 @@ | ||||
| package kr.co.vividnext.sodalive.chat.room.dto | ||||
|  | ||||
| /** | ||||
|  * 채팅방 생성 요청 DTO | ||||
|  */ | ||||
| data class CreateChatRoomRequest( | ||||
|     val characterId: Long | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 채팅방 생성 응답 DTO | ||||
|  */ | ||||
| data class CreateChatRoomResponse( | ||||
|     val chatRoomId: Long | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 외부 API 채팅 세션 응답 DTO | ||||
|  */ | ||||
| data class ExternalChatSessionResponse( | ||||
|     val success: Boolean, | ||||
|     val message: String?, | ||||
|     val data: ExternalChatSessionData? | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 외부 API 채팅 세션 데이터 DTO | ||||
|  */ | ||||
| data class ExternalChatSessionData( | ||||
|     val sessionId: String, | ||||
|     val userId: String, | ||||
|     val characterId: String, | ||||
|     val character: ExternalCharacterData, | ||||
|     val status: String, | ||||
|     val createdAt: String | ||||
| ) | ||||
|  | ||||
| /** | ||||
|  * 외부 API 캐릭터 데이터 DTO | ||||
|  */ | ||||
| data class ExternalCharacterData( | ||||
|     val id: String, | ||||
|     val name: String, | ||||
|     val age: String, | ||||
|     val gender: String | ||||
| ) | ||||
| @@ -0,0 +1,36 @@ | ||||
| package kr.co.vividnext.sodalive.chat.room.repository | ||||
|  | ||||
| import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatParticipant | ||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import org.springframework.data.jpa.repository.JpaRepository | ||||
| import org.springframework.stereotype.Repository | ||||
|  | ||||
| @Repository | ||||
| interface CharacterChatParticipantRepository : JpaRepository<CharacterChatParticipant, Long> { | ||||
|  | ||||
|     /** | ||||
|      * 특정 채팅방에 참여 중인 멤버 참여자 찾기 | ||||
|      * | ||||
|      * @param chatRoom 채팅방 | ||||
|      * @param member 멤버 | ||||
|      * @return 채팅방 참여자 (없으면 null) | ||||
|      */ | ||||
|     fun findByChatRoomAndMemberAndIsActiveTrue( | ||||
|         chatRoom: CharacterChatRoom, | ||||
|         member: Member | ||||
|     ): CharacterChatParticipant? | ||||
|  | ||||
|     /** | ||||
|      * 특정 채팅방에 참여 중인 캐릭터 참여자 찾기 | ||||
|      * | ||||
|      * @param chatRoom 채팅방 | ||||
|      * @param character 캐릭터 | ||||
|      * @return 채팅방 참여자 (없으면 null) | ||||
|      */ | ||||
|     fun findByChatRoomAndCharacterAndIsActiveTrue( | ||||
|         chatRoom: CharacterChatRoom, | ||||
|         character: ChatCharacter | ||||
|     ): CharacterChatParticipant? | ||||
| } | ||||
| @@ -0,0 +1,35 @@ | ||||
| package kr.co.vividnext.sodalive.chat.room.repository | ||||
|  | ||||
| import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import org.springframework.data.jpa.repository.JpaRepository | ||||
| import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | ||||
| import org.springframework.stereotype.Repository | ||||
|  | ||||
| @Repository | ||||
| interface CharacterChatRoomRepository : JpaRepository<CharacterChatRoom, Long> { | ||||
|  | ||||
|     /** | ||||
|      * 특정 멤버와 캐릭터가 참여 중인 활성화된 채팅방을 찾는 쿼리 | ||||
|      * | ||||
|      * @param member 멤버 | ||||
|      * @param character 캐릭터 | ||||
|      * @return 활성화된 채팅방 (없으면 null) | ||||
|      */ | ||||
|     @Query( | ||||
|         """ | ||||
|         SELECT DISTINCT r FROM CharacterChatRoom r | ||||
|         JOIN r.participants p1 | ||||
|         JOIN r.participants p2 | ||||
|         WHERE p1.member = :member AND p1.isActive = true | ||||
|         AND p2.character = :character AND p2.isActive = true | ||||
|         AND r.isActive = true | ||||
|     """ | ||||
|     ) | ||||
|     fun findActiveChatRoomByMemberAndCharacter( | ||||
|         @Param("member") member: Member, | ||||
|         @Param("character") character: ChatCharacter | ||||
|     ): CharacterChatRoom? | ||||
| } | ||||
| @@ -0,0 +1,167 @@ | ||||
| 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.CharacterChatParticipant | ||||
| import kr.co.vividnext.sodalive.chat.room.CharacterChatRoom | ||||
| import kr.co.vividnext.sodalive.chat.room.ParticipantType | ||||
| import kr.co.vividnext.sodalive.chat.room.dto.CreateChatRoomResponse | ||||
| import kr.co.vividnext.sodalive.chat.room.dto.ExternalChatSessionResponse | ||||
| import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatParticipantRepository | ||||
| import kr.co.vividnext.sodalive.chat.room.repository.CharacterChatRoomRepository | ||||
| import kr.co.vividnext.sodalive.common.SodaException | ||||
| import kr.co.vividnext.sodalive.member.Member | ||||
| import org.springframework.beans.factory.annotation.Value | ||||
| import org.springframework.http.HttpEntity | ||||
| import org.springframework.http.HttpHeaders | ||||
| import org.springframework.http.HttpMethod | ||||
| import org.springframework.http.MediaType | ||||
| import org.springframework.http.client.SimpleClientHttpRequestFactory | ||||
| import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | ||||
| import org.springframework.web.client.RestTemplate | ||||
| import java.util.UUID | ||||
|  | ||||
| @Service | ||||
| class ChatRoomService( | ||||
|     private val chatRoomRepository: CharacterChatRoomRepository, | ||||
|     private val participantRepository: CharacterChatParticipantRepository, | ||||
|     private val characterService: ChatCharacterService, | ||||
|  | ||||
|     @Value("\${weraser.api-key}") | ||||
|     private val apiKey: String, | ||||
|  | ||||
|     @Value("\${weraser.api-url}") | ||||
|     private val apiUrl: String, | ||||
|  | ||||
|     @Value("\${server.env}") | ||||
|     private val serverEnv: String | ||||
| ) { | ||||
|  | ||||
|     /** | ||||
|      * 채팅방 생성 또는 조회 | ||||
|      * | ||||
|      * @param member 멤버 | ||||
|      * @param characterId 캐릭터 ID | ||||
|      * @return 채팅방 ID | ||||
|      */ | ||||
|     @Transactional | ||||
|     fun createOrGetChatRoom(member: Member, characterId: Long): CreateChatRoomResponse { | ||||
|         // 1. 캐릭터 조회 | ||||
|         val character = characterService.findById(characterId) | ||||
|             ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: $characterId") | ||||
|  | ||||
|         // 2. 이미 참여 중인 채팅방이 있는지 확인 | ||||
|         val existingChatRoom = chatRoomRepository.findActiveChatRoomByMemberAndCharacter(member, character) | ||||
|  | ||||
|         // 3. 있으면 채팅방 ID 반환 | ||||
|         if (existingChatRoom != null) { | ||||
|             return CreateChatRoomResponse(chatRoomId = existingChatRoom.id!!) | ||||
|         } | ||||
|  | ||||
|         // 4. 없으면 외부 API 호출하여 세션 생성 | ||||
|         val userId = generateUserId(member.id!!) | ||||
|         val sessionId = callExternalApiForChatSession(userId, character.characterUUID) | ||||
|  | ||||
|         // 5. 채팅방 생성 | ||||
|         val chatRoom = CharacterChatRoom( | ||||
|             sessionId = sessionId, | ||||
|             title = character.name, | ||||
|             isActive = true | ||||
|         ) | ||||
|         val savedChatRoom = chatRoomRepository.save(chatRoom) | ||||
|  | ||||
|         // 6. 채팅방 참여자 추가 (멤버) | ||||
|         val memberParticipant = CharacterChatParticipant( | ||||
|             chatRoom = savedChatRoom, | ||||
|             participantType = ParticipantType.USER, | ||||
|             member = member, | ||||
|             character = null, | ||||
|             isActive = true | ||||
|         ) | ||||
|         participantRepository.save(memberParticipant) | ||||
|  | ||||
|         // 7. 채팅방 참여자 추가 (캐릭터) | ||||
|         val characterParticipant = CharacterChatParticipant( | ||||
|             chatRoom = savedChatRoom, | ||||
|             participantType = ParticipantType.CHARACTER, | ||||
|             member = null, | ||||
|             character = character, | ||||
|             isActive = true | ||||
|         ) | ||||
|         participantRepository.save(characterParticipant) | ||||
|  | ||||
|         // 8. 채팅방 ID 반환 | ||||
|         return CreateChatRoomResponse(chatRoomId = savedChatRoom.id!!) | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 유저 ID 생성 | ||||
|      * "$serverEnv_user_$유저번호"를 UUID로 변환 | ||||
|      * | ||||
|      * @param memberId 멤버 ID | ||||
|      * @return UUID 형태의 유저 ID | ||||
|      */ | ||||
|     private fun generateUserId(memberId: Long): String { | ||||
|         val userIdString = "${serverEnv}_user_$memberId" | ||||
|         return UUID.nameUUIDFromBytes(userIdString.toByteArray()).toString() | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * 외부 API 호출하여 채팅 세션 생성 | ||||
|      * | ||||
|      * @param userId 유저 ID (UUID) | ||||
|      * @param characterUUID 캐릭터 UUID | ||||
|      * @return 세션 ID | ||||
|      */ | ||||
|     private fun callExternalApiForChatSession(userId: String, characterUUID: String): String { | ||||
|         try { | ||||
|             val factory = SimpleClientHttpRequestFactory() | ||||
|             factory.setConnectTimeout(20000) // 20초 | ||||
|             factory.setReadTimeout(20000) // 20초 | ||||
|  | ||||
|             val restTemplate = RestTemplate(factory) | ||||
|  | ||||
|             val headers = HttpHeaders() | ||||
|             headers.set("x-api-key", apiKey) | ||||
|             headers.contentType = MediaType.APPLICATION_JSON | ||||
|  | ||||
|             // 요청 바디 생성 - userId와 characterId 전달 | ||||
|             val requestBody = mapOf( | ||||
|                 "userId" to userId, | ||||
|                 "characterId" to characterUUID | ||||
|             ) | ||||
|  | ||||
|             val httpEntity = HttpEntity(requestBody, headers) | ||||
|  | ||||
|             val response = restTemplate.exchange( | ||||
|                 "$apiUrl/api/session", | ||||
|                 HttpMethod.POST, | ||||
|                 httpEntity, | ||||
|                 String::class.java | ||||
|             ) | ||||
|  | ||||
|             // 응답 파싱 | ||||
|             val objectMapper = ObjectMapper() | ||||
|             val apiResponse = objectMapper.readValue(response.body, ExternalChatSessionResponse::class.java) | ||||
|  | ||||
|             // success가 false이면 throw | ||||
|             if (!apiResponse.success) { | ||||
|                 throw SodaException(apiResponse.message ?: "채팅방 생성에 실패했습니다. 다시 시도해 주세요.") | ||||
|             } | ||||
|  | ||||
|             // success가 true이면 파라미터로 넘긴 값과 일치하는지 확인 | ||||
|             val data = apiResponse.data ?: throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") | ||||
|  | ||||
|             if (data.userId != userId && data.characterId != characterUUID && data.status != "active") { | ||||
|                 throw SodaException("채팅방 생성에 실패했습니다. 다시 시도해 주세요.") | ||||
|             } | ||||
|  | ||||
|             // 세션 ID 반환 | ||||
|             return data.sessionId | ||||
|         } catch (e: Exception) { | ||||
|             e.printStackTrace() | ||||
|             throw SodaException("${e.message}, 채팅방 생성에 실패했습니다. 다시 시도해 주세요.") | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| server: | ||||
|     shutdown: graceful | ||||
|     env: ${SERVER_ENV} | ||||
|  | ||||
| logging: | ||||
|     level: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user