diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index c0d6138e..890e759f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -196,8 +196,12 @@ class AdminOriginalWorkService( /** 원작 상세 조회 (소프트 삭제 제외) */ @Transactional(readOnly = true) fun getOriginalWork(id: Long): OriginalWork { - return originalWorkRepository.findByIdAndIsDeletedFalse(id) + val originalWork = originalWorkRepository.findByIdAndIsDeletedFalse(id) .orElseThrow { SodaException(messageKey = "admin.chat.original.not_found") } + + initializeResponseRelations(originalWork) + + return originalWork } /** 원작 페이징 조회 */ @@ -210,7 +214,9 @@ class AdminOriginalWorkService( else -> size } val pageable = PageRequest.of(safePage, safeSize, Sort.by("createdAt").descending()) - return originalWorkRepository.findByIsDeletedFalse(pageable) + val originalWorks = originalWorkRepository.findByIsDeletedFalse(pageable) + originalWorks.content.forEach { initializeResponseRelations(it) } + return originalWorks } /** 지정 원작에 속한 활성 캐릭터 페이징 조회 (최신순) */ @@ -233,7 +239,14 @@ class AdminOriginalWorkService( /** 원작 검색 (제목/콘텐츠타입/카테고리, 소프트 삭제 제외) - 무페이징 */ @Transactional(readOnly = true) fun searchOriginalWorksAll(searchTerm: String): List { - return originalWorkRepository.searchNoPaging(searchTerm) + val originalWorks = originalWorkRepository.searchNoPaging(searchTerm) + originalWorks.forEach { initializeResponseRelations(it) } + return originalWorks + } + + private fun initializeResponseRelations(originalWork: OriginalWork) { + originalWork.originalLinks.forEach { it.url } + originalWork.tagMappings.forEach { it.tag.tag } } /** 원작에 기존 캐릭터들을 배정 */ diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt index 3c90f856..51cd9808 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/repository/ChatCharacterRepository.kt @@ -74,6 +74,28 @@ interface ChatCharacterRepository : JpaRepository { pageable: Pageable ): List + /** + * 특정 캐릭터와 태그를 공유하는 다른 캐릭터 ID를 무작위로 조회 (현재 캐릭터 제외) + */ + @Query( + """ + SELECT c.id FROM ChatCharacter c + JOIN c.tagMappings tm + JOIN tm.tag t + WHERE c.isActive = true + AND c.id <> :characterId + AND t.id IN ( + SELECT t2.id FROM ChatCharacterTagMapping tm2 JOIN tm2.tag t2 WHERE tm2.chatCharacter.id = :characterId + ) + GROUP BY c.id + ORDER BY function('RAND') + """ + ) + fun findRandomIdsBySharedTags( + @Param("characterId") characterId: Long, + pageable: Pageable + ): List + /** * 활성 캐릭터 무작위 조회 */ @@ -99,6 +121,27 @@ interface ChatCharacterRepository : JpaRepository { fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List, pageable: Pageable): List fun findByIdInAndIsActiveTrue(ids: List): List + + @Query( + """ + SELECT DISTINCT c FROM ChatCharacter c + LEFT JOIN FETCH c.tagMappings tm + LEFT JOIN FETCH tm.tag + WHERE c.id = :id + """ + ) + fun findByIdWithTagMappings(@Param("id") id: Long): ChatCharacter? + + @Query( + """ + SELECT DISTINCT c FROM ChatCharacter c + LEFT JOIN FETCH c.tagMappings tm + LEFT JOIN FETCH tm.tag + WHERE c.id IN :ids + """ + ) + fun findByIdInWithTagMappings(@Param("ids") ids: List): List + fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter? fun existsByCreatorMemberId(creatorMemberId: Long): Boolean } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt index f400163d..d0bf3c63 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterService.kt @@ -210,13 +210,15 @@ class ChatCharacterService( */ @Transactional(readOnly = true) fun getOtherCharactersBySharedTags(characterId: Long, limit: Int = 10): List { - val others = chatCharacterRepository.findRandomBySharedTags( + val ids = chatCharacterRepository.findRandomIdsBySharedTags( characterId, PageRequest.of(0, limit) - ) - // 태그 초기화 (지연 로딩 문제 방지) - others.forEach { it.tagMappings.size } - return others + ).distinct() + if (ids.isEmpty()) return emptyList() + + val charactersById = chatCharacterRepository.findByIdInWithTagMappings(ids) + .associateBy { it.id } + return ids.mapNotNull { charactersById[it] } } /** @@ -555,13 +557,12 @@ class ChatCharacterService( */ @Transactional(readOnly = true) fun getCharacterDetail(id: Long): ChatCharacter? { - val character = findById(id) ?: return null + val character = chatCharacterRepository.findByIdWithTagMappings(id) ?: return null // 지연 로딩된 관계 데이터 초기화 - character.tagMappings.size - character.valueMappings.size - character.hobbyMappings.size - character.goalMappings.size + character.valueMappings.forEach { it.value.value } + character.hobbyMappings.forEach { it.hobby.hobby } + character.goalMappings.forEach { it.goal.goal } character.memories.size character.personalities.size character.backgrounds.size diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt index 998cada9..37dcf5b7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/service/OriginalWorkQueryService.kt @@ -43,8 +43,13 @@ class OriginalWorkQueryService( */ @Transactional(readOnly = true) fun getOriginalWork(id: Long): OriginalWork { - return originalWorkRepository.findByIdAndIsDeletedFalse(id) + val originalWork = originalWorkRepository.findByIdAndIsDeletedFalse(id) .orElseThrow { SodaException(messageKey = "chat.original.not_found") } + + originalWork.originalLinks.forEach { it.url } + originalWork.tagMappings.forEach { it.tag.tag } + + return originalWork } /** diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt index ce882b80..035b08ce 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt @@ -2,12 +2,14 @@ package kr.co.vividnext.sodalive.i18n.translation import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService.Companion.getTranslatableLanguageCodes import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional @Service class ResourceTranslationJobScheduler( private val sourceExtractor: TranslationSourceExtractor, private val translationJobScheduler: TranslationJobScheduler ) { + @Transactional fun scheduleResourceTranslations(resourceType: LanguageTranslationTargetType, resourceId: Long) { val source = sourceExtractor.extract(resourceType, resourceId) ?: return getTranslatableLanguageCodes(source.sourceLanguage).forEach { targetLanguage -> @@ -15,6 +17,7 @@ class ResourceTranslationJobScheduler( } } + @Transactional fun scheduleResourceTranslation( resourceType: LanguageTranslationTargetType, resourceId: Long, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt index 69670edb..3fcb3a16 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/rank/RankingRepository.kt @@ -23,6 +23,8 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember.blockMember +import kr.co.vividnext.sodalive.member.tag.QCreatorTag.creatorTag +import kr.co.vividnext.sodalive.member.tag.QMemberCreatorTag.memberCreatorTag import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Repository import java.time.LocalDateTime @@ -53,6 +55,8 @@ class RankingRepository( .select(member) .from(creatorRanking) .innerJoin(creatorRanking.member, member) + .leftJoin(member.tags, memberCreatorTag).fetchJoin() + .leftJoin(memberCreatorTag.tag, creatorTag).fetchJoin() if (memberId != null) { select = select.leftJoin(blockMember).on(blockMemberCondition) @@ -65,6 +69,7 @@ class RankingRepository( return select .orderBy(creatorRanking.ranking.asc()) .fetch() + .distinctBy { it.id } } fun getAudioContentRanking( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt new file mode 100644 index 00000000..76f43e5e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/osiv/OsivLazyLoadingRegressionTest.kt @@ -0,0 +1,448 @@ +package kr.co.vividnext.sodalive.osiv + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.admin.chat.original.dto.OriginalWorkResponse +import kr.co.vividnext.sodalive.admin.chat.original.service.AdminOriginalWorkService +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.chat.character.image.CharacterImageRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterGoalRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterHobbyRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterTagRepository +import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterValueRepository +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterCreatorMemberService +import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService +import kr.co.vividnext.sodalive.chat.character.service.PopularCharacterQuery +import kr.co.vividnext.sodalive.chat.original.OriginalWork +import kr.co.vividnext.sodalive.chat.original.OriginalWorkLink +import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping +import kr.co.vividnext.sodalive.chat.original.dto.OriginalWorkDetailResponse +import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository +import kr.co.vividnext.sodalive.chat.original.service.OriginalWorkQueryService +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository +import kr.co.vividnext.sodalive.explorer.CreatorRanking +import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType +import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler +import kr.co.vividnext.sodalive.i18n.translation.TranslationJobRepository +import kr.co.vividnext.sodalive.i18n.translation.TranslationJobScheduler +import kr.co.vividnext.sodalive.i18n.translation.TranslationSourceExtractor +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.tag.CreatorTag +import kr.co.vividnext.sodalive.member.tag.MemberCreatorTag +import kr.co.vividnext.sodalive.rank.RankingRepository +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.context.annotation.Import +import org.springframework.data.domain.PageRequest +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionTemplate +import javax.persistence.EntityManager + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:osiv-regression;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import( + QueryDslConfig::class, + ResourceTranslationJobScheduler::class, + TranslationSourceExtractor::class, + TranslationJobScheduler::class, + AudioContentThemeQueryRepository::class +) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class OsivLazyLoadingRegressionTest @Autowired constructor( + private val chatCharacterRepository: ChatCharacterRepository, + private val tagRepository: ChatCharacterTagRepository, + private val valueRepository: ChatCharacterValueRepository, + private val hobbyRepository: ChatCharacterHobbyRepository, + private val goalRepository: ChatCharacterGoalRepository, + private val originalWorkRepository: OriginalWorkRepository, + private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler, + private val translationJobRepository: TranslationJobRepository, + private val queryFactory: JPAQueryFactory, + private val transactionTemplate: TransactionTemplate, + private val entityManager: EntityManager +) { + private val chatCharacterService = ChatCharacterService( + chatCharacterRepository = chatCharacterRepository, + tagRepository = tagRepository, + valueRepository = valueRepository, + hobbyRepository = hobbyRepository, + goalRepository = goalRepository, + popularCharacterQuery = Mockito.mock(PopularCharacterQuery::class.java), + imageRepository = Mockito.mock(CharacterImageRepository::class.java), + creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java), + imageHost = "https://cdn.test" + ) + private val rankingRepository = RankingRepository(queryFactory, "https://cdn.test") + private val originalWorkQueryService = OriginalWorkQueryService( + originalWorkRepository = originalWorkRepository, + chatCharacterRepository = chatCharacterRepository + ) + private val adminOriginalWorkService = AdminOriginalWorkService( + originalWorkRepository = originalWorkRepository, + chatCharacterRepository = chatCharacterRepository, + originalWorkTagRepository = Mockito.mock(OriginalWorkTagRepository::class.java), + applicationEventPublisher = Mockito.mock(ApplicationEventPublisher::class.java) + ) + + @Test + @DisplayName("캐릭터 상세 조회 결과는 트랜잭션 밖에서도 태그 이름을 읽을 수 있다") + fun shouldLoadCharacterDetailTagOutsideTransaction() { + val characterId = saveCharacterWithTag("detail") + + val character = transactionTemplate.execute { + chatCharacterService.getCharacterDetail(characterId) + }!! + + val tags = character.tagMappings.map { it.tag.tag } + assertEquals(listOf("detail-tag"), tags) + } + + @Test + @DisplayName("캐릭터 상세 조회 결과는 트랜잭션 밖에서도 가치관, 취미, 목표 이름을 읽을 수 있다") + fun shouldLoadCharacterDetailMappingTargetsOutsideTransaction() { + val characterId = saveCharacterWithDetailMappings("detail-target") + + val character = transactionTemplate.execute { + chatCharacterService.getCharacterDetail(characterId) + }!! + + assertEquals(listOf("detail-target-value"), character.valueMappings.map { it.value.value }) + assertEquals(listOf("detail-target-hobby"), character.hobbyMappings.map { it.hobby.hobby }) + assertEquals(listOf("detail-target-goal"), character.goalMappings.map { it.goal.goal }) + } + + @Test + @DisplayName("공유 태그 기반 다른 캐릭터 조회 결과는 트랜잭션 밖에서도 태그 이름을 읽을 수 있다") + fun shouldLoadSharedTagCharactersOutsideTransaction() { + val characterId = saveTwoCharactersWithSharedTag() + + val characters = transactionTemplate.execute { + chatCharacterService.getOtherCharactersBySharedTags(characterId, 10) + }!! + + val tags = characters.single().tagMappings.map { it.tag.tag } + assertEquals(listOf("shared-tag"), tags) + } + + @Test + @DisplayName("공유 태그 기반 다른 캐릭터 ID 조회 결과는 태그 조인 중복을 제거한다") + fun shouldReturnDistinctSharedTagCharacterIds() { + val characterId = saveCharactersWithDuplicateSharedTags() + + val ids = transactionTemplate.execute { + chatCharacterRepository.findRandomIdsBySharedTags( + characterId, + PageRequest.of(0, 10) + ) + }!! + + assertEquals(ids.distinct(), ids) + assertEquals(2, ids.size) + } + + @Test + @DisplayName("원작 상세 조회 결과는 트랜잭션 밖에서도 원작 링크와 태그 이름을 DTO로 변환할 수 있다") + fun shouldLoadOriginalWorkDetailRelationsOutsideTransaction() { + val originalWorkId = saveOriginalWorkWithLinkAndTag() + + val originalWork = transactionTemplate.execute { + originalWorkQueryService.getOriginalWork(originalWorkId) + }!! + + val response = OriginalWorkDetailResponse.from( + entity = originalWork, + characters = emptyList(), + translated = null + ) + assertEquals(listOf("https://original.test/original"), response.originalLinks) + assertEquals(listOf("original-tag"), response.tags) + } + + @Test + @DisplayName("관리자 원작 상세 조회 결과는 트랜잭션 밖에서도 DTO로 변환할 수 있다") + fun shouldLoadAdminOriginalWorkDetailRelationsOutsideTransaction() { + val originalWorkId = saveOriginalWorkWithLinkAndTag("admin-detail") + + val originalWork = transactionTemplate.execute { + adminOriginalWorkService.getOriginalWork(originalWorkId) + }!! + + val response = OriginalWorkResponse.from(originalWork) + assertEquals(listOf("https://original.test/admin-detail"), response.originalLinks) + assertEquals(listOf("admin-detail-tag"), response.tags) + } + + @Test + @DisplayName("관리자 원작 목록 조회 결과는 트랜잭션 밖에서도 DTO로 변환할 수 있다") + fun shouldLoadAdminOriginalWorkPageRelationsOutsideTransaction() { + saveOriginalWorkWithLinkAndTag("admin-page") + + val page = transactionTemplate.execute { + adminOriginalWorkService.getOriginalWorkPage(page = 0, size = 20) + }!! + + val response = OriginalWorkResponse.from(page.content.single()) + assertEquals(listOf("https://original.test/admin-page"), response.originalLinks) + assertEquals(listOf("admin-page-tag"), response.tags) + } + + @Test + @DisplayName("관리자 원작 검색 결과는 트랜잭션 밖에서도 DTO로 변환할 수 있다") + fun shouldLoadAdminOriginalWorkSearchRelationsOutsideTransaction() { + saveOriginalWorkWithLinkAndTag("admin-search") + + val originalWorks = transactionTemplate.execute { + adminOriginalWorkService.searchOriginalWorksAll("admin-search-title") + }!! + + val response = OriginalWorkResponse.from(originalWorks.single()) + assertEquals(listOf("https://original.test/admin-search"), response.originalLinks) + assertEquals(listOf("admin-search-tag"), response.tags) + } + + @Test + @DisplayName("캐릭터 번역 job 스케줄러는 트랜잭션 밖 호출에서도 lazy 관계를 추출할 수 있다") + fun shouldScheduleCharacterTranslationOutsideTransaction() { + val characterId = saveCharacterForTranslation("translation") + + resourceTranslationJobScheduler.scheduleResourceTranslation( + resourceType = LanguageTranslationTargetType.CHARACTER, + resourceId = characterId, + targetLanguage = "en" + ) + + val jobs = translationJobRepository.findAll() + assertEquals( + listOf( + "name", + "description", + "gender", + "personalityTrait", + "personalityDescription", + "backgroundTopic", + "backgroundDescription", + "tags" + ), + jobs.map { it.fieldKey }.sortedBy { expectedCharacterTranslationFieldOrder().indexOf(it) } + ) + } + + @Test + @DisplayName("크리에이터 랭킹 조회 결과는 트랜잭션 밖에서도 explorer creator DTO로 변환할 수 있다") + fun shouldLoadCreatorRankingTagsOutsideTransaction() { + saveCreatorRankingWithTag() + + val creators = rankingRepository.getCreatorRankings(memberId = null) + + val response = creators.single().toExplorerSectionCreator("https://cdn.test") + assertEquals("#creator-tag", response.tags) + } + + private fun saveCharacterWithTag(seed: String): Long { + return transactionTemplate.execute { + val tag = kr.co.vividnext.sodalive.chat.character.ChatCharacterTag("$seed-tag") + entityManager.persist(tag) + val character = ChatCharacter( + characterUUID = "$seed-character-uuid", + name = "$seed-character", + description = "$seed-description", + systemPrompt = "$seed-system-prompt" + ) + character.creatorMember = persistCreator("$seed-character-creator") + character.addTag(tag) + chatCharacterRepository.save(character).id!! + }!! + } + + private fun saveTwoCharactersWithSharedTag(): Long { + return transactionTemplate.execute { + val tag = kr.co.vividnext.sodalive.chat.character.ChatCharacterTag("shared-tag") + entityManager.persist(tag) + + val current = ChatCharacter( + characterUUID = "shared-current-uuid", + name = "shared-current", + description = "shared-current-description", + systemPrompt = "shared-current-system-prompt" + ) + current.creatorMember = persistCreator("shared-current-creator") + current.addTag(tag) + chatCharacterRepository.save(current) + + val other = ChatCharacter( + characterUUID = "shared-other-uuid", + name = "shared-other", + description = "shared-other-description", + systemPrompt = "shared-other-system-prompt" + ) + other.creatorMember = persistCreator("shared-other-creator") + other.addTag(tag) + chatCharacterRepository.save(other) + + current.id!! + }!! + } + + private fun saveCharacterWithDetailMappings(seed: String): Long { + return transactionTemplate.execute { + val value = kr.co.vividnext.sodalive.chat.character.ChatCharacterValue("$seed-value") + val hobby = kr.co.vividnext.sodalive.chat.character.ChatCharacterHobby("$seed-hobby") + val goal = kr.co.vividnext.sodalive.chat.character.ChatCharacterGoal("$seed-goal") + entityManager.persist(value) + entityManager.persist(hobby) + entityManager.persist(goal) + + val character = ChatCharacter( + characterUUID = "$seed-character-uuid", + name = "$seed-character", + description = "$seed-description", + systemPrompt = "$seed-system-prompt" + ) + character.creatorMember = persistCreator("$seed-character-creator") + character.addValue(value) + character.addHobby(hobby) + character.addGoal(goal) + chatCharacterRepository.save(character).id!! + }!! + } + + private fun saveCharacterForTranslation(seed: String): Long { + return transactionTemplate.execute { + val tag = kr.co.vividnext.sodalive.chat.character.ChatCharacterTag("$seed-tag") + entityManager.persist(tag) + val character = ChatCharacter( + characterUUID = "$seed-character-uuid", + name = "$seed-character", + description = "$seed-description", + languageCode = "ko", + systemPrompt = "$seed-system-prompt", + gender = "female" + ) + character.creatorMember = persistCreator("$seed-character-creator") + character.addTag(tag) + character.addPersonality("$seed-trait", "$seed-personality-description") + character.addBackground("$seed-topic", "$seed-background-description") + chatCharacterRepository.save(character).id!! + }!! + } + + private fun expectedCharacterTranslationFieldOrder(): List { + return listOf( + "name", + "description", + "gender", + "personalityTrait", + "personalityDescription", + "backgroundTopic", + "backgroundDescription", + "tags" + ) + } + + private fun saveCharactersWithDuplicateSharedTags(): Long { + return transactionTemplate.execute { + val tagA = kr.co.vividnext.sodalive.chat.character.ChatCharacterTag("duplicate-shared-a") + val tagB = kr.co.vividnext.sodalive.chat.character.ChatCharacterTag("duplicate-shared-b") + entityManager.persist(tagA) + entityManager.persist(tagB) + + val current = ChatCharacter( + characterUUID = "duplicate-current-uuid", + name = "duplicate-current", + description = "duplicate-current-description", + systemPrompt = "duplicate-current-system-prompt" + ) + current.creatorMember = persistCreator("duplicate-current-creator") + current.addTag(tagA) + current.addTag(tagB) + chatCharacterRepository.save(current) + + val otherWithTwoSharedTags = ChatCharacter( + characterUUID = "duplicate-other-two-uuid", + name = "duplicate-other-two", + description = "duplicate-other-two-description", + systemPrompt = "duplicate-other-two-system-prompt" + ) + otherWithTwoSharedTags.creatorMember = persistCreator("duplicate-other-two-creator") + otherWithTwoSharedTags.addTag(tagA) + otherWithTwoSharedTags.addTag(tagB) + chatCharacterRepository.save(otherWithTwoSharedTags) + + val otherWithOneSharedTag = ChatCharacter( + characterUUID = "duplicate-other-one-uuid", + name = "duplicate-other-one", + description = "duplicate-other-one-description", + systemPrompt = "duplicate-other-one-system-prompt" + ) + otherWithOneSharedTag.creatorMember = persistCreator("duplicate-other-one-creator") + otherWithOneSharedTag.addTag(tagA) + chatCharacterRepository.save(otherWithOneSharedTag) + + current.id!! + }!! + } + + private fun saveOriginalWorkWithLinkAndTag(seed: String = "original"): Long { + return transactionTemplate.execute { + val originalWork = OriginalWork( + title = "$seed-title", + contentType = "webtoon", + category = "romance", + description = "$seed-description" + ) + val link = OriginalWorkLink( + url = "https://original.test/$seed", + originalWork = originalWork + ) + val tag = OriginalWorkTag("$seed-tag") + val tagMapping = OriginalWorkTagMapping(originalWork, tag) + + entityManager.persist(tag) + originalWork.originalLinks.add(link) + originalWork.tagMappings.add(tagMapping) + + originalWorkRepository.save(originalWork).id!! + }!! + } + + private fun persistCreator(seed: String): Member { + val creator = Member( + email = "$seed@test.com", + password = "password", + nickname = seed, + role = MemberRole.CREATOR + ) + entityManager.persist(creator) + return creator + } + + private fun saveCreatorRankingWithTag() { + transactionTemplate.executeWithoutResult { + val creator = persistCreator("creator-osiv") + + val tag = CreatorTag(tag = "creator-tag") + entityManager.persist(tag) + entityManager.persist(MemberCreatorTag(member = creator, tag = tag)) + + val ranking = CreatorRanking(ranking = 1) + ranking.member = creator + entityManager.persist(ranking) + } + } +}