diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt index bea7f13..070719e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/AdminChatCharacterController.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.admin.chat.character import com.amazonaws.services.s3.model.ObjectMetadata 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.ChatCharacterUpdateRequest import kr.co.vividnext.sodalive.admin.chat.character.dto.ExternalApiResponse import kr.co.vividnext.sodalive.aws.s3.S3Uploader 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.security.access.prepost.PreAuthorize 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.RequestPart import org.springframework.web.bind.annotation.RestController @@ -144,4 +146,162 @@ class AdminChatCharacterController( 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() + + 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("수정에 실패했습니다. 다시 시도해 주세요.") + } + } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt index 299bdfd..94cf606 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/dto/ChatCharacterDto.kt @@ -46,3 +46,25 @@ data class ExternalApiResponse( data class ExternalApiData( 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? = null, + val hobbies: List? = null, + val values: List? = null, + val goals: List? = null, + val relationships: List? = null, + val personalities: List? = null, + val backgrounds: List? = null, + val memories: List? = null +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt index 42fd8f0..a5a3e86 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt @@ -12,37 +12,37 @@ class ChatCharacter( val characterUUID: String, // 캐릭터 이름 (API 키 내에서 유일해야 함) - val name: String, + var name: String, // 캐릭터 설명 @Column(columnDefinition = "TEXT", nullable = false) - val description: String, + var description: String, // AI 시스템 프롬프트 @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 - val mbti: String? = null, + var mbti: String? = null, // 말투 패턴 설명 @Column(columnDefinition = "TEXT") - val speechPattern: String? = null, + var speechPattern: String? = null, // 대화 스타일 - val speechStyle: String? = null, + var speechStyle: String? = null, // 외모 설명 @Column(columnDefinition = "TEXT") - val appearance: String? = null, + var appearance: String? = null, - val isActive: Boolean = true + var isActive: Boolean = true ) : BaseEntity() { var imagePath: String? = null diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index 3ffad40..af72b69 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -1,5 +1,6 @@ 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.ChatCharacterGoal import kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby @@ -134,6 +135,14 @@ class ChatCharacterService( 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) } + + /** + * 캐릭터 수정 시 기본 정보와 함께 추가 정보도 설정 + * 이름은 변경할 수 없으므로 이름은 변경하지 않음 + * 변경된 데이터만 업데이트 + * + * @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) + } }