test #443

Merged
klaus merged 25 commits from test into main 2026-08-03 06:20:48 +00:00
11 changed files with 2539 additions and 0 deletions
Showing only changes of commit 90c0182ae1 - Show all commits

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")
}
}

View File

@@ -0,0 +1,345 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.sun.net.httpserver.HttpServer
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.hamcrest.Matchers.endsWith
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
import javax.persistence.EntityManager
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class AiCharacterAdminCharacterControllerTest @Autowired constructor(
private val mockMvc: MockMvc,
private val chatCharacterService: ChatCharacterService,
private val entityManager: EntityManager
) {
@Test
@DisplayName("목록은 음수 page와 최소 미만 size를 기본값으로 보정한다")
fun shouldNormalizeListPageAndMinimumSize() {
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-list-character",
name = "v2-list-character",
description = "description",
systemPrompt = "prompt"
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("page", "-1")
.param("size", "1")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].name").value("v2-list-character"))
.andExpect(jsonPath("$.data.items[0].creatorProfileImageUrl").doesNotExist())
.andExpect(jsonPath("$.data.items[0].creatorIntroduce").doesNotExist())
.andExpect(jsonPath("$.data.items[0].updatedAtUtc").doesNotExist())
}
@Test
@DisplayName("검색은 최대 size를 50으로 보정한다")
fun shouldNormalizeSearchMaximumSize() {
chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-search-character",
name = "v2-search-character",
description = "find this character",
systemPrompt = "prompt"
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("search", "find this")
.param("size", "100")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].externalCharacterId").value("v2-search-character"))
}
@Test
@DisplayName("상세는 유효한 AI 캐릭터를 v2 DTO로 반환한다")
fun shouldReturnDetailForAiCharacter() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-detail-character",
name = "v2-detail-character",
description = "detail description",
systemPrompt = "prompt"
)
entityManager.flush()
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.characterId").value(character.id))
.andExpect(jsonPath("$.data.externalCharacterId").value("v2-detail-character"))
.andExpect(jsonPath("$.data.creatorMemberId").value(character.creatorMember!!.id))
.andExpect(jsonPath("$.data.creatorNickname").value("v2-detail-character"))
.andExpect(jsonPath("$.data.creatorProfileImageUrl").value(endsWith("/profile/default-profile.png")))
.andExpect(jsonPath("$.data.creatorIntroduce").value("detail description"))
.andExpect(jsonPath("$.data.isActive").value(true))
.andExpect(jsonPath("$.data.createdAtUtc").value(endsWith("Z")))
.andExpect(jsonPath("$.data.updatedAtUtc").value(endsWith("Z")))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("상세는 AI creatorMember가 아닌 target을 요청 언어의 invalid request로 거부한다")
fun shouldReturnLocalizedErrorForInvalidDetailTarget(language: String, message: String) {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-invalid-target",
name = "v2-invalid-target",
description = "description",
systemPrompt = "prompt"
)
character.creatorMember!!.memberKind = MemberKind.HUMAN
entityManager.flush()
entityManager.clear()
mockMvc.perform(
get("/api/v2/admin/ai-characters/${character.id}")
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
}
@ParameterizedTest
@CsvSource(
"ko,잘못된 요청입니다.",
"en,Invalid request.",
"ja,無効なリクエストです。"
)
@DisplayName("목록 binding 오류는 요청 언어의 invalid request를 반환한다")
fun shouldReturnLocalizedErrorForInvalidListBinding(language: String, message: String) {
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.param("page", "not-a-number")
.header(HttpHeaders.ACCEPT_LANGUAGE, language)
.with(adminAuthentication())
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value(message))
}
@Test
@DisplayName("목록·상세·생성·수정 endpoint는 non-ADMIN JWT를 binding 전에 거부한다")
fun shouldRejectNonAdminJwtRoleForEveryCharacterEndpoint() {
actualCharacterEndpointRequests().forEach { request ->
mockMvc.perform(
request
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(nonAdminAuthentication())
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
}
}
@Test
@DisplayName("목록·상세·생성·수정 endpoint는 stale ADMIN claim을 binding 전에 거부한다")
fun shouldRejectStaleAdminClaimForEveryCharacterEndpoint() {
actualCharacterEndpointRequests().forEach { request ->
mockMvc.perform(
request
.header(HttpHeaders.ACCEPT_LANGUAGE, "en")
.with(staleAdminAuthentication())
)
.andExpect(status().isForbidden)
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("You do not have permission."))
}
}
@Test
@DisplayName("실제 상세 endpoint preflight는 캐릭터 관리자 Origin을 허용한다")
fun shouldApplyCorsToDetailEndpointPreflight() {
mockMvc.perform(
options("/api/v2/admin/ai-characters/1")
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
}
@ParameterizedTest
@CsvSource(
"/api/v2/admin/ai-characters,GET",
"/api/v2/admin/ai-characters/1,GET",
"/api/v2/admin/ai-characters,POST",
"/api/v2/admin/ai-characters/1,PUT"
)
@DisplayName("실제 character endpoint preflight는 캐릭터 관리자 Origin만 허용한다")
fun shouldApplyCorsPreflightToEveryCharacterEndpoint(path: String, method: String) {
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, CHARACTER_ADMIN_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isOk)
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, CHARACTER_ADMIN_ORIGIN))
mockMvc.perform(
options(path)
.header(HttpHeaders.ORIGIN, CREATOR_ORIGIN)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, method)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "authorization,content-type")
)
.andExpect(status().isForbidden)
.andExpect(header().doesNotExist(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN))
}
@Test
@DisplayName("목록 endpoint는 ADMIN 인증을 상속한다")
fun shouldInheritAdminAuthorizationForListEndpoint() {
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.with(anonymous())
)
.andExpect(status().isUnauthorized)
mockMvc.perform(
get("/api/v2/admin/ai-characters")
.with(adminAuthentication())
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
}
private fun adminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "admin@example.com",
password = "password",
nickname = "admin",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
private fun nonAdminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "user@example.com",
password = "password",
nickname = "user",
role = MemberRole.ADMIN
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_USER"))
)
)
private fun staleAdminAuthentication() = authentication(
UsernamePasswordAuthenticationToken(
MemberAdapter(
Member(
email = "stale-admin@example.com",
password = "password",
nickname = "stale-admin",
role = MemberRole.USER
)
),
"token",
listOf(SimpleGrantedAuthority("ROLE_ADMIN"))
)
)
private fun actualCharacterEndpointRequests(): List<MockHttpServletRequestBuilder> {
return listOf(
get("/api/v2/admin/ai-characters"),
get("/api/v2/admin/ai-characters/1"),
post("/api/v2/admin/ai-characters"),
put("/api/v2/admin/ai-characters/1")
)
}
companion object {
private const val CHARACTER_ADMIN_ORIGIN = "https://character-admin.sodalive.net"
private const val CREATOR_ORIGIN = "https://creator.sodalive.net"
private val externalCharacterApi = HttpServer.create(InetSocketAddress(0), 0).apply {
createContext("/api/characters") { exchange ->
val response = """{"success":true,"data":{"id":"external-v2-created-character"}}"""
.toByteArray(StandardCharsets.UTF_8)
exchange.responseHeaders.add("Content-Type", MediaType.APPLICATION_JSON_VALUE)
exchange.sendResponseHeaders(200, response.size.toLong())
exchange.responseBody.use { it.write(response) }
}
start()
}
@JvmStatic
@DynamicPropertySource
fun externalCharacterApiProperties(registry: DynamicPropertyRegistry) {
registry.add("weraser.api-url") { "http://localhost:${externalCharacterApi.address.port}" }
}
@JvmStatic
@AfterAll
fun stopExternalCharacterApi() {
externalCharacterApi.stop(0)
}
}
}

View File

@@ -0,0 +1,164 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
import com.amazonaws.services.s3.AmazonS3Client
import com.sun.net.httpserver.HttpServer
import kr.co.vividnext.sodalive.admin.chat.character.AdminChatCharacterController
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
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.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.ApplicationEventPublisher
import org.springframework.mock.web.MockMultipartFile
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import java.net.InetSocketAddress
import java.net.URL
import java.nio.charset.StandardCharsets
import javax.persistence.EntityManager
@SpringBootTest
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class LegacyChatCharacterAdminCharacterControllerEventCharacterizationTest @Autowired constructor(
private val chatCharacterService: ChatCharacterService,
private val adminChatCharacterService: AdminChatCharacterService,
private val originalWorkService: AdminOriginalWorkService,
private val creatorMemberService: ChatCharacterCreatorMemberService,
private val entityManager: EntityManager
) {
@Test
fun `기존 관리자 캐릭터 등록은 설명 언어 감지 이벤트를 발행한다`() {
val server = startExternalCharacterApi()
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
val controller = createLegacyController(
s3Uploader = stubS3Uploader("characters/event/profile.png"),
publisher = publisher,
apiUrl = "http://localhost:${server.address.port}"
)
try {
controller.registerCharacter(
image = MockMultipartFile("image", "image.png", "image/png", byteArrayOf(1)),
requestString = """
{
"name":"legacy-character-language-detect",
"systemPrompt":"prompt",
"description":"detect this description",
"age":"21",
"gender":"여성",
"region":"KR",
"tags":[],
"hobbies":[],
"values":[],
"goals":[],
"relationships":[],
"personalities":[],
"backgrounds":[],
"memories":[]
}
""".trimIndent()
)
entityManager.flush()
val saved = chatCharacterService.findByName("legacy-character-language-detect")!!
val event = capturePublishedEvent(publisher) as LanguageDetectEvent
assertEquals(saved.id, event.id)
assertEquals("detect this description", event.query)
assertEquals(LanguageDetectTargetType.CHARACTER, event.targetType)
} finally {
server.stop(0)
}
}
@Test
fun `기존 관리자 캐릭터 수정은 번역 이벤트를 발행한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-translation",
name = "legacy-character-translation",
description = "description",
systemPrompt = "prompt"
)
entityManager.flush()
val publisher = Mockito.mock(ApplicationEventPublisher::class.java)
val controller = createLegacyController(
s3Uploader = stubS3Uploader("unused.png"),
publisher = publisher,
apiUrl = "http://localhost"
)
controller.updateCharacter(
image = null,
requestString = """
{
"id":${character.id},
"originalTitle":"changed original"
}
""".trimIndent()
)
val event = capturePublishedEvent(publisher) as LanguageTranslationEvent
assertEquals(character.id, event.id)
assertEquals(LanguageTranslationTargetType.CHARACTER, event.targetType)
assertTrue(event.waitTransactionCommit)
}
private fun createLegacyController(
s3Uploader: S3Uploader,
publisher: ApplicationEventPublisher,
apiUrl: String
): AdminChatCharacterController {
return AdminChatCharacterController(
service = chatCharacterService,
adminService = adminChatCharacterService,
s3Uploader = s3Uploader,
originalWorkService = originalWorkService,
creatorMemberService = creatorMemberService,
applicationEventPublisher = publisher,
apiKey = "test-api-key",
apiUrl = apiUrl,
s3Bucket = "test-bucket",
imageHost = "https://cdn.example.com"
)
}
private fun stubS3Uploader(path: String): S3Uploader {
val amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
Mockito.`when`(amazonS3Client.getUrl(Mockito.anyString(), Mockito.anyString()))
.thenReturn(URL("https://cdn.example.com/$path"))
return S3Uploader(amazonS3Client)
}
private fun startExternalCharacterApi(): HttpServer {
val server = HttpServer.create(InetSocketAddress(0), 0)
server.createContext("/api/characters") { exchange ->
val response = """{"success":true,"data":{"id":"external-event-character"}}"""
.toByteArray(StandardCharsets.UTF_8)
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(200, response.size.toLong())
exchange.responseBody.use { it.write(response) }
}
server.start()
return server
}
private fun capturePublishedEvent(publisher: ApplicationEventPublisher): Any {
val captor = ArgumentCaptor.forClass(Any::class.java)
Mockito.verify(publisher).publishEvent(captor.capture())
return captor.value
}
}

View File

@@ -0,0 +1,220 @@
package kr.co.vividnext.sodalive.v2.api.admin.aicharacter.character
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.ChatCharacterUpdateRequest
import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService
import kr.co.vividnext.sodalive.chat.character.CharacterType
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.annotation.Transactional
import javax.persistence.EntityManager
@SpringBootTest
@Transactional
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class LegacyChatCharacterAdminCharacterizationTest @Autowired constructor(
private val chatCharacterService: ChatCharacterService,
private val originalWorkService: AdminOriginalWorkService,
private val chatCharacterRepository: ChatCharacterRepository,
private val memberRepository: MemberRepository,
private val entityManager: EntityManager
) {
@Test
fun `기존 관리자 캐릭터 생성은 AI creatorMember와 상세 정보를 함께 저장한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-1",
name = "legacy-character-create",
description = "legacy description",
systemPrompt = "system prompt",
age = 21,
gender = "여성",
mbti = "INFP",
speechPattern = "polite",
speechStyle = "soft",
appearance = "silver hair",
originalTitle = "original title",
originalLink = "https://example.com/original",
characterType = CharacterType.Character,
region = "KR",
tags = listOf("tag-a", "tag-a", "tag-b"),
values = listOf("value-a"),
hobbies = listOf("hobby-a"),
goals = listOf("goal-a"),
memories = listOf(Triple("memory", "content", "happy")),
personalities = listOf(Pair("kind", "kind desc"))
)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
val creatorMember = saved.creatorMember
assertNotNull(creatorMember)
assertEquals(MemberRole.CREATOR, creatorMember!!.role)
assertEquals(MemberKind.AI_CHARACTER, creatorMember.memberKind)
assertEquals("legacy-character-create", creatorMember.nickname)
assertEquals("legacy description", creatorMember.introduce)
assertEquals(listOf("tag-a", "tag-b"), saved.tagMappings.map { it.tag.tag }.sorted())
assertEquals(listOf("value-a"), saved.valueMappings.map { it.value.value })
assertEquals(listOf("hobby-a"), saved.hobbyMappings.map { it.hobby.hobby })
assertEquals(listOf("goal-a"), saved.goalMappings.map { it.goal.goal })
assertEquals(listOf("memory"), saved.memories.map { it.title })
assertEquals(listOf("kind"), saved.personalities.map { it.trait })
}
@Test
fun `기존 관리자 원작 배정은 캐릭터 originalWork 연결을 저장한다`() {
val originalWork = saveOriginalWork("legacy-original-work")
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-original",
name = "legacy-character-original-work",
description = "description",
systemPrompt = "prompt"
)
entityManager.flush()
originalWorkService.assignOneCharacter(originalWork.id!!, character.id!!)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
assertEquals(originalWork.id, saved.originalWork!!.id)
assertEquals("legacy-original-work", saved.originalWork!!.title)
}
@Test
fun `기존 관리자 캐릭터 수정은 변경 필드와 AI creatorMember 표시 정보를 동기화한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-2",
name = "legacy-character-update",
description = "before description",
systemPrompt = "before prompt",
tags = listOf("old-tag"),
memories = listOf(Triple("old-memory", "old content", "sad"))
)
entityManager.flush()
chatCharacterService.updateChatCharacterWithDetails(
imagePath = "characters/${character.id}/profile.png",
request = ChatCharacterUpdateRequest(
id = character.id!!,
name = "legacy-character-updated",
description = "after description",
age = "24",
tags = listOf("new-tag"),
memories = listOf(ChatCharacterMemoryRequest("new-memory", "new content", "calm")),
personalities = listOf(ChatCharacterPersonalityRequest("calm", "calm desc"))
)
)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
val creatorMember = memberRepository.findById(saved.creatorMember!!.id!!).orElseThrow()
assertEquals("legacy-character-updated", saved.name)
assertEquals("after description", saved.description)
assertEquals(24, saved.age)
assertEquals("characters/${character.id}/profile.png", saved.imagePath)
assertEquals(listOf("new-tag"), saved.tagMappings.map { it.tag.tag })
assertEquals(listOf("new-memory"), saved.memories.map { it.title })
assertEquals(listOf("calm"), saved.personalities.map { it.trait })
assertEquals("legacy-character-updated", creatorMember.nickname)
assertEquals("characters/${character.id}/profile.png", creatorMember.profileImage)
assertEquals("after description", creatorMember.introduce)
}
@Test
fun `기존 관리자 캐릭터 비활성화는 row와 creatorMember를 남기고 isActive false로 저장한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-3",
name = "legacy-character-disable",
description = "disable description",
systemPrompt = "prompt"
)
val creatorMemberId = character.creatorMember!!.id!!
val audioContent = saveAudioContent(character.creatorMember!!)
entityManager.flush()
chatCharacterService.updateChatCharacterWithDetails(
request = ChatCharacterUpdateRequest(
id = character.id!!,
name = character.name,
isActive = false
)
)
entityManager.flush()
entityManager.clear()
val saved = chatCharacterRepository.findById(character.id!!).orElseThrow()
assertFalse(saved.isActive)
assertTrue(saved.name.startsWith("inactive_legacy-character-disable_"))
assertTrue(memberRepository.existsById(creatorMemberId))
assertEquals(creatorMemberId, saved.creatorMember!!.id)
assertNotNull(entityManager.find(AudioContent::class.java, audioContent.id!!))
assertEquals(creatorMemberId, entityManager.find(AudioContent::class.java, audioContent.id!!).member!!.id)
}
@Test
fun `기존 관리자 중복 이름 검증은 findByName 조회 결과에 의존한다`() {
val character = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "external-character-4",
name = "legacy-duplicate-name",
description = "description",
systemPrompt = "prompt"
)
entityManager.flush()
entityManager.clear()
val found = chatCharacterService.findByName("legacy-duplicate-name")
assertEquals(character.id, found!!.id)
}
private fun saveOriginalWork(title: String): OriginalWork {
val originalWork = OriginalWork(
title = title,
contentType = "webtoon",
category = "fantasy",
isAdult = false,
description = "description"
)
entityManager.persist(originalWork)
return originalWork
}
private fun saveAudioContent(creator: Member): AudioContent {
val theme = AudioContentTheme(theme = "legacy-theme-${creator.id}", image = "theme.png", isActive = true)
entityManager.persist(theme)
val content = AudioContent(
title = "legacy-audio-${creator.id}",
detail = "detail",
languageCode = "ko"
)
content.member = creator
content.theme = theme
content.isActive = true
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
}