feat(chat-banner): 다국어 캐릭터 배너 등록과 노출을 지원한다

배너를 언어별로 저장하고 요청 언어 우선 조회 후 한국어로 fallback 하도록 맞춘다.
This commit is contained in:
2026-04-02 15:32:42 +09:00
parent 06acfae1c9
commit ee14389786
12 changed files with 543 additions and 5 deletions

View File

@@ -127,7 +127,8 @@ class AdminChatBannerController(
// 1. 먼저 빈 이미지 경로로 배너 등록 (정렬 순서 포함)
val banner = bannerService.registerBanner(
characterId = request.characterId,
imagePath = ""
imagePath = "",
lang = request.lang
)
// 2. 배너 ID를 사용하여 이미지 업로드

View File

@@ -1,13 +1,15 @@
package kr.co.vividnext.sodalive.admin.chat.dto
import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.i18n.Lang
/**
* 캐릭터 배너 등록 요청 DTO
*/
data class ChatCharacterBannerRegisterRequest(
// 캐릭터 ID
@JsonProperty("characterId") val characterId: Long
@JsonProperty("characterId") val characterId: Long,
@JsonProperty("lang") val lang: Lang? = null
)
/**

View File

@@ -1,7 +1,11 @@
package kr.co.vividnext.sodalive.chat.character
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.i18n.Lang
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.FetchType
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
@@ -24,6 +28,10 @@ class ChatCharacterBanner(
// 정렬 순서 (낮을수록 먼저 표시)
var sortOrder: Int = 0,
@Column(nullable = false)
@Enumerated(EnumType.STRING)
var lang: Lang = Lang.KO,
// 활성화 여부 (소프트 삭제용)
var isActive: Boolean = true
) : BaseEntity()

View File

@@ -62,7 +62,7 @@ class ChatCharacterController(
val isAdultAccessible = resolveIsAdultAccessible(member)
// 배너 조회 (최대 10개)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10))
val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang)
.content
.map {
CharacterBannerResponse(

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.chat.character.repository
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
@@ -12,6 +13,8 @@ interface ChatCharacterBannerRepository : JpaRepository<ChatCharacterBanner, Lon
// 활성화된 배너 목록 조회 (정렬 순서대로)
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<ChatCharacterBanner>
fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page<ChatCharacterBanner>
// 활성화된 배너 중 최대 정렬 순서 값 조회
@Query("SELECT MAX(b.sortOrder) FROM ChatCharacterBanner b WHERE b.isActive = true")
fun findMaxSortOrder(): Int?

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
@@ -21,6 +22,19 @@ class ChatCharacterBannerService(
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
}
fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<ChatCharacterBanner> {
if (lang == Lang.KO) {
return bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
val localizedBanners = bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(lang, pageable)
return if (localizedBanners.hasContent()) {
localizedBanners
} else {
bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
}
/**
* 배너 상세 조회
*/
@@ -37,7 +51,10 @@ class ChatCharacterBannerService(
* @return 등록된 배너
*/
@Transactional
fun registerBanner(characterId: Long, imagePath: String): ChatCharacterBanner {
fun registerBanner(characterId: Long, imagePath: String, lang: Lang? = null): ChatCharacterBanner {
val finalLang = lang ?: Lang.KO
validateRegisterLang(finalLang)
val character = characterRepository.findById(characterId)
.orElseThrow { SodaException(messageKey = "chat.character.not_found") }
@@ -51,12 +68,21 @@ class ChatCharacterBannerService(
val banner = ChatCharacterBanner(
imagePath = imagePath,
chatCharacter = character,
sortOrder = finalSortOrder
sortOrder = finalSortOrder,
lang = finalLang
)
return bannerRepository.save(banner)
}
private fun validateRegisterLang(lang: Lang) {
if (lang == Lang.KO || lang == Lang.EN || lang == Lang.JA) {
return
}
throw SodaException(messageKey = "common.error.invalid_request")
}
/**
* 배너 수정
*

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.i18n
import com.fasterxml.jackson.annotation.JsonCreator
import java.util.Locale
enum class Lang(val code: String, val locale: Locale) {
@@ -8,6 +9,14 @@ enum class Lang(val code: String, val locale: Locale) {
JA("ja", Locale.JAPANESE);
companion object {
@JvmStatic
@JsonCreator(mode = JsonCreator.Mode.DELEGATING)
fun fromCode(value: String): Lang {
return values().find {
it.code.equals(value.trim(), ignoreCase = true) || it.name.equals(value.trim(), ignoreCase = true)
} ?: throw IllegalArgumentException("Unknown language code: $value")
}
fun fromAcceptLanguage(header: String?): Lang {
if (header.isNullOrBlank()) return KO
val two = header.trim().lowercase().take(2) // 앱은 2자리만 보내지만 안전하게 처리

View File

@@ -0,0 +1,128 @@
package kr.co.vividnext.sodalive.admin.chat
import com.amazonaws.services.s3.AmazonS3Client
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.chat.character.service.AdminChatCharacterService
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.mock.web.MockMultipartFile
import java.net.URL
class AdminChatBannerControllerTest {
private val bannerService = Mockito.mock(ChatCharacterBannerService::class.java)
private val adminCharacterService = Mockito.mock(AdminChatCharacterService::class.java)
private val amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
private val s3Uploader = S3Uploader(amazonS3Client)
private val controller = AdminChatBannerController(
bannerService = bannerService,
adminCharacterService = adminCharacterService,
s3Uploader = s3Uploader,
langContext = LangContext(),
messageSource = SodaMessageSource(),
s3Bucket = "test-bucket",
imageHost = "https://cdn.test"
)
@Test
fun shouldRegisterJapaneseBannerThroughAdminApi() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val registeredBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
val updatedBanner = createBanner(id = 10L, lang = Lang.JA, imagePath = "")
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
Mockito.`when`(
bannerService.registerBanner(
characterId = 1L,
imagePath = "",
lang = Lang.JA
)
).thenReturn(registeredBanner)
Mockito.doAnswer {
updatedBanner.apply {
imagePath = it.arguments[1] as String
}
}.`when`(bannerService).updateBanner(Mockito.eq(10L), Mockito.anyString(), Mockito.isNull())
val response = controller.registerBanner(
image = image,
requestString = "{\"characterId\":1,\"lang\":\"ja\"}"
)
assertTrue(response.success)
assertEquals(10L, response.data?.id)
assertTrue(response.data?.imagePath?.startsWith("https://cdn.test/characters/banners/10/") == true)
Mockito.verify(bannerService).registerBanner(1L, "", Lang.JA)
}
@Test
fun shouldDeserializeIso639LanguageCodeToLangEnum() {
val request = ObjectMapper().readValue(
"{\"characterId\":1,\"lang\":\"en\"}",
kr.co.vividnext.sodalive.admin.chat.dto.ChatCharacterBannerRegisterRequest::class.java
)
assertEquals(Lang.EN, request.lang)
}
@Test
fun shouldRegisterKoreanBannerByDefaultWhenLangIsMissing() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val registeredBanner = createBanner(id = 11L, lang = Lang.KO, imagePath = "")
val updatedBanner = createBanner(id = 11L, lang = Lang.KO, imagePath = "")
Mockito.`when`(amazonS3Client.getUrl(Mockito.eq("test-bucket"), Mockito.anyString()))
.thenAnswer { URL("https://cdn.test/${it.arguments[1]}") }
Mockito.`when`(
bannerService.registerBanner(
characterId = 2L,
imagePath = "",
lang = null
)
).thenReturn(registeredBanner)
Mockito.doAnswer {
updatedBanner.apply {
imagePath = it.arguments[1] as String
}
}.`when`(bannerService).updateBanner(Mockito.eq(11L), Mockito.anyString(), Mockito.isNull())
val response = controller.registerBanner(
image = image,
requestString = "{\"characterId\":2}"
)
assertTrue(response.success)
assertEquals(11L, response.data?.id)
Mockito.verify(bannerService).registerBanner(2L, "", null)
}
private fun createBanner(id: Long, lang: Lang, imagePath: String): ChatCharacterBanner {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
)
character.id = id
return ChatCharacterBanner(
imagePath = imagePath,
chatCharacter = character,
sortOrder = 1,
lang = lang
).also {
it.id = id
}
}
}

View File

@@ -0,0 +1,118 @@
package kr.co.vividnext.sodalive.chat.character.controller
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.comment.CharacterCommentService
import kr.co.vividnext.sodalive.chat.character.curation.CharacterCurationQueryService
import kr.co.vividnext.sodalive.chat.character.dto.RecentCharactersResponse
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterBannerService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.chat.character.translate.AiCharacterTranslationRepository
import kr.co.vividnext.sodalive.chat.room.service.ChatRoomService
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.translation.PapagoTranslationService
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
class ChatCharacterControllerTest {
private val service = Mockito.mock(ChatCharacterService::class.java)
private val bannerService = Mockito.mock(ChatCharacterBannerService::class.java)
private val chatRoomService = Mockito.mock(ChatRoomService::class.java)
private val characterCommentService = Mockito.mock(CharacterCommentService::class.java)
private val curationQueryService = Mockito.mock(CharacterCurationQueryService::class.java)
private val translationService = Mockito.mock(PapagoTranslationService::class.java)
private val aiCharacterTranslationRepository = Mockito.mock(AiCharacterTranslationRepository::class.java)
private val langContext = LangContext().apply { setLang(Lang.JA) }
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val controller = ChatCharacterController(
service = service,
bannerService = bannerService,
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = langContext,
memberContentPreferenceService = memberContentPreferenceService,
imageHost = "https://cdn.test"
)
@Test
fun shouldUseJapaneseBannerWhenLangContextIsJapanese() {
val pageable = PageRequest.of(0, 10)
val banner = createBanner(id = 1L, imagePath = "banner/jp.png", lang = Lang.JA)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.JA))
.thenReturn(PageImpl(listOf(banner), pageable, 1))
Mockito.`when`(service.getPopularCharacters(locale = "ja")).thenReturn(emptyList())
Mockito.`when`(service.getRecentCharactersPage(page = 0, size = 50))
.thenReturn(RecentCharactersResponse(totalCount = 0, content = emptyList()))
Mockito.`when`(service.getRecommendCharacters(emptyList(), 30)).thenReturn(emptyList())
Mockito.`when`(curationQueryService.getActiveCurationsWithCharacters()).thenReturn(emptyList())
val response = controller.getCharacterMain(member = null)
assertTrue(response.success)
assertEquals(1, response.data?.banners?.size)
assertEquals(1L, response.data?.banners?.first()?.characterId)
assertEquals("https://cdn.test/banner/jp.png", response.data?.banners?.first()?.imageUrl)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.JA)
}
@Test
fun shouldUseEnglishRequestWithKoreanFallbackBanner() {
val controller = ChatCharacterController(
service = service,
bannerService = bannerService,
chatRoomService = chatRoomService,
characterCommentService = characterCommentService,
curationQueryService = curationQueryService,
translationService = translationService,
aiCharacterTranslationRepository = aiCharacterTranslationRepository,
langContext = LangContext().apply { setLang(Lang.EN) },
memberContentPreferenceService = memberContentPreferenceService,
imageHost = "https://cdn.test"
)
val pageable = PageRequest.of(0, 10)
val banner = createBanner(id = 2L, imagePath = "banner/ko.png", lang = Lang.KO)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.EN))
.thenReturn(PageImpl(listOf(banner), pageable, 1))
Mockito.`when`(service.getPopularCharacters(locale = "en")).thenReturn(emptyList())
Mockito.`when`(service.getRecentCharactersPage(page = 0, size = 50))
.thenReturn(RecentCharactersResponse(totalCount = 0, content = emptyList()))
Mockito.`when`(service.getRecommendCharacters(emptyList(), 30)).thenReturn(emptyList())
Mockito.`when`(curationQueryService.getActiveCurationsWithCharacters()).thenReturn(emptyList())
val response = controller.getCharacterMain(member = null)
assertTrue(response.success)
assertEquals("https://cdn.test/banner/ko.png", response.data?.banners?.first()?.imageUrl)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.EN)
}
private fun createBanner(id: Long, imagePath: String, lang: Lang): ChatCharacterBanner {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
)
character.id = id
return ChatCharacterBanner(
imagePath = imagePath,
chatCharacter = character,
sortOrder = 1,
lang = lang
).also {
it.id = id
}
}
}

View File

@@ -0,0 +1,177 @@
package kr.co.vividnext.sodalive.chat.character.service
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.character.ChatCharacterBanner
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterBannerRepository
import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository
import kr.co.vividnext.sodalive.i18n.Lang
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import java.util.Optional
class ChatCharacterBannerServiceTest {
private lateinit var bannerRepository: ChatCharacterBannerRepository
private lateinit var characterRepository: ChatCharacterRepository
private lateinit var service: ChatCharacterBannerService
@BeforeEach
fun setUp() {
bannerRepository = Mockito.mock(ChatCharacterBannerRepository::class.java)
characterRepository = Mockito.mock(ChatCharacterRepository::class.java)
service = ChatCharacterBannerService(
bannerRepository = bannerRepository,
characterRepository = characterRepository
)
}
@Test
@DisplayName("일본어 배너 등록 요청은 JA 언어값으로 저장한다")
fun shouldRegisterJapaneseBanner() {
val character = createCharacter(id = 1L)
Mockito.`when`(characterRepository.findById(1L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(3)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 1L,
imagePath = "banner/jp.png",
lang = Lang.JA
)
assertEquals(Lang.JA, banner.lang)
assertEquals(4, banner.sortOrder)
assertEquals("banner/jp.png", banner.imagePath)
}
@Test
@DisplayName("기본 배너 등록 요청은 언어값이 없으면 KO로 저장한다")
fun shouldRegisterDefaultBannerAsKoreanWhenLangIsNull() {
val character = createCharacter(id = 2L)
Mockito.`when`(characterRepository.findById(2L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(null)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 2L,
imagePath = "banner/default.png",
lang = null
)
assertEquals(Lang.KO, banner.lang)
assertEquals(1, banner.sortOrder)
}
@Test
@DisplayName("영어 배너 등록 요청은 EN 언어값으로 저장한다")
fun shouldRegisterEnglishBanner() {
val character = createCharacter(id = 3L)
Mockito.`when`(characterRepository.findById(3L)).thenReturn(Optional.of(character))
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(5)
Mockito.`when`(bannerRepository.save(Mockito.any(ChatCharacterBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
characterId = 3L,
imagePath = "banner/en.png",
lang = Lang.EN
)
assertEquals(Lang.EN, banner.lang)
assertEquals(6, banner.sortOrder)
}
@Test
@DisplayName("일본어 사용자는 일본어 배너만 조회한다")
fun shouldReturnJapaneseBannersForJapaneseUser() {
val pageable = PageRequest.of(0, 10)
val japaneseBanner = ChatCharacterBanner(
imagePath = "banner/jp.png",
chatCharacter = createCharacter(id = 4L),
sortOrder = 1,
lang = Lang.JA
)
val expectedPage = PageImpl(listOf(japaneseBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable))
.thenReturn(expectedPage)
val actual = service.getDisplayBanners(pageable, Lang.JA)
assertEquals(expectedPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.JA, pageable)
Mockito.verify(bannerRepository, Mockito.never())
.findByIsActiveTrueAndLangOrderBySortOrderAsc(
Lang.KO,
pageable
)
}
@Test
@DisplayName("한국어 사용자는 한국어 배너를 조회한다")
fun shouldReturnKoreanBannersForKoreanUser() {
val pageable = PageRequest.of(0, 10)
val defaultBanner = ChatCharacterBanner(
imagePath = "banner/ko.png",
chatCharacter = createCharacter(id = 5L),
sortOrder = 1,
lang = Lang.KO
)
val expectedPage = PageImpl(listOf(defaultBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable))
.thenReturn(expectedPage)
val actual = service.getDisplayBanners(pageable, Lang.KO)
assertEquals(expectedPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
Mockito.verify(bannerRepository, Mockito.never())
.findByIsActiveTrueAndLangOrderBySortOrderAsc(
Lang.JA,
pageable
)
}
@Test
@DisplayName("영어 배너가 없으면 한국어 배너로 fallback 한다")
fun shouldFallbackToKoreanWhenEnglishBannerDoesNotExist() {
val pageable = PageRequest.of(0, 10)
val englishPage = PageImpl<ChatCharacterBanner>(emptyList(), pageable, 0)
val koreanBanner = ChatCharacterBanner(
imagePath = "banner/ko.png",
chatCharacter = createCharacter(id = 6L),
sortOrder = 1,
lang = Lang.KO
)
val koreanPage = PageImpl(listOf(koreanBanner), pageable, 1)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.EN, pageable))
.thenReturn(englishPage)
Mockito.`when`(bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable))
.thenReturn(koreanPage)
val actual = service.getDisplayBanners(pageable, Lang.EN)
assertEquals(koreanPage, actual)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.EN, pageable)
Mockito.verify(bannerRepository).findByIsActiveTrueAndLangOrderBySortOrderAsc(Lang.KO, pageable)
}
private fun createCharacter(id: Long): ChatCharacter {
val character = ChatCharacter(
characterUUID = "character-$id",
name = "character-$id",
description = "description-$id",
systemPrompt = "system-prompt-$id"
)
character.id = id
return character
}
}