feat(series-banner): 시리즈 배너를 언어별로 등록하고 노출한다

This commit is contained in:
2026-04-02 16:59:31 +09:00
parent d3b1f4bcd4
commit 3c32559c5d
10 changed files with 371 additions and 8 deletions

View File

@@ -0,0 +1,10 @@
- [x] 시리즈 배너 등록·조회 경로와 언어 처리 기준을 확인한다.
- [x] 배너 등록 시 언어를 저장하고 관리자 목록에서 시리즈 제목에 `(언어)` 표기를 추가한다.
- [x] 사용자 시리즈 메인 조회에서 요청 언어와 일치하는 배너만 반환하도록 수정하고 검증 결과를 기록한다.
## 검증 기록
### 1차 구현
- 무엇을: 시리즈 배너 등록 요청에 `lang`을 추가하고, 관리자 목록에서는 `seriesTitle (언어)` 형태로 응답하며, 사용자 시리즈 메인에서는 `LangContext`와 일치하는 언어 배너만 조회하도록 수정했다.
- 왜: 관리자 화면에서는 같은 시리즈명의 다국어 배너를 구분할 수 있어야 하고, 사용자 화면에서는 요청 언어와 맞는 배너만 노출되어야 하기 때문이다.
- 어떻게: Kotlin LSP가 없어 정적 진단은 Gradle 컴파일로 대체했고, `./gradlew test --tests "kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerServiceTest" --tests "kr.co.vividnext.sodalive.admin.content.series.banner.AdminContentSeriesBannerControllerTest" --tests "kr.co.vividnext.sodalive.content.series.main.SeriesMainControllerTest"`를 실행해 등록 언어 저장, 관리자 목록 언어 표기, 사용자 언어별 배너 조회를 검증했다. 결과는 `BUILD SUCCESSFUL`이다.

View File

@@ -56,7 +56,9 @@ class AdminContentSeriesBannerController(
val banners = bannerService.getActiveBanners(pageable) val banners = bannerService.getActiveBanners(pageable)
val response = SeriesBannerListPageResponse( val response = SeriesBannerListPageResponse(
totalCount = banners.totalElements, totalCount = banners.totalElements,
content = banners.content.map { SeriesBannerResponse.from(it, imageHost) } content = banners.content.map {
SeriesBannerResponse.from(it, imageHost, appendLanguageToSeriesTitle = true)
}
) )
ApiResponse.ok(response) ApiResponse.ok(response)
} }
@@ -82,7 +84,7 @@ class AdminContentSeriesBannerController(
val objectMapper = ObjectMapper() val objectMapper = ObjectMapper()
val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java) val request = objectMapper.readValue(requestString, SeriesBannerRegisterRequest::class.java)
val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "") val banner = bannerService.registerBanner(seriesId = request.seriesId, imagePath = "", lang = request.lang)
val imagePath = saveImage(banner.id!!, image) val imagePath = saveImage(banner.id!!, image)
val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath) val updatedBanner = bannerService.updateBanner(banner.id!!, imagePath)
val response = SeriesBannerResponse.from(updatedBanner, imageHost) val response = SeriesBannerResponse.from(updatedBanner, imageHost)

View File

@@ -2,10 +2,12 @@ package kr.co.vividnext.sodalive.admin.content.series.banner.dto
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
import kr.co.vividnext.sodalive.i18n.Lang
// 시리즈 배너 등록 요청 DTO // 시리즈 배너 등록 요청 DTO
data class SeriesBannerRegisterRequest( data class SeriesBannerRegisterRequest(
@JsonProperty("seriesId") val seriesId: Long @JsonProperty("seriesId") val seriesId: Long,
@JsonProperty("lang") val lang: Lang? = null
) )
// 시리즈 배너 수정 요청 DTO // 시리즈 배너 수정 요청 DTO
@@ -22,14 +24,30 @@ data class SeriesBannerResponse(
val seriesTitle: String val seriesTitle: String
) { ) {
companion object { companion object {
fun from(banner: SeriesBanner, imageHost: String): SeriesBannerResponse { fun from(
banner: SeriesBanner,
imageHost: String,
appendLanguageToSeriesTitle: Boolean = false
): SeriesBannerResponse {
return SeriesBannerResponse( return SeriesBannerResponse(
id = banner.id!!, id = banner.id!!,
imagePath = "$imageHost/${banner.imagePath}", imagePath = "$imageHost/${banner.imagePath}",
seriesId = banner.series.id!!, seriesId = banner.series.id!!,
seriesTitle = banner.series.title seriesTitle = if (appendLanguageToSeriesTitle) {
"${banner.series.title} (${getLanguageLabel(banner.lang)})"
} else {
banner.series.title
}
) )
} }
private fun getLanguageLabel(lang: Lang): String {
return when (lang) {
Lang.KO -> "한국어"
Lang.EN -> "영어"
Lang.JA -> "일본어"
}
}
} }
} }

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.content.series.ContentSeriesService import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
@@ -21,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController
class SeriesMainController( class SeriesMainController(
private val contentSeriesService: ContentSeriesService, private val contentSeriesService: ContentSeriesService,
private val bannerService: ContentSeriesBannerService, private val bannerService: ContentSeriesBannerService,
private val langContext: LangContext,
private val memberContentPreferenceService: MemberContentPreferenceService, private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
@@ -33,7 +35,7 @@ class SeriesMainController(
if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") if (member == null) throw SodaException(messageKey = "common.error.bad_credentials")
val preference = resolvePreference(member) val preference = resolvePreference(member)
val banners = bannerService.getActiveBanners(PageRequest.of(0, 10)) val banners = bannerService.getDisplayBanners(PageRequest.of(0, 10), langContext.lang)
.content .content
.map { .map {
SeriesBannerResponse.from(it, imageHost) SeriesBannerResponse.from(it, imageHost)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.common.SodaException 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.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@@ -16,22 +17,28 @@ class ContentSeriesBannerService(
return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable) return bannerRepository.findByIsActiveTrueOrderBySortOrderAsc(pageable)
} }
fun getDisplayBanners(pageable: Pageable, lang: Lang): Page<SeriesBanner> {
return bannerRepository.findByIsActiveTrueAndLangOrderBySortOrderAsc(lang, pageable)
}
fun getBannerById(bannerId: Long): SeriesBanner { fun getBannerById(bannerId: Long): SeriesBanner {
return bannerRepository.findById(bannerId) return bannerRepository.findById(bannerId)
.orElseThrow { SodaException(messageKey = "series.banner.error.not_found") } .orElseThrow { SodaException(messageKey = "series.banner.error.not_found") }
} }
@Transactional @Transactional
fun registerBanner(seriesId: Long, imagePath: String): SeriesBanner { fun registerBanner(seriesId: Long, imagePath: String, lang: Lang? = null): SeriesBanner {
val series = seriesRepository.findByIdAndActiveTrue(seriesId) val series = seriesRepository.findByIdAndActiveTrue(seriesId)
?: throw SodaException(messageKey = "series.banner.error.series_not_found") ?: throw SodaException(messageKey = "series.banner.error.series_not_found")
val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1 val finalSortOrder = (bannerRepository.findMaxSortOrder() ?: 0) + 1
val finalLang = lang ?: Lang.KO
val banner = SeriesBanner( val banner = SeriesBanner(
imagePath = imagePath, imagePath = imagePath,
series = series, series = series,
sortOrder = finalSortOrder sortOrder = finalSortOrder,
lang = finalLang
) )
return bannerRepository.save(banner) return bannerRepository.save(banner)
} }

View File

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

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.content.series.main.banner package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.i18n.Lang
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
@@ -10,6 +11,8 @@ import org.springframework.stereotype.Repository
interface SeriesBannerRepository : JpaRepository<SeriesBanner, Long> { interface SeriesBannerRepository : JpaRepository<SeriesBanner, Long> {
fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<SeriesBanner> fun findByIsActiveTrueOrderBySortOrderAsc(pageable: Pageable): Page<SeriesBanner>
fun findByIsActiveTrueAndLangOrderBySortOrderAsc(lang: Lang, pageable: Pageable): Page<SeriesBanner>
@Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true") @Query("SELECT MAX(b.sortOrder) FROM SeriesBanner b WHERE b.isActive = true")
fun findMaxSortOrder(): Int? fun findMaxSortOrder(): Int?
} }

View File

@@ -0,0 +1,108 @@
package kr.co.vividnext.sodalive.admin.content.series.banner
import com.amazonaws.services.s3.AmazonS3Client
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
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.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.mock.web.MockMultipartFile
import java.net.URL
class AdminContentSeriesBannerControllerTest {
private val bannerService = Mockito.mock(ContentSeriesBannerService::class.java)
private val amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
private val s3Uploader = S3Uploader(amazonS3Client)
private val controller = AdminContentSeriesBannerController(
bannerService = bannerService,
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(
seriesId = 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 = "{\"seriesId\":1,\"lang\":\"ja\"}"
)
assertTrue(response.success)
assertEquals(10L, response.data?.id)
assertTrue(response.data?.imagePath?.startsWith("https://cdn.test/series_banner/10/") == true)
Mockito.verify(bannerService).registerBanner(1L, "", Lang.JA)
}
@Test
fun shouldDeserializeIso639LanguageCodeToLangEnum() {
val request = ObjectMapper().readValue(
"{\"seriesId\":1,\"lang\":\"en\"}",
kr.co.vividnext.sodalive.admin.content.series.banner.dto.SeriesBannerRegisterRequest::class.java
)
assertEquals(Lang.EN, request.lang)
}
@Test
fun shouldAppendBannerLanguageToSeriesTitleInBannerList() {
val pageable = PageRequest.of(0, 20)
val japaneseBanner = createBanner(id = 12L, lang = Lang.JA, imagePath = "banner/jp.png")
Mockito.`when`(bannerService.getActiveBanners(pageable))
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1))
val response = controller.getBannerList(page = 0, size = 20)
assertTrue(response.success)
assertEquals("series-12 (일본어)", response.data?.content?.first()?.seriesTitle)
}
private fun createBanner(id: Long, lang: Lang, imagePath: String): SeriesBanner {
val series = Series(
title = "series-$id",
introduction = "introduction-$id",
languageCode = "ko"
)
series.id = id
return SeriesBanner(
imagePath = imagePath,
series = series,
sortOrder = 1,
lang = lang
).also {
it.id = id
}
}
}

View File

@@ -0,0 +1,105 @@
package kr.co.vividnext.sodalive.content.series.main
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.series.GetSeriesListResponse
import kr.co.vividnext.sodalive.content.series.main.banner.ContentSeriesBannerService
import kr.co.vividnext.sodalive.content.series.main.banner.SeriesBanner
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference
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 SeriesMainControllerTest {
private val contentSeriesService = Mockito.mock(ContentSeriesService::class.java)
private val bannerService = Mockito.mock(ContentSeriesBannerService::class.java)
private val langContext = LangContext().apply { setLang(Lang.JA) }
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val controller = SeriesMainController(
contentSeriesService = contentSeriesService,
bannerService = bannerService,
langContext = langContext,
memberContentPreferenceService = memberContentPreferenceService,
imageHost = "https://cdn.test"
)
@Test
fun shouldFetchOnlyRequestedLanguageBanners() {
val member = createMember(id = 1L)
val preference = ViewerContentPreference(
countryCode = "KR",
isAdultContentVisible = true,
contentType = ContentType.ALL,
isAdult = true
)
val pageable = PageRequest.of(0, 10)
val japaneseBanner = SeriesBanner(
imagePath = "banner/jp.png",
series = createSeries(id = 10L),
sortOrder = 1,
lang = Lang.JA
).also {
it.id = 100L
}
Mockito.`when`(memberContentPreferenceService.resolveForQuery(member)).thenReturn(preference)
Mockito.`when`(bannerService.getDisplayBanners(pageable, Lang.JA))
.thenReturn(PageImpl(listOf(japaneseBanner), pageable, 1))
Mockito.`when`(
contentSeriesService.getSeriesList(
null,
false,
true,
true,
true,
ContentType.ALL,
member,
0,
20
)
).thenReturn(GetSeriesListResponse(totalCount = 0, items = emptyList()))
Mockito.`when`(
contentSeriesService.getRecommendSeriesList(
true,
ContentType.ALL,
member
)
).thenReturn(emptyList())
val response = controller.fetchData(member)
assertTrue(response.success)
assertEquals(1, response.data?.banners?.size)
assertEquals("series-10", response.data?.banners?.first()?.seriesTitle)
Mockito.verify(bannerService).getDisplayBanners(pageable, Lang.JA)
Mockito.verify(bannerService, Mockito.never()).getActiveBanners(pageable)
}
private fun createMember(id: Long): Member {
return Member(
email = "member-$id@test.com",
password = "password",
nickname = "member-$id"
).also {
it.id = id
}
}
private fun createSeries(id: Long): Series {
return Series(
title = "series-$id",
introduction = "introduction-$id",
languageCode = "ja"
).also {
it.id = id
}
}
}

View File

@@ -0,0 +1,100 @@
package kr.co.vividnext.sodalive.content.series.main.banner
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
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
class ContentSeriesBannerServiceTest {
private lateinit var bannerRepository: SeriesBannerRepository
private lateinit var seriesRepository: AdminContentSeriesRepository
private lateinit var service: ContentSeriesBannerService
@BeforeEach
fun setUp() {
bannerRepository = Mockito.mock(SeriesBannerRepository::class.java)
seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java)
service = ContentSeriesBannerService(
bannerRepository = bannerRepository,
seriesRepository = seriesRepository
)
}
@Test
@DisplayName("일본어 배너 등록 요청은 JA 언어값으로 저장한다")
fun shouldRegisterJapaneseBanner() {
val series = createSeries(id = 1L)
Mockito.`when`(seriesRepository.findByIdAndActiveTrue(1L)).thenReturn(series)
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(2)
Mockito.`when`(bannerRepository.save(Mockito.any(SeriesBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
seriesId = 1L,
imagePath = "banner/jp.png",
lang = Lang.JA
)
assertEquals(Lang.JA, banner.lang)
assertEquals(3, banner.sortOrder)
assertEquals("banner/jp.png", banner.imagePath)
}
@Test
@DisplayName("언어가 없는 배너 등록 요청은 KO로 저장한다")
fun shouldRegisterKoreanBannerWhenLangIsMissing() {
val series = createSeries(id = 2L)
Mockito.`when`(seriesRepository.findByIdAndActiveTrue(2L)).thenReturn(series)
Mockito.`when`(bannerRepository.findMaxSortOrder()).thenReturn(null)
Mockito.`when`(bannerRepository.save(Mockito.any(SeriesBanner::class.java))).thenAnswer { it.arguments[0] }
val banner = service.registerBanner(
seriesId = 2L,
imagePath = "banner/default.png",
lang = null
)
assertEquals(Lang.KO, banner.lang)
assertEquals(1, banner.sortOrder)
}
@Test
@DisplayName("일본어 사용자는 일본어 배너만 조회한다")
fun shouldReturnJapaneseBannersForJapaneseUser() {
val pageable = PageRequest.of(0, 10)
val japaneseBanner = SeriesBanner(
imagePath = "banner/jp.png",
series = createSeries(id = 3L),
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())
.findByIsActiveTrueOrderBySortOrderAsc(pageable)
}
private fun createSeries(id: Long): Series {
return Series(
title = "series-$id",
introduction = "introduction-$id",
languageCode = "ko"
).also {
it.id = id
}
}
}