feat(ai-character): 캐릭터 관리자 API를 추가한다
This commit is contained in:
@@ -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>
|
||||
|
||||
/**
|
||||
* 특정 캐릭터와 태그를 공유하는 다른 캐릭터를 무작위로 조회 (현재 캐릭터 제외)
|
||||
*/
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user