캐릭터 챗봇 #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: | server: | ||||||
|     shutdown: graceful |     shutdown: graceful | ||||||
|  |     env: ${SERVER_ENV} | ||||||
|  |  | ||||||
| logging: | logging: | ||||||
|     level: |     level: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user