feat(ai-character): 캐릭터 관리자 API를 추가한다

This commit is contained in:
2026-07-28 01:32:53 +09:00
parent 54bbfc75cf
commit 90c0182ae1
11 changed files with 2539 additions and 0 deletions

View File

@@ -1,8 +1,11 @@
package kr.co.vividnext.sodalive.chat.character.repository
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
@@ -53,6 +56,48 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
pageable: Pageable
): Page<ChatCharacter>
@EntityGraph(attributePaths = ["creatorMember", "originalWork"])
@Query(
value = """
SELECT DISTINCT c FROM ChatCharacter c
JOIN c.creatorMember cm
LEFT JOIN c.tagMappings tm
LEFT JOIN tm.tag t
WHERE c.isActive = true
AND cm.role = :role
AND cm.memberKind = :memberKind
AND (
:searchTerm = '' OR
LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
(c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR
(t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%')))
)
""",
countQuery = """
SELECT COUNT(DISTINCT c) FROM ChatCharacter c
JOIN c.creatorMember cm
LEFT JOIN c.tagMappings tm
LEFT JOIN tm.tag t
WHERE c.isActive = true
AND cm.role = :role
AND cm.memberKind = :memberKind
AND (
:searchTerm = '' OR
LOWER(c.name) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
LOWER(c.description) LIKE LOWER(CONCAT('%', :searchTerm, '%')) OR
(c.mbti IS NOT NULL AND LOWER(c.mbti) LIKE LOWER(CONCAT('%', :searchTerm, '%'))) OR
(t.tag IS NOT NULL AND LOWER(t.tag) LIKE LOWER(CONCAT('%', :searchTerm, '%')))
)
"""
)
fun searchAiCharacters(
@Param("searchTerm") searchTerm: String,
@Param("role") role: MemberRole,
@Param("memberKind") memberKind: MemberKind,
pageable: Pageable
): Page<ChatCharacter>
/**
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
*/

View File

@@ -0,0 +1,50 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import kr.co.vividnext.sodalive.common.ApiResponse
import org.springframework.http.MediaType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
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.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
@RestController
@RequestMapping("/api/v2/admin/ai-characters")
class AiCharacterAdminCharacterController(
private val facade: AiCharacterAdminCharacterFacade
) {
@GetMapping
fun list(
@RequestParam(required = false) search: String?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "20") size: Int
): ApiResponse<AiCharacterAdminListResponse> {
return ApiResponse.ok(facade.list(search, page, size))
}
@GetMapping("/{characterId:[0-9]+}")
fun detail(@PathVariable characterId: Long): ApiResponse<AiCharacterAdminCharacterResponse> {
return ApiResponse.ok(facade.detail(characterId))
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") request: String
): ApiResponse<AiCharacterAdminCharacterResponse> {
return ApiResponse.ok(facade.create(image, request))
}
@PutMapping("/{characterId:[0-9]+}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable characterId: Long,
@RequestPart(value = "image", required = false) image: MultipartFile?,
@RequestPart("request") request: String
): ApiResponse<AiCharacterAdminCharacterResponse> {
return ApiResponse.ok(facade.update(characterId, image, request))
}
}

View File

@@ -0,0 +1,116 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
data class AiCharacterAdminListResponse(
val totalCount: Long,
val items: List<AiCharacterAdminCharacterListItemResponse>,
val page: Int,
val size: Int,
val hasNext: Boolean
)
data class AiCharacterAdminCharacterListItemResponse(
val characterId: Long,
val name: String,
val description: String,
val imageUrl: String,
val creatorMemberId: Long,
val creatorNickname: String,
val originalWorkId: Long?,
val externalCharacterId: String,
val isActive: Boolean,
val createdAtUtc: String?
)
data class AiCharacterAdminCharacterResponse(
val characterId: Long,
val name: String,
val description: String,
val imageUrl: String,
val creatorMemberId: Long,
val creatorNickname: String,
val creatorProfileImageUrl: String,
val creatorIntroduce: String?,
val originalWorkId: Long?,
val externalCharacterId: String,
val isActive: Boolean,
val createdAtUtc: String?,
val updatedAtUtc: String?
)
data class AiCharacterAdminCharacterCreateRequest(
val name: String,
val systemPrompt: String,
val description: String,
val externalCharacterId: String? = null,
val isActive: Boolean? = 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 region: String = "KR",
val originalTitle: String? = null,
val originalLink: String? = null,
val originalWorkId: Long? = null,
val characterType: String? = null,
val tags: List<String> = emptyList(),
val hobbies: List<String> = emptyList(),
val values: List<String> = emptyList(),
val goals: List<String> = emptyList(),
val relationships: List<AiCharacterAdminCharacterRelationshipRequest> = emptyList(),
val personalities: List<AiCharacterAdminCharacterPersonalityRequest> = emptyList(),
val backgrounds: List<AiCharacterAdminCharacterBackgroundRequest> = emptyList(),
val memories: List<AiCharacterAdminCharacterMemoryRequest> = emptyList()
)
data class AiCharacterAdminCharacterUpdateRequest(
val name: String? = null,
val systemPrompt: String? = null,
val description: String? = null,
val externalCharacterId: 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 originalTitle: String? = null,
val originalLink: String? = null,
val originalWorkId: Long? = null,
val characterType: 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<AiCharacterAdminCharacterRelationshipRequest>? = null,
val personalities: List<AiCharacterAdminCharacterPersonalityRequest>? = null,
val backgrounds: List<AiCharacterAdminCharacterBackgroundRequest>? = null,
val memories: List<AiCharacterAdminCharacterMemoryRequest>? = null
)
data class AiCharacterAdminCharacterRelationshipRequest(
val personName: String,
val relationshipName: String,
val description: String,
val importance: Int,
val relationshipType: String,
val currentStatus: String
)
data class AiCharacterAdminCharacterPersonalityRequest(
val trait: String,
val description: String
)
data class AiCharacterAdminCharacterBackgroundRequest(
val topic: String,
val description: String
)
data class AiCharacterAdminCharacterMemoryRequest(
val title: String,
val content: String,
val emotion: String
)

View File

@@ -0,0 +1,139 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
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.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestTemplate
@Component
class AiCharacterAdminCharacterExternalApiClient(
@Value("\${weraser.api-key}") private val apiKey: String,
@Value("\${weraser.api-url}") private val apiUrl: String
) {
fun create(request: AiCharacterAdminCharacterCreateRequest): String {
val body = mutableMapOf<String, Any>(
"name" to request.name,
"systemPrompt" to request.systemPrompt,
"description" to request.description,
"region" to request.region
)
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = mapGender(request.region, it) }
request.mbti?.let { body["mbti"] = it }
request.speechPattern?.let { body["speechPattern"] = it }
request.speechStyle?.let { body["speechStyle"] = it }
request.appearance?.let { body["appearance"] = it }
if (request.tags.isNotEmpty()) body["tags"] = request.tags
if (request.hobbies.isNotEmpty()) body["hobbies"] = request.hobbies
if (request.values.isNotEmpty()) body["values"] = request.values
if (request.goals.isNotEmpty()) body["goals"] = request.goals
if (request.relationships.isNotEmpty()) body["relationships"] = request.relationships
if (request.personalities.isNotEmpty()) body["personalities"] = request.personalities
if (request.backgrounds.isNotEmpty()) body["backgrounds"] = request.backgrounds
if (request.memories.isNotEmpty()) body["memories"] = request.memories
return exchange(HttpMethod.POST, "/api/characters", body) ?: throw invalidRequest()
}
fun update(
chatCharacter: ChatCharacter,
request: AiCharacterAdminCharacterUpdateRequest,
requestName: String?
) {
val body = mutableMapOf<String, Any>()
if (request.isActive == false) {
val inactiveName = java.util.UUID.randomUUID().toString().replace("-", "")
body["name"] = "inactive_${requestName}_$inactiveName"
} else {
request.name?.let { body["name"] = it }
request.systemPrompt?.let { body["systemPrompt"] = it }
request.description?.let { body["description"] = it }
request.age?.let { body["age"] = it }
request.gender?.let { body["gender"] = it }
request.mbti?.let { body["mbti"] = it }
request.speechPattern?.let { body["speechPattern"] = it }
request.speechStyle?.let { body["speechStyle"] = it }
request.appearance?.let { body["appearance"] = it }
request.tags?.let { body["tags"] = it }
request.hobbies?.let { body["hobbies"] = it }
request.values?.let { body["values"] = it }
request.goals?.let { body["goals"] = it }
request.relationships?.let { body["relationships"] = it }
request.personalities?.let { body["personalities"] = it }
request.backgrounds?.let { body["backgrounds"] = it }
request.memories?.let { body["memories"] = it }
}
exchange(HttpMethod.PUT, "/api/characters/${chatCharacter.characterUUID}", body)
}
private fun exchange(method: HttpMethod, path: String, body: Map<String, Any>): String? {
val headers = HttpHeaders().apply {
set("x-api-key", apiKey)
contentType = MediaType.APPLICATION_JSON
}
val response = try {
createRestTemplate().exchange(
"$apiUrl$path",
method,
HttpEntity(body, headers),
ExternalCharacterResponse::class.java
)
} catch (_: RestClientException) {
throw invalidRequest()
}
val externalResponse = response.body ?: throw invalidRequest()
if (!externalResponse.success) {
throw invalidRequest()
}
return externalResponse.data?.id
}
private fun createRestTemplate(): RestTemplate {
return RestTemplate(
SimpleClientHttpRequestFactory().apply {
setConnectTimeout(EXTERNAL_API_TIMEOUT_MILLIS)
setReadTimeout(EXTERNAL_API_TIMEOUT_MILLIS)
}
)
}
private fun mapGender(region: String, gender: String): String {
if (!region.equals("JP", ignoreCase = true)) return gender
return when (gender) {
"여성" -> "女性"
"남성" -> "男性"
"기타" -> "その他"
else -> gender
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
@JsonIgnoreProperties(ignoreUnknown = true)
private data class ExternalCharacterResponse(
val success: Boolean,
val data: ExternalCharacterData? = null
)
@JsonIgnoreProperties(ignoreUnknown = true)
private data class ExternalCharacterData(
val id: String
)
companion object {
private const val EXTERNAL_API_TIMEOUT_MILLIS = 20_000
}
}

View File

@@ -0,0 +1,188 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.LanguageDetectEvent
import kr.co.vividnext.sodalive.content.LanguageDetectTargetType
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.application.AiCharacterAdminTargetResolver
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.context.ApplicationEventPublisher
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
@Service
class AiCharacterAdminCharacterFacade(
private val objectMapper: ObjectMapper,
private val chatCharacterService: ChatCharacterService,
private val chatCharacterRepository: ChatCharacterRepository,
private val targetResolver: AiCharacterAdminTargetResolver,
private val externalApiClient: AiCharacterAdminCharacterExternalApiClient,
private val imageStorage: AiCharacterAdminCharacterImageStorage,
private val characterMapper: AiCharacterAdminCharacterMapper,
private val originalWorkService: AdminOriginalWorkService,
private val creatorMemberService: ChatCharacterCreatorMemberService,
private val applicationEventPublisher: ApplicationEventPublisher
) {
@Transactional(readOnly = true)
fun list(search: String?, page: Int, size: Int): AiCharacterAdminListResponse {
val normalizedPage = page.coerceAtLeast(0)
val normalizedSize = size.coerceIn(MINIMUM_PAGE_SIZE, MAXIMUM_PAGE_SIZE)
val characters = chatCharacterRepository.searchAiCharacters(
searchTerm = search?.trim().orEmpty(),
role = MemberRole.CREATOR,
memberKind = MemberKind.AI_CHARACTER,
pageable = PageRequest.of(normalizedPage, normalizedSize, Sort.by(Sort.Direction.DESC, "createdAt"))
)
return AiCharacterAdminListResponse(
totalCount = characters.totalElements,
items = characters.content.map(characterMapper::toListItemResponse),
page = normalizedPage,
size = normalizedSize,
hasNext = characters.hasNext()
)
}
@Transactional(readOnly = true)
fun detail(characterId: Long): AiCharacterAdminCharacterResponse {
return characterMapper.toResponse(targetResolver.resolve(characterId).chatCharacter)
}
@Transactional
fun create(image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse {
val request = readRequest(requestString, AiCharacterAdminCharacterCreateRequest::class.java)
if (request.externalCharacterId != null || request.isActive != null) {
throw invalidRequest()
}
val characterType = characterMapper.toCharacterType(request)
if (chatCharacterService.findByName(request.name) != null) {
throw invalidRequest()
}
request.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork)
val chatCharacter = chatCharacterService.createChatCharacterWithDetails(
characterUUID = externalApiClient.create(request),
name = request.name,
description = request.description,
systemPrompt = request.systemPrompt,
age = request.age?.toIntOrNull(),
gender = request.gender,
mbti = request.mbti,
speechPattern = request.speechPattern,
speechStyle = request.speechStyle,
appearance = request.appearance,
originalTitle = request.originalTitle,
originalLink = request.originalLink,
characterType = characterType,
region = request.region,
tags = request.tags,
values = request.values,
hobbies = request.hobbies,
goals = request.goals,
memories = request.memories.map { Triple(it.title, it.content, it.emotion) },
personalities = request.personalities.map { Pair(it.trait, it.description) },
backgrounds = request.backgrounds.map { Pair(it.topic, it.description) },
relationships = request.relationships.map(characterMapper::toLegacyRequest)
)
if (image?.isEmpty == false) {
chatCharacter.imagePath = imageStorage.upload(chatCharacter.id ?: throw invalidRequest(), image)
chatCharacterService.saveChatCharacter(chatCharacter)
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter)
}
request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, chatCharacter.id ?: throw invalidRequest()) }
if (chatCharacter.languageCode.isNullOrBlank() && chatCharacter.description.isNotBlank()) {
applicationEventPublisher.publishEvent(
LanguageDetectEvent(
id = chatCharacter.id ?: throw invalidRequest(),
query = chatCharacter.description,
targetType = LanguageDetectTargetType.CHARACTER
)
)
}
return characterMapper.toResponse(chatCharacter)
}
@Transactional
fun update(characterId: Long, image: MultipartFile?, requestString: String): AiCharacterAdminCharacterResponse {
val target = targetResolver.resolve(characterId)
val request = readRequest(requestString, AiCharacterAdminCharacterUpdateRequest::class.java)
if (request.externalCharacterId != null) {
throw invalidRequest()
}
characterMapper.validateCharacterType(request)
if (
request.isActive == false &&
(characterMapper.hasNonSoftDeleteChanges(request) || image?.isEmpty == false)
) {
throw invalidRequest()
}
if (
!characterMapper.hasExternalChanges(request) &&
!characterMapper.hasDbOnlyChanges(request) &&
image?.isEmpty != false
) {
throw invalidRequest()
}
if (
request.name != null && request.name != target.chatCharacter.name &&
chatCharacterService.findByName(request.name) != null
) {
throw invalidRequest()
}
request.originalWorkId?.takeIf { it != 0L }?.let(originalWorkService::getOriginalWork)
val requestName = if (request.isActive == false) request.name ?: target.chatCharacter.name else request.name
if (characterMapper.hasExternalChanges(request)) {
externalApiClient.update(target.chatCharacter, request, requestName)
}
val imagePath = if (image?.isEmpty == false) imageStorage.upload(characterId, image) else null
val updatedCharacter = chatCharacterService.updateChatCharacterWithDetails(
imagePath = imagePath,
request = characterMapper.toLegacyRequest(request, characterId, requestName)
)
request.originalWorkId?.let { originalWorkService.assignOneCharacter(it, characterId) }
chatCharacterRepository.flush()
applicationEventPublisher.publishEvent(
LanguageTranslationEvent(
id = characterId,
targetType = LanguageTranslationTargetType.CHARACTER,
waitTransactionCommit = true
)
)
return characterMapper.toResponse(updatedCharacter)
}
private fun <T> readRequest(requestString: String, requestClass: Class<T>): T {
return try {
objectMapper.readValue(requestString, requestClass)
} catch (_: JsonProcessingException) {
throw invalidRequest()
}
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
companion object {
private const val MINIMUM_PAGE_SIZE = 20
private const val MAXIMUM_PAGE_SIZE = 50
}
}

View File

@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.amazonaws.services.s3.model.ObjectMetadata
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.utils.generateFileName
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.web.multipart.MultipartFile
@Component
class AiCharacterAdminCharacterImageStorage(
private val s3Uploader: S3Uploader,
@Value("\${cloud.aws.s3.bucket}") private val s3Bucket: String
) {
fun upload(characterId: Long, image: MultipartFile): String {
val metadata = ObjectMetadata().apply { contentLength = image.size }
return s3Uploader.upload(
inputStream = image.inputStream,
bucket = s3Bucket,
filePath = "characters/$characterId/${generateFileName(prefix = "character")}",
metadata = metadata
)
}
}

View File

@@ -0,0 +1,134 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterBackgroundRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterMemoryRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterPersonalityRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterRelationshipRequest
import kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.extensions.toUtcIso
import kr.co.vividnext.sodalive.v2.api.admin.aicharacter.error.AiCharacterAdminApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
@Component
class AiCharacterAdminCharacterMapper(
@Value("\${cloud.aws.cloud-front.host}") private val imageHost: String
) {
fun toListItemResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterListItemResponse {
val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest()
return AiCharacterAdminCharacterListItemResponse(
characterId = chatCharacter.id ?: throw invalidRequest(),
name = chatCharacter.name,
description = chatCharacter.description,
imageUrl = "$imageHost/${chatCharacter.imagePath ?: "profile/default-profile.png"}",
creatorMemberId = creatorMember.id ?: throw invalidRequest(),
creatorNickname = creatorMember.nickname,
originalWorkId = chatCharacter.originalWork?.id,
externalCharacterId = chatCharacter.characterUUID,
isActive = chatCharacter.isActive,
createdAtUtc = chatCharacter.createdAt?.toUtcIso()
)
}
fun toResponse(chatCharacter: ChatCharacter): AiCharacterAdminCharacterResponse {
val creatorMember = chatCharacter.creatorMember ?: throw invalidRequest()
return AiCharacterAdminCharacterResponse(
characterId = chatCharacter.id ?: throw invalidRequest(),
name = chatCharacter.name,
description = chatCharacter.description,
imageUrl = "$imageHost/${chatCharacter.imagePath ?: "profile/default-profile.png"}",
creatorMemberId = creatorMember.id ?: throw invalidRequest(),
creatorNickname = creatorMember.nickname,
creatorProfileImageUrl = "$imageHost/${creatorMember.profileImage ?: "profile/default-profile.png"}",
creatorIntroduce = creatorMember.introduce,
originalWorkId = chatCharacter.originalWork?.id,
externalCharacterId = chatCharacter.characterUUID,
isActive = chatCharacter.isActive,
createdAtUtc = chatCharacter.createdAt?.toUtcIso(),
updatedAtUtc = chatCharacter.updatedAt?.toUtcIso()
)
}
fun toCharacterType(request: AiCharacterAdminCharacterCreateRequest): CharacterType {
return request.characterType?.let { toCharacterType(it) } ?: CharacterType.Character
}
fun validateCharacterType(request: AiCharacterAdminCharacterUpdateRequest) {
request.characterType?.let { toCharacterType(it) }
}
fun hasExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return hasRegularExternalChanges(request) || request.isActive == false
}
fun hasNonSoftDeleteChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return hasRegularExternalChanges(request) || hasDbOnlyChanges(request)
}
private fun hasRegularExternalChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return request.name != null || 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.tags != null || request.hobbies != null ||
request.values != null || request.goals != null || request.relationships != null || request.personalities != null ||
request.backgrounds != null || request.memories != null
}
fun hasDbOnlyChanges(request: AiCharacterAdminCharacterUpdateRequest): Boolean {
return request.originalTitle != null || request.originalLink != null || request.characterType != null ||
request.originalWorkId != null
}
fun toLegacyRequest(
request: AiCharacterAdminCharacterUpdateRequest,
characterId: Long,
requestName: String?
): ChatCharacterUpdateRequest {
return ChatCharacterUpdateRequest(
id = characterId,
name = requestName,
systemPrompt = request.systemPrompt,
description = request.description,
age = request.age,
gender = request.gender,
mbti = request.mbti,
speechPattern = request.speechPattern,
speechStyle = request.speechStyle,
appearance = request.appearance,
originalTitle = request.originalTitle,
originalLink = request.originalLink,
originalWorkId = request.originalWorkId,
characterType = request.characterType,
isActive = request.isActive,
tags = request.tags,
hobbies = request.hobbies,
values = request.values,
goals = request.goals,
relationships = request.relationships?.map(::toLegacyRequest),
personalities = request.personalities?.map { ChatCharacterPersonalityRequest(it.trait, it.description) },
backgrounds = request.backgrounds?.map { ChatCharacterBackgroundRequest(it.topic, it.description) },
memories = request.memories?.map { ChatCharacterMemoryRequest(it.title, it.content, it.emotion) }
)
}
fun toLegacyRequest(request: AiCharacterAdminCharacterRelationshipRequest): ChatCharacterRelationshipRequest {
return ChatCharacterRelationshipRequest(
personName = request.personName,
relationshipName = request.relationshipName,
description = request.description,
importance = request.importance,
relationshipType = request.relationshipType,
currentStatus = request.currentStatus
)
}
private fun toCharacterType(characterType: String): CharacterType {
return runCatching { CharacterType.valueOf(characterType) }.getOrElse { throw invalidRequest() }
}
private fun invalidRequest(): AiCharacterAdminApiException {
return AiCharacterAdminApiException(HttpStatus.BAD_REQUEST, "common.error.invalid_request")
}
}