From 74414937cfefd18bd4869dc7a4af2b373e266a02 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 12 Jun 2026 10:56:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(aicharacter):=20=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=97=90=EC=9D=B4=ED=84=B0=20=ED=9A=8C=EC=9B=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/chat/character/ChatCharacter.kt | 6 + .../repository/ChatCharacterRepository.kt | 2 + .../ChatCharacterCreatorMemberService.kt | 57 +++++ .../character/service/ChatCharacterService.kt | 10 +- .../ChatCharacterCreatorMemberServiceTest.kt | 215 ++++++++++++++++++ ...ltHomeRecommendationQueryRepositoryTest.kt | 13 ++ 6 files changed, 301 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt index 87369943..961d4b10 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/ChatCharacter.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character import kr.co.vividnext.sodalive.chat.original.OriginalWork import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.member.Member import javax.persistence.CascadeType import javax.persistence.Column import javax.persistence.Entity @@ -11,6 +12,7 @@ import javax.persistence.FetchType import javax.persistence.JoinColumn import javax.persistence.ManyToOne import javax.persistence.OneToMany +import javax.persistence.OneToOne @Entity class ChatCharacter( @@ -75,6 +77,10 @@ class ChatCharacter( ) : BaseEntity() { var imagePath: String? = null + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "creator_member_id", nullable = false, unique = true) + var creatorMember: Member? = null + @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true) var memories: MutableList = mutableListOf() 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 e35bf6fe..3c90f856 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 @@ -99,4 +99,6 @@ interface ChatCharacterRepository : JpaRepository { fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List, pageable: Pageable): List fun findByIdInAndIsActiveTrue(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/ChatCharacterCreatorMemberService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt new file mode 100644 index 00000000..932304f1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberService.kt @@ -0,0 +1,57 @@ +package kr.co.vividnext.sodalive.chat.character.service + +import kr.co.vividnext.sodalive.chat.character.ChatCharacter +import kr.co.vividnext.sodalive.common.SodaException +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 org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class ChatCharacterCreatorMemberService( + private val memberRepository: MemberRepository +) { + @Transactional + fun ensureAiCharacterCreatorMember(chatCharacter: ChatCharacter): Member { + val creatorMember = chatCharacter.creatorMember + if (creatorMember != null) { + if (creatorMember.memberKind == MemberKind.AI_CHARACTER) { + syncDisplayFields(creatorMember, chatCharacter) + memberRepository.save(creatorMember) + } + return creatorMember + } + + val member = Member( + email = null, + password = "", + nickname = chatCharacter.name, + profileImage = chatCharacter.imagePath, + role = MemberRole.CREATOR, + memberKind = MemberKind.AI_CHARACTER + ) + member.introduce = chatCharacter.description + + val savedMember = memberRepository.save(member) + chatCharacter.creatorMember = savedMember + return savedMember + } + + @Transactional + fun syncAiCharacterCreatorMemberDisplayFields(chatCharacter: ChatCharacter) { + val creatorMember = chatCharacter.creatorMember + ?: throw SodaException(messageKey = "common.error.invalid_request") + if (creatorMember.memberKind != MemberKind.AI_CHARACTER) return + + syncDisplayFields(creatorMember, chatCharacter) + memberRepository.save(creatorMember) + } + + private fun syncDisplayFields(member: Member, chatCharacter: ChatCharacter) { + member.nickname = chatCharacter.name + member.profileImage = chatCharacter.imagePath + member.introduce = chatCharacter.description + } +} 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 23eb26ef..f400163d 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 @@ -36,6 +36,7 @@ class ChatCharacterService( private val goalRepository: ChatCharacterGoalRepository, private val popularCharacterQuery: PopularCharacterQuery, private val imageRepository: CharacterImageRepository, + private val creatorMemberService: ChatCharacterCreatorMemberService, @Value("\${cloud.aws.cloud-front.host}") private val imageHost: String @@ -616,6 +617,7 @@ class ChatCharacterService( addHobbiesToCharacter(chatCharacter, hobbies) addGoalsToCharacter(chatCharacter, goals) + creatorMemberService.ensureAiCharacterCreatorMember(chatCharacter) return saveChatCharacter(chatCharacter) } @@ -721,7 +723,9 @@ class ChatCharacterService( val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "") chatCharacter.name = inactiveName + randomSuffix - return saveChatCharacter(chatCharacter) + val savedChatCharacter = saveChatCharacter(chatCharacter) + creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(savedChatCharacter) + return savedChatCharacter } // 이미지 경로가 있으면 설정 @@ -779,6 +783,8 @@ class ChatCharacterService( updateRelationshipsForCharacter(chatCharacter, request.relationships) } - return saveChatCharacter(chatCharacter) + val savedChatCharacter = saveChatCharacter(chatCharacter) + creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(savedChatCharacter) + return savedChatCharacter } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt new file mode 100644 index 00000000..7847bf60 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/chat/character/service/ChatCharacterCreatorMemberServiceTest.kt @@ -0,0 +1,215 @@ +package kr.co.vividnext.sodalive.chat.character.service + +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.common.SodaException +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 org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.util.Optional + +class ChatCharacterCreatorMemberServiceTest { + private lateinit var memberRepository: MemberRepository + private lateinit var chatCharacterRepository: ChatCharacterRepository + private lateinit var creatorMemberService: ChatCharacterCreatorMemberService + + @BeforeEach + fun setUp() { + memberRepository = Mockito.mock(MemberRepository::class.java) + chatCharacterRepository = Mockito.mock(ChatCharacterRepository::class.java) + creatorMemberService = ChatCharacterCreatorMemberService(memberRepository) + } + + @Test + fun `ChatCharacter creatorMember 관계와 repository 메서드를 사용할 수 있다`() { + val member = createMember(memberKind = MemberKind.AI_CHARACTER) + val chatCharacter = createCharacter().apply { creatorMember = member } + + Mockito.`when`(chatCharacterRepository.findByCreatorMemberId(1L)).thenReturn(chatCharacter) + Mockito.`when`(chatCharacterRepository.existsByCreatorMemberId(1L)).thenReturn(true) + + assertSame(member, chatCharacter.creatorMember) + assertSame(chatCharacter, chatCharacterRepository.findByCreatorMemberId(1L)) + assertEquals(true, chatCharacterRepository.existsByCreatorMemberId(1L)) + } + + @Test + fun `AI 캐릭터용 Member를 생성하고 표시 정보를 복사한다`() { + val chatCharacter = createCharacter( + name = "소다", + description = "AI 캐릭터 설명", + imagePath = "characters/1/profile.png" + ) + Mockito.`when`(memberRepository.save(Mockito.any(Member::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as Member).apply { id = 10L } + } + + val member = creatorMemberService.ensureAiCharacterCreatorMember(chatCharacter) + + assertSame(member, chatCharacter.creatorMember) + assertNull(member.email) + assertEquals("", member.password) + assertEquals(MemberRole.CREATOR, member.role) + assertEquals(MemberKind.AI_CHARACTER, member.memberKind) + assertEquals("소다", member.nickname) + assertEquals("characters/1/profile.png", member.profileImage) + assertEquals("AI 캐릭터 설명", member.introduce) + Mockito.verify(memberRepository).save(member) + } + + @Test + fun `AI 캐릭터용 Member 표시 정보를 동기화한다`() { + val member = createMember(memberKind = MemberKind.AI_CHARACTER).apply { + nickname = "old-name" + profileImage = "old/profile.png" + introduce = "old-description" + } + val chatCharacter = createCharacter( + name = "new-name", + description = "new-description", + imagePath = "new/profile.png" + ).apply { creatorMember = member } + + creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter) + + assertEquals("new-name", member.nickname) + assertEquals("new/profile.png", member.profileImage) + assertEquals("new-description", member.introduce) + Mockito.verify(memberRepository).save(member) + } + + @Test + fun `동기화 대상 creatorMember가 없으면 저장 실패를 위해 예외를 던진다`() { + val chatCharacter = createCharacter( + id = 1L, + name = "missing-creator-member", + description = "description" + ) + + val exception = assertThrows(SodaException::class.java) { + creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + Mockito.verifyNoInteractions(memberRepository) + } + + @Test + fun `사람 크리에이터 Member 표시 정보는 덮어쓰지 않는다`() { + val member = createMember(memberKind = MemberKind.HUMAN).apply { + nickname = "human-name" + profileImage = "human/profile.png" + introduce = "human-description" + } + val chatCharacter = createCharacter( + name = "ai-name", + description = "ai-description", + imagePath = "ai/profile.png" + ).apply { creatorMember = member } + + creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(chatCharacter) + + assertEquals("human-name", member.nickname) + assertEquals("human/profile.png", member.profileImage) + assertEquals("human-description", member.introduce) + Mockito.verifyNoInteractions(memberRepository) + } + + @Test + fun `캐릭터 생성 시 저장 전에 AI 캐릭터용 Member 생성을 요청한다`() { + val service = createChatCharacterService() + Mockito.`when`(chatCharacterRepository.save(Mockito.any(ChatCharacter::class.java))).thenAnswer { invocation -> + (invocation.arguments[0] as ChatCharacter).apply { id = 1L } + } + + val chatCharacter = service.createChatCharacter( + characterUUID = "character-1", + name = "created-name", + description = "created-description", + systemPrompt = "system-prompt" + ) + + val inOrder = Mockito.inOrder(creatorMemberService, chatCharacterRepository) + inOrder.verify(creatorMemberService).ensureAiCharacterCreatorMember(chatCharacter) + inOrder.verify(chatCharacterRepository).save(chatCharacter) + } + + @Test + fun `캐릭터 수정 시 AI 캐릭터용 Member 표시 정보 동기화를 요청한다`() { + val service = createChatCharacterService() + val chatCharacter = createCharacter(id = 1L).apply { + creatorMember = createMember(memberKind = MemberKind.AI_CHARACTER) + } + Mockito.`when`(chatCharacterRepository.findById(1L)).thenReturn(Optional.of(chatCharacter)) + Mockito.`when`(chatCharacterRepository.save(Mockito.any(ChatCharacter::class.java))).thenAnswer { it.arguments[0] } + + service.updateChatCharacterWithDetails( + imagePath = "updated/profile.png", + request = kr.co.vividnext.sodalive.admin.chat.character.dto.ChatCharacterUpdateRequest( + id = 1L, + name = "updated-name", + description = "updated-description" + ) + ) + + assertEquals("updated-name", chatCharacter.name) + assertEquals("updated-description", chatCharacter.description) + assertEquals("updated/profile.png", chatCharacter.imagePath) + Mockito.verify(creatorMemberService).syncAiCharacterCreatorMemberDisplayFields(chatCharacter) + } + + private fun createChatCharacterService(): ChatCharacterService { + creatorMemberService = Mockito.mock(ChatCharacterCreatorMemberService::class.java) + return ChatCharacterService( + chatCharacterRepository = chatCharacterRepository, + tagRepository = Mockito.mock(ChatCharacterTagRepository::class.java), + valueRepository = Mockito.mock(ChatCharacterValueRepository::class.java), + hobbyRepository = Mockito.mock(ChatCharacterHobbyRepository::class.java), + goalRepository = Mockito.mock(ChatCharacterGoalRepository::class.java), + popularCharacterQuery = Mockito.mock(PopularCharacterQuery::class.java), + imageRepository = Mockito.mock(CharacterImageRepository::class.java), + creatorMemberService = creatorMemberService, + imageHost = "https://cdn.example.com" + ) + } + + private fun createCharacter( + id: Long? = null, + name: String = "character-name", + description: String = "character-description", + imagePath: String? = null + ): ChatCharacter { + val character = ChatCharacter( + characterUUID = "character-uuid", + name = name, + description = description, + systemPrompt = "system-prompt" + ) + character.id = id + character.imagePath = imagePath + return character + } + + private fun createMember(memberKind: MemberKind): Member { + return Member( + email = if (memberKind == MemberKind.HUMAN) "human@example.com" else null, + password = "password", + nickname = "member-name", + role = MemberRole.CREATOR, + memberKind = memberKind + ).apply { id = 1L } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 0f9a3d19..b5b16d0c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCo import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberKind import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.following.CreatorFollowing @@ -1793,6 +1794,17 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( } private fun saveCharacter(name: String, isActive: Boolean, originalWork: OriginalWork? = null): ChatCharacter { + val creatorMember = Member( + email = null, + password = "", + nickname = name, + role = MemberRole.CREATOR, + memberKind = MemberKind.AI_CHARACTER, + isActive = isActive + ) + creatorMember.introduce = "description" + entityManager.persist(creatorMember) + val character = ChatCharacter( characterUUID = "$name-uuid", name = name, @@ -1801,6 +1813,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( isActive = isActive ) character.originalWork = originalWork + character.creatorMember = creatorMember entityManager.persist(character) return character }