feat(aicharacter): 크리에이터 회원 연결을 추가한다

This commit is contained in:
2026-06-12 10:56:55 +09:00
parent 72e6efe3e6
commit 74414937cf
6 changed files with 301 additions and 2 deletions

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.chat.original.OriginalWork import kr.co.vividnext.sodalive.chat.original.OriginalWork
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.CascadeType import javax.persistence.CascadeType
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
@@ -11,6 +12,7 @@ import javax.persistence.FetchType
import javax.persistence.JoinColumn import javax.persistence.JoinColumn
import javax.persistence.ManyToOne import javax.persistence.ManyToOne
import javax.persistence.OneToMany import javax.persistence.OneToMany
import javax.persistence.OneToOne
@Entity @Entity
class ChatCharacter( class ChatCharacter(
@@ -75,6 +77,10 @@ class ChatCharacter(
) : BaseEntity() { ) : BaseEntity() {
var imagePath: String? = null 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) @OneToMany(mappedBy = "chatCharacter", cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
var memories: MutableList<ChatCharacterMemory> = mutableListOf() var memories: MutableList<ChatCharacterMemory> = mutableListOf()

View File

@@ -99,4 +99,6 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter> fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter> fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
} }

View File

@@ -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
}
}

View File

@@ -36,6 +36,7 @@ class ChatCharacterService(
private val goalRepository: ChatCharacterGoalRepository, private val goalRepository: ChatCharacterGoalRepository,
private val popularCharacterQuery: PopularCharacterQuery, private val popularCharacterQuery: PopularCharacterQuery,
private val imageRepository: CharacterImageRepository, private val imageRepository: CharacterImageRepository,
private val creatorMemberService: ChatCharacterCreatorMemberService,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String private val imageHost: String
@@ -616,6 +617,7 @@ class ChatCharacterService(
addHobbiesToCharacter(chatCharacter, hobbies) addHobbiesToCharacter(chatCharacter, hobbies)
addGoalsToCharacter(chatCharacter, goals) addGoalsToCharacter(chatCharacter, goals)
creatorMemberService.ensureAiCharacterCreatorMember(chatCharacter)
return saveChatCharacter(chatCharacter) return saveChatCharacter(chatCharacter)
} }
@@ -721,7 +723,9 @@ class ChatCharacterService(
val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "") val randomSuffix = "_" + java.util.UUID.randomUUID().toString().replace("-", "")
chatCharacter.name = inactiveName + randomSuffix 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) updateRelationshipsForCharacter(chatCharacter, request.relationships)
} }
return saveChatCharacter(chatCharacter) val savedChatCharacter = saveChatCharacter(chatCharacter)
creatorMemberService.syncAiCharacterCreatorMemberDisplayFields(savedChatCharacter)
return savedChatCharacter
} }
} }

View File

@@ -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 }
}
}

View File

@@ -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.i18n.Lang
import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member 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.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.following.CreatorFollowing 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 { 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( val character = ChatCharacter(
characterUUID = "$name-uuid", characterUUID = "$name-uuid",
name = name, name = name,
@@ -1801,6 +1813,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
isActive = isActive isActive = isActive
) )
character.originalWork = originalWork character.originalWork = originalWork
character.creatorMember = creatorMember
entityManager.persist(character) entityManager.persist(character)
return character return character
} }