feat(aicharacter): 크리에이터 회원 연결을 추가한다
This commit is contained in:
@@ -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<ChatCharacterMemory> = mutableListOf()
|
||||
|
||||
|
||||
@@ -99,4 +99,6 @@ interface ChatCharacterRepository : JpaRepository<ChatCharacter, Long> {
|
||||
fun findRandomActiveExcluding(@Param("excludeIds") excludeIds: List<Long>, pageable: Pageable): List<ChatCharacter>
|
||||
|
||||
fun findByIdInAndIsActiveTrue(ids: List<Long>): List<ChatCharacter>
|
||||
fun findByCreatorMemberId(creatorMemberId: Long): ChatCharacter?
|
||||
fun existsByCreatorMemberId(creatorMemberId: Long): Boolean
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user