캐릭터 챗봇 #338
| @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.chat.character | |||||||
| import com.amazonaws.services.s3.model.ObjectMetadata | import com.amazonaws.services.s3.model.ObjectMetadata | ||||||
| import com.fasterxml.jackson.databind.ObjectMapper | import com.fasterxml.jackson.databind.ObjectMapper | ||||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest | import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRegisterRequest | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest | ||||||
| import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse | import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse | ||||||
| import kr.co.vividnext.sodalive.aws.s3.S3Uploader | import kr.co.vividnext.sodalive.aws.s3.S3Uploader | ||||||
| import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService | ||||||
| @@ -19,6 +20,7 @@ import org.springframework.retry.annotation.Backoff | |||||||
| import org.springframework.retry.annotation.Retryable | import org.springframework.retry.annotation.Retryable | ||||||
| import org.springframework.security.access.prepost.PreAuthorize | import org.springframework.security.access.prepost.PreAuthorize | ||||||
| import org.springframework.web.bind.annotation.PostMapping | import org.springframework.web.bind.annotation.PostMapping | ||||||
|  | import org.springframework.web.bind.annotation.PutMapping | ||||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||||
| import org.springframework.web.bind.annotation.RequestPart | import org.springframework.web.bind.annotation.RequestPart | ||||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||||
| @@ -144,4 +146,162 @@ class AdminChatCharacterController( | |||||||
|             throw SodaException("이미지 저장에 실패했습니다: ${e.message}") |             throw SodaException("이미지 저장에 실패했습니다: ${e.message}") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 캐릭터 수정 API | ||||||
|  |      * 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환 | ||||||
|  |      * 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인 | ||||||
|  |      * 3. 이미지 있는지 확인 | ||||||
|  |      * 4. 2, 3번 중 하나라도 해당 하면 계속 진행 | ||||||
|  |      * 5. 2, 3번에 데이터 없으면 throw SodaException('변경된 데이터가 없습니다.') | ||||||
|  |      * | ||||||
|  |      * @param image 캐릭터 이미지 (선택적) | ||||||
|  |      * @param requestString ChatCharacterUpdateRequest 객체를 JSON 문자열로 변환한 값 | ||||||
|  |      * @return ApiResponse 객체 | ||||||
|  |      * @throws SodaException 변경된 데이터가 없거나 캐릭터를 찾을 수 없는 경우 | ||||||
|  |      */ | ||||||
|  |     @PutMapping("/update") | ||||||
|  |     @Retryable( | ||||||
|  |         value = [Exception::class], | ||||||
|  |         maxAttempts = 3, | ||||||
|  |         backoff = Backoff(delay = 1000) | ||||||
|  |     ) | ||||||
|  |     fun updateCharacter( | ||||||
|  |         @RequestPart(value = "image", required = false) image: MultipartFile?, | ||||||
|  |         @RequestPart("request") requestString: String | ||||||
|  |     ) = run { | ||||||
|  |         // 1. JSON 문자열을 ChatCharacterUpdateRequest 객체로 변환 | ||||||
|  |         val objectMapper = ObjectMapper() | ||||||
|  |         val request = objectMapper.readValue(requestString, ChatCharacterUpdateRequest::class.java) | ||||||
|  |  | ||||||
|  |         // 2. ChatCharacterUpdateRequest를 확인해서 변경한 데이터가 있는지 확인 | ||||||
|  |         val hasChangedData = hasChanges(request) | ||||||
|  |  | ||||||
|  |         // 3. 이미지 있는지 확인 | ||||||
|  |         val hasImage = image != null && !image.isEmpty | ||||||
|  |  | ||||||
|  |         if (!hasChangedData && !hasImage) { | ||||||
|  |             throw SodaException("변경된 데이터가 없습니다.") | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 변경된 데이터가 있으면 외부 API 호출 | ||||||
|  |         if (hasChangedData) { | ||||||
|  |             val chatCharacter = service.findById(request.id) | ||||||
|  |                 ?: throw SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") | ||||||
|  |             callExternalApiForUpdate(chatCharacter.characterUUID, request) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 이미지 경로 변수 초기화 | ||||||
|  |         // 이미지가 있으면 이미지 저장 | ||||||
|  |         val imagePath = if (hasImage) { | ||||||
|  |             saveImage( | ||||||
|  |                 characterId = request.id, | ||||||
|  |                 image = image!! | ||||||
|  |             ) | ||||||
|  |         } else { | ||||||
|  |             null | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 엔티티 수정 | ||||||
|  |         service.updateChatCharacterWithDetails( | ||||||
|  |             imagePath = imagePath, | ||||||
|  |             request = request | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(null) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 요청에 변경된 데이터가 있는지 확인 | ||||||
|  |      * id를 제외한 모든 필드가 null이면 변경된 데이터가 없는 것으로 판단 | ||||||
|  |      * | ||||||
|  |      * @param request 수정 요청 데이터 | ||||||
|  |      * @return 변경된 데이터가 있으면 true, 없으면 false | ||||||
|  |      */ | ||||||
|  |     private fun hasChanges(request: ChatCharacterUpdateRequest): Boolean { | ||||||
|  |         return request.systemPrompt != null || | ||||||
|  |             request.description != null || | ||||||
|  |             request.age != null || | ||||||
|  |             request.gender != null || | ||||||
|  |             request.mbti != null || | ||||||
|  |             request.speechPattern != null || | ||||||
|  |             request.speechStyle != null || | ||||||
|  |             request.appearance != null || | ||||||
|  |             request.isActive != null || | ||||||
|  |             request.tags != null || | ||||||
|  |             request.hobbies != null || | ||||||
|  |             request.values != null || | ||||||
|  |             request.goals != null || | ||||||
|  |             request.relationships != null || | ||||||
|  |             request.personalities != null || | ||||||
|  |             request.backgrounds != null || | ||||||
|  |             request.memories != null || | ||||||
|  |             request.name != null | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 외부 API 호출 - 수정 API | ||||||
|  |      * 변경된 데이터만 요청에 포함 | ||||||
|  |      * | ||||||
|  |      * @param characterUUID 캐릭터 UUID | ||||||
|  |      * @param request 수정 요청 데이터 | ||||||
|  |      */ | ||||||
|  |     private fun callExternalApiForUpdate(characterUUID: String, request: ChatCharacterUpdateRequest) { | ||||||
|  |         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 | ||||||
|  |  | ||||||
|  |             // 변경된 데이터만 포함하는 맵 생성 | ||||||
|  |             val updateData = mutableMapOf<String, Any>() | ||||||
|  |  | ||||||
|  |             if (request.isActive != null && !request.isActive) { | ||||||
|  |                 updateData["name"] = "inactive_${request.name}" | ||||||
|  |             } else { | ||||||
|  |                 request.name?.let { updateData["name"] = it } | ||||||
|  |                 request.systemPrompt?.let { updateData["systemPrompt"] = it } | ||||||
|  |                 request.description?.let { updateData["description"] = it } | ||||||
|  |                 request.age?.let { updateData["age"] = it } | ||||||
|  |                 request.gender?.let { updateData["gender"] = it } | ||||||
|  |                 request.mbti?.let { updateData["mbti"] = it } | ||||||
|  |                 request.speechPattern?.let { updateData["speechPattern"] = it } | ||||||
|  |                 request.speechStyle?.let { updateData["speechStyle"] = it } | ||||||
|  |                 request.appearance?.let { updateData["appearance"] = it } | ||||||
|  |                 request.tags?.let { updateData["tags"] = it } | ||||||
|  |                 request.hobbies?.let { updateData["hobbies"] = it } | ||||||
|  |                 request.values?.let { updateData["values"] = it } | ||||||
|  |                 request.goals?.let { updateData["goals"] = it } | ||||||
|  |                 request.relationships?.let { updateData["relationships"] = it } | ||||||
|  |                 request.personalities?.let { updateData["personalities"] = it } | ||||||
|  |                 request.backgrounds?.let { updateData["backgrounds"] = it } | ||||||
|  |                 request.memories?.let { updateData["memories"] = it } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val httpEntity = HttpEntity(updateData, headers) | ||||||
|  |  | ||||||
|  |             val response = restTemplate.exchange( | ||||||
|  |                 "$apiUrl/api/characters/$characterUUID", | ||||||
|  |                 HttpMethod.PUT, | ||||||
|  |                 httpEntity, | ||||||
|  |                 String::class.java | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             // 응답 파싱 | ||||||
|  |             val objectMapper = ObjectMapper() | ||||||
|  |             val apiResponse = objectMapper.readValue(response.body, ExternalApiResponse::class.java) | ||||||
|  |  | ||||||
|  |             // success가 false이면 throw | ||||||
|  |             if (!apiResponse.success) { | ||||||
|  |                 throw SodaException(apiResponse.message ?: "수정에 실패했습니다. 다시 시도해 주세요.") | ||||||
|  |             } | ||||||
|  |         } catch (_: Exception) { | ||||||
|  |             throw SodaException("수정에 실패했습니다. 다시 시도해 주세요.") | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -46,3 +46,25 @@ data class ExternalApiResponse( | |||||||
| data class ExternalApiData( | data class ExternalApiData( | ||||||
|     val id: String |     val id: String | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | data class ChatCharacterUpdateRequest( | ||||||
|  |     val id: Long, | ||||||
|  |     val name: String? = null, | ||||||
|  |     val systemPrompt: String? = null, | ||||||
|  |     val description: String? = null, | ||||||
|  |     val age: String? = null, | ||||||
|  |     val gender: String? = null, | ||||||
|  |     val mbti: String? = null, | ||||||
|  |     val speechPattern: String? = null, | ||||||
|  |     val speechStyle: String? = null, | ||||||
|  |     val appearance: String? = null, | ||||||
|  |     val isActive: Boolean? = null, | ||||||
|  |     val tags: List<String>? = null, | ||||||
|  |     val hobbies: List<String>? = null, | ||||||
|  |     val values: List<String>? = null, | ||||||
|  |     val goals: List<String>? = null, | ||||||
|  |     val relationships: List<String>? = null, | ||||||
|  |     val personalities: List<ChatCharacterPersonalityRequest>? = null, | ||||||
|  |     val backgrounds: List<ChatCharacterBackgroundRequest>? = null, | ||||||
|  |     val memories: List<ChatCharacterMemoryRequest>? = null | ||||||
|  | ) | ||||||
|   | |||||||
| @@ -12,37 +12,37 @@ class ChatCharacter( | |||||||
|     val characterUUID: String, |     val characterUUID: String, | ||||||
|  |  | ||||||
|     // 캐릭터 이름 (API 키 내에서 유일해야 함) |     // 캐릭터 이름 (API 키 내에서 유일해야 함) | ||||||
|     val name: String, |     var name: String, | ||||||
|  |  | ||||||
|     // 캐릭터 설명 |     // 캐릭터 설명 | ||||||
|     @Column(columnDefinition = "TEXT", nullable = false) |     @Column(columnDefinition = "TEXT", nullable = false) | ||||||
|     val description: String, |     var description: String, | ||||||
|  |  | ||||||
|     // AI 시스템 프롬프트 |     // AI 시스템 프롬프트 | ||||||
|     @Column(columnDefinition = "TEXT", nullable = false) |     @Column(columnDefinition = "TEXT", nullable = false) | ||||||
|     val systemPrompt: String, |     var systemPrompt: String, | ||||||
|  |  | ||||||
|     // 나이 |     // 나이 | ||||||
|     val age: Int? = null, |     var age: Int? = null, | ||||||
|  |  | ||||||
|     // 성별 |     // 성별 | ||||||
|     val gender: String? = null, |     var gender: String? = null, | ||||||
|  |  | ||||||
|     // mbti |     // mbti | ||||||
|     val mbti: String? = null, |     var mbti: String? = null, | ||||||
|  |  | ||||||
|     // 말투 패턴 설명 |     // 말투 패턴 설명 | ||||||
|     @Column(columnDefinition = "TEXT") |     @Column(columnDefinition = "TEXT") | ||||||
|     val speechPattern: String? = null, |     var speechPattern: String? = null, | ||||||
|  |  | ||||||
|     // 대화 스타일 |     // 대화 스타일 | ||||||
|     val speechStyle: String? = null, |     var speechStyle: String? = null, | ||||||
|  |  | ||||||
|     // 외모 설명 |     // 외모 설명 | ||||||
|     @Column(columnDefinition = "TEXT") |     @Column(columnDefinition = "TEXT") | ||||||
|     val appearance: String? = null, |     var appearance: String? = null, | ||||||
|  |  | ||||||
|     val isActive: Boolean = true |     var isActive: Boolean = true | ||||||
| ) : BaseEntity() { | ) : BaseEntity() { | ||||||
|     var imagePath: String? = null |     var imagePath: String? = null | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,6 @@ | |||||||
| package kr.co.vividnext.sodalive.chat.character.service | package kr.co.vividnext.sodalive.chat.character.service | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest | ||||||
| import kr.co.vividnext.sodalive.chat.character.ChatCharacter | import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||||
| import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal | import kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal | ||||||
| import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby | import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby | ||||||
| @@ -134,6 +135,14 @@ class ChatCharacterService( | |||||||
|         return chatCharacterRepository.findAll() |         return chatCharacterRepository.findAll() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * ID로 캐릭터 조회 | ||||||
|  |      */ | ||||||
|  |     @Transactional(readOnly = true) | ||||||
|  |     fun findById(id: Long): ChatCharacter? { | ||||||
|  |         return chatCharacterRepository.findById(id).orElse(null) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
|      * 캐릭터 생성 및 관련 엔티티 연결 |      * 캐릭터 생성 및 관련 엔티티 연결 | ||||||
|      */ |      */ | ||||||
| @@ -260,4 +269,100 @@ class ChatCharacterService( | |||||||
|  |  | ||||||
|         return saveChatCharacter(chatCharacter) |         return saveChatCharacter(chatCharacter) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 캐릭터 수정 시 기본 정보와 함께 추가 정보도 설정 | ||||||
|  |      * 이름은 변경할 수 없으므로 이름은 변경하지 않음 | ||||||
|  |      * 변경된 데이터만 업데이트 | ||||||
|  |      * | ||||||
|  |      * @param imagePath 이미지 경로 (null이면 이미지 변경 없음) | ||||||
|  |      * @param request 수정 요청 데이터 (id를 제외한 모든 필드는 null 가능) | ||||||
|  |      * @return 수정된 ChatCharacter 객체 | ||||||
|  |      * @throws SodaException 캐릭터를 찾을 수 없는 경우 | ||||||
|  |      */ | ||||||
|  |     @Transactional | ||||||
|  |     fun updateChatCharacterWithDetails( | ||||||
|  |         imagePath: String? = null, | ||||||
|  |         request: ChatCharacterUpdateRequest | ||||||
|  |     ): ChatCharacter { | ||||||
|  |         // 캐릭터 조회 | ||||||
|  |         val chatCharacter = findById(request.id) | ||||||
|  |             ?: throw kr.co.vividnext.sodalive.common.SodaException("해당 ID의 캐릭터를 찾을 수 없습니다: ${request.id}") | ||||||
|  |  | ||||||
|  |         // isActive가 false이면 isActive = false, name = "inactive_$name"으로 변경하고 나머지는 반영하지 않는다. | ||||||
|  |         if (request.isActive != null && !request.isActive) { | ||||||
|  |             chatCharacter.isActive = false | ||||||
|  |             chatCharacter.name = "inactive_${chatCharacter.name}" | ||||||
|  |  | ||||||
|  |             return saveChatCharacter(chatCharacter) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 이미지 경로가 있으면 설정 | ||||||
|  |         if (imagePath != null) { | ||||||
|  |             chatCharacter.imagePath = imagePath | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 기본 필드 업데이트 - 변경된 데이터만 업데이트 | ||||||
|  |         request.name?.let { chatCharacter.name = it } | ||||||
|  |         request.systemPrompt?.let { chatCharacter.systemPrompt = it } | ||||||
|  |         request.description?.let { chatCharacter.description = it } | ||||||
|  |         request.age?.toIntOrNull()?.let { chatCharacter.age = it } | ||||||
|  |         request.gender?.let { chatCharacter.gender = it } | ||||||
|  |         request.mbti?.let { chatCharacter.mbti = it } | ||||||
|  |         request.speechPattern?.let { chatCharacter.speechPattern = it } | ||||||
|  |         request.speechStyle?.let { chatCharacter.speechStyle = it } | ||||||
|  |         request.appearance?.let { chatCharacter.appearance = it } | ||||||
|  |  | ||||||
|  |         // request에서 변경된 데이터만 업데이트 | ||||||
|  |         if (request.tags != null) { | ||||||
|  |             chatCharacter.tagMappings.clear() | ||||||
|  |             addTagsToCharacter(chatCharacter, request.tags) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.values != null) { | ||||||
|  |             chatCharacter.valueMappings.clear() | ||||||
|  |             addValuesToCharacter(chatCharacter, request.values) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.hobbies != null) { | ||||||
|  |             chatCharacter.hobbyMappings.clear() | ||||||
|  |             addHobbiesToCharacter(chatCharacter, request.hobbies) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.goals != null) { | ||||||
|  |             chatCharacter.goalMappings.clear() | ||||||
|  |             addGoalsToCharacter(chatCharacter, request.goals) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 추가 정보 설정 - 변경된 데이터만 업데이트 | ||||||
|  |         if (request.memories != null) { | ||||||
|  |             chatCharacter.memories.clear() | ||||||
|  |             request.memories.forEach { memory -> | ||||||
|  |                 chatCharacter.addMemory(memory.title, memory.content, memory.emotion) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.personalities != null) { | ||||||
|  |             chatCharacter.personalities.clear() | ||||||
|  |             request.personalities.forEach { personality -> | ||||||
|  |                 chatCharacter.addPersonality(personality.trait, personality.description) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.backgrounds != null) { | ||||||
|  |             chatCharacter.backgrounds.clear() | ||||||
|  |             request.backgrounds.forEach { background -> | ||||||
|  |                 chatCharacter.addBackground(background.topic, background.description) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (request.relationships != null) { | ||||||
|  |             chatCharacter.relationships.clear() | ||||||
|  |             request.relationships.forEach { relationship -> | ||||||
|  |                 chatCharacter.addRelationship(relationship) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return saveChatCharacter(chatCharacter) | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user