test #443
@@ -1,8 +1,11 @@
|
|||||||
package kr.co.vividnext.sodalive.chat.character.repository
|
package kr.co.vividnext.sodalive.chat.character.repository
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
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.Page
|
||||||
import org.springframework.data.domain.Pageable
|
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.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.data.repository.query.Param
|
import org.springframework.data.repository.query.Param
|
||||||
@@ -53,6 +56,48 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
|||||||
pageable: Pageable
|
pageable: Pageable
|
||||||
): Page<ChatCharacter>
|
): 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user