feat(character): 캐릭터 수정 API 구현
- ChatCharacterUpdateRequest 클래스 추가 (모든 필드 nullable) - ChatCharacter 엔티티의 필드를 var로 변경하여 수정 가능하게 함 - 이미지 포함/제외 수정 API를 하나로 통합 - 변경된 데이터만 업데이트하도록 구현 - isActive가 false인 경우 특별 처리 추가
This commit is contained in:
parent
de6642b675
commit
5132a6b9fa
|
@ -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<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(
|
||||
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,
|
||||
|
||||
// 캐릭터 이름 (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
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue