feat(content-banner): 오디오 콘텐츠 배너를 언어별로 등록하고 노출한다

This commit is contained in:
2026-04-02 17:55:20 +09:00
parent 3c32559c5d
commit 7f1606a8aa
11 changed files with 508 additions and 6 deletions

View File

@@ -72,7 +72,7 @@ class AdminContentBannerService(
null
}
val audioContentBanner = AudioContentBanner(type = request.type)
val audioContentBanner = AudioContentBanner(type = request.type, lang = request.lang)
audioContentBanner.link = request.link
audioContentBanner.isAdult = request.isAdult
audioContentBanner.event = event

View File

@@ -1,9 +1,11 @@
package kr.co.vividnext.sodalive.admin.content.banner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.i18n.Lang
data class CreateContentBannerRequest(
val type: AudioContentBannerType,
val lang: Lang,
val tabId: Long?,
val eventId: Long?,
val creatorId: Long?,

View File

@@ -124,7 +124,8 @@ class HomeService(
val bannerList = bannerService.getBannerList(
tabId = 1,
memberId = member?.id,
isAdult = isAdult
isAdult = isAdult,
lang = langContext.lang
)
val originalAudioDramaList = seriesService.getOriginalAudioDramaList(

View File

@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import javax.persistence.Column
import javax.persistence.Entity
@@ -22,6 +23,9 @@ data class AudioContentBanner(
@Enumerated(value = EnumType.STRING)
var type: AudioContentBannerType,
@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
var lang: Lang = Lang.KO,
@Column(nullable = false)
var isAdult: Boolean = false,
@Column(nullable = false)
var isActive: Boolean = true,

View File

@@ -4,19 +4,20 @@ import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner
import kr.co.vividnext.sodalive.content.main.tab.QAudioContentMainTab.audioContentMainTab
import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.QMember.member
import org.springframework.data.jpa.repository.JpaRepository
interface AudioContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AudioContentBannerQueryRepository
interface AudioContentBannerQueryRepository {
fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean): List<AudioContentBanner>
fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang? = null): List<AudioContentBanner>
}
class AudioContentBannerQueryRepositoryImpl(
private val queryFactory: JPAQueryFactory
) : AudioContentBannerQueryRepository {
override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean): List<AudioContentBanner> {
override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang?): List<AudioContentBanner> {
var where = audioContentBanner.isActive.isTrue
where = if (tabId == 1L) {
@@ -29,6 +30,10 @@ class AudioContentBannerQueryRepositoryImpl(
where = where.and(audioContentBanner.isAdult.isFalse)
}
if (lang != null) {
where = where.and(audioContentBanner.lang.eq(lang))
}
return queryFactory
.selectFrom(audioContentBanner)
.leftJoin(audioContentBanner.tab, audioContentMainTab)

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.content.main.banner
import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
@@ -13,8 +14,8 @@ class AudioContentBannerService(
@Value("\${cloud.aws.cloud-front.host}")
private val imageHost: String
) {
fun getBannerList(tabId: Long, memberId: Long?, isAdult: Boolean): List<GetAudioContentBannerResponse> {
return repository.getAudioContentMainBannerList(tabId, isAdult)
fun getBannerList(tabId: Long, memberId: Long?, isAdult: Boolean, lang: Lang? = null): List<GetAudioContentBannerResponse> {
return repository.getAudioContentMainBannerList(tabId, isAdult, lang)
.filter {
if (it.type == AudioContentBannerType.CREATOR && it.creator != null && memberId != null) {
!isBlockedBetweenMembers(memberId = memberId, creatorId = it.creator!!.id!!)

View File

@@ -0,0 +1,78 @@
package kr.co.vividnext.sodalive.admin.content.banner
import com.amazonaws.services.s3.AmazonS3Client
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.AdminContentSeriesRepository
import kr.co.vividnext.sodalive.admin.content.tab.AdminContentMainTabRepository
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRepository
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.ArgumentCaptor
import org.mockito.Mockito
import org.springframework.mock.web.MockMultipartFile
import java.net.URL
class AdminContentBannerServiceTest {
private lateinit var amazonS3Client: AmazonS3Client
private lateinit var s3Uploader: S3Uploader
private lateinit var repository: AdminContentBannerRepository
private lateinit var memberRepository: MemberRepository
private lateinit var seriesRepository: AdminContentSeriesRepository
private lateinit var eventRepository: EventRepository
private lateinit var contentMainTabRepository: AdminContentMainTabRepository
private lateinit var service: AdminContentBannerService
@BeforeEach
fun setUp() {
amazonS3Client = Mockito.mock(AmazonS3Client::class.java)
s3Uploader = S3Uploader(amazonS3Client)
repository = Mockito.mock(AdminContentBannerRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
seriesRepository = Mockito.mock(AdminContentSeriesRepository::class.java)
eventRepository = Mockito.mock(EventRepository::class.java)
contentMainTabRepository = Mockito.mock(AdminContentMainTabRepository::class.java)
service = AdminContentBannerService(
s3Uploader = s3Uploader,
repository = repository,
memberRepository = memberRepository,
seriesRepository = seriesRepository,
eventRepository = eventRepository,
contentMainTabRepository = contentMainTabRepository,
objectMapper = jacksonObjectMapper(),
bucket = "test-bucket"
)
}
@Test
@DisplayName("배너 등록 요청의 lang 값을 저장한다")
fun shouldSaveRequestedLangWhenCreatingBanner() {
val image = MockMultipartFile("image", "banner.png", "image/png", "image".toByteArray())
val requestString = """
{"type":"LINK","lang":"ja","tabId":null,"eventId":null,"creatorId":null,"seriesId":null,"link":"https://example.com","isAdult":false}
""".trimIndent()
val savedBannerCaptor = ArgumentCaptor.forClass(AudioContentBanner::class.java)
Mockito.`when`(repository.save(Mockito.any(AudioContentBanner::class.java)))
.thenAnswer {
(it.arguments[0] as AudioContentBanner).also { banner ->
banner.id = 1L
}
}
Mockito.doAnswer { URL("https://cdn.test/${it.arguments[1]}") }
.`when`(amazonS3Client)
.getUrl(Mockito.eq("test-bucket"), Mockito.anyString())
service.createAudioContentMainBanner(image, requestString)
Mockito.verify(repository).save(savedBannerCaptor.capture())
assertEquals(AudioContentBannerType.LINK, savedBannerCaptor.value.type)
assertEquals(Lang.JA, savedBannerCaptor.value.lang)
}
}

View File

@@ -0,0 +1,314 @@
package kr.co.vividnext.sodalive.api.home
import kr.co.vividnext.sodalive.audition.AuditionService
import kr.co.vividnext.sodalive.chat.character.service.ChatCharacterService
import kr.co.vividnext.sodalive.content.AudioContentService
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.SortType
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerService
import kr.co.vividnext.sodalive.content.series.ContentSeriesService
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeService
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek
import kr.co.vividnext.sodalive.explorer.ExplorerQueryRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.live.room.LiveRoomService
import kr.co.vividnext.sodalive.live.room.LiveRoomStatus
import kr.co.vividnext.sodalive.query.recommend.RecommendChannelQueryService
import kr.co.vividnext.sodalive.rank.ContentRankingSortType
import kr.co.vividnext.sodalive.rank.RankingRepository
import kr.co.vividnext.sodalive.rank.RankingService
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.Pageable
import java.time.DayOfWeek
import java.time.LocalDateTime
import java.time.ZoneId
class HomeServiceTest {
private lateinit var liveRoomService: LiveRoomService
private lateinit var auditionService: AuditionService
private lateinit var seriesService: ContentSeriesService
private lateinit var contentService: AudioContentService
private lateinit var bannerService: AudioContentBannerService
private lateinit var contentThemeService: AudioContentThemeService
private lateinit var recommendChannelService: RecommendChannelQueryService
private lateinit var characterService: ChatCharacterService
private lateinit var rankingService: RankingService
private lateinit var rankingRepository: RankingRepository
private lateinit var explorerQueryRepository: ExplorerQueryRepository
private lateinit var service: HomeService
private val timezone = "Asia/Seoul"
@BeforeEach
fun setUp() {
liveRoomService = Mockito.mock(LiveRoomService::class.java)
auditionService = Mockito.mock(AuditionService::class.java)
seriesService = Mockito.mock(ContentSeriesService::class.java)
contentService = Mockito.mock(AudioContentService::class.java)
bannerService = Mockito.mock(AudioContentBannerService::class.java)
contentThemeService = Mockito.mock(AudioContentThemeService::class.java)
recommendChannelService = Mockito.mock(RecommendChannelQueryService::class.java)
characterService = Mockito.mock(ChatCharacterService::class.java)
rankingService = Mockito.mock(RankingService::class.java)
rankingRepository = Mockito.mock(RankingRepository::class.java)
explorerQueryRepository = Mockito.mock(ExplorerQueryRepository::class.java)
service = HomeService(
liveRoomService = liveRoomService,
auditionService = auditionService,
seriesService = seriesService,
contentService = contentService,
bannerService = bannerService,
contentThemeService = contentThemeService,
recommendChannelService = recommendChannelService,
characterService = characterService,
rankingService = rankingService,
rankingRepository = rankingRepository,
explorerQueryRepository = explorerQueryRepository,
langContext = LangContext().apply { setLang(Lang.JA) },
memberContentPreferenceService = Mockito.mock(
kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService::class.java
),
imageHost = "https://cdn.test"
)
val systemTime = LocalDateTime.now()
val zonedDateTime = systemTime
.atZone(ZoneId.systemDefault())
.withZoneSameInstant(ZoneId.of(timezone))
val expectedDayOfWeek = when (zonedDateTime.dayOfWeek) {
DayOfWeek.MONDAY -> SeriesPublishedDaysOfWeek.MON
DayOfWeek.TUESDAY -> SeriesPublishedDaysOfWeek.TUE
DayOfWeek.WEDNESDAY -> SeriesPublishedDaysOfWeek.WED
DayOfWeek.THURSDAY -> SeriesPublishedDaysOfWeek.THU
DayOfWeek.FRIDAY -> SeriesPublishedDaysOfWeek.FRI
DayOfWeek.SATURDAY -> SeriesPublishedDaysOfWeek.SAT
DayOfWeek.SUNDAY -> SeriesPublishedDaysOfWeek.SUN
null -> SeriesPublishedDaysOfWeek.RANDOM
}
val currentDateTime = LocalDateTime.now()
val rankingStartDate = currentDateTime
.withHour(15)
.withMinute(0)
.withSecond(0)
.minusWeeks(1)
.with(java.time.temporal.TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val rankingEndDate = rankingStartDate.plusDays(6)
Mockito.doReturn(emptyList<Any>()).`when`(liveRoomService)
.getRoomList(null, LiveRoomStatus.NOW, Pageable.ofSize(10), null, timezone)
Mockito.`when`(rankingRepository.getCreatorRankings(null)).thenReturn(emptyList())
Mockito.`when`(
contentThemeService.getActiveThemeOfContent(
false,
false,
false,
ContentType.ALL,
listOf("다시듣기")
)
).thenReturn(emptyList())
Mockito.`when`(
contentThemeService.getActiveThemeOfContent(
false,
true,
false,
ContentType.ALL,
emptyList()
)
).thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
0,
20,
SortType.NEWEST,
false,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
0,
50,
SortType.NEWEST,
true,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
50,
100,
SortType.NEWEST,
true,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
150,
150,
SortType.NEWEST,
true,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
0,
50,
SortType.NEWEST,
false,
false,
false,
true,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
50,
100,
SortType.NEWEST,
false,
false,
false,
true,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
150,
150,
SortType.NEWEST,
false,
false,
false,
true,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
0,
50,
SortType.NEWEST,
false,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
50,
100,
SortType.NEWEST,
false,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(
contentService.getLatestContentByTheme(
null,
emptyList(),
ContentType.ALL,
150,
150,
SortType.NEWEST,
false,
false,
false,
false,
emptyList()
)
)
.thenReturn(emptyList())
Mockito.`when`(bannerService.getBannerList(1L, null, false, Lang.JA)).thenReturn(emptyList())
Mockito.`when`(
seriesService.getOriginalAudioDramaList(null, false, ContentType.ALL, 0, 20)
).thenReturn(emptyList())
Mockito.`when`(auditionService.getInProgressAuditionList(false)).thenReturn(emptyList())
Mockito.`when`(
seriesService.getDayOfWeekSeriesList(null, false, ContentType.ALL, expectedDayOfWeek, 0, 10)
).thenReturn(emptyList())
Mockito.`when`(characterService.getPopularCharacters("ja", 20)).thenReturn(emptyList())
Mockito.`when`(
rankingService.getContentRanking(
null,
false,
ContentType.ALL,
rankingStartDate.minusDays(1),
rankingEndDate,
0,
12,
ContentRankingSortType.REVENUE,
""
)
)
.thenReturn(emptyList())
Mockito.`when`(recommendChannelService.getRecommendChannel(null, false, ContentType.ALL)).thenReturn(emptyList())
}
@Test
@DisplayName("홈 fetchData는 현재 요청 언어를 배너 조회에 전달한다")
fun shouldPassCurrentLangToBannerServiceWhenFetchingHome() {
service.fetchData(timezone = timezone, member = null)
Mockito.verify(bannerService).getBannerList(1L, null, false, Lang.JA)
}
}

View File

@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.content.main.banner
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.i18n.Lang
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
@DataJpaTest
@Import(QueryDslConfig::class)
class AudioContentBannerRepositoryTest @Autowired constructor(
private val repository: AudioContentBannerRepository
) {
@Test
@DisplayName("사용자 배너 조회는 요청 언어와 일치하는 배너만 반환한다")
fun shouldReturnOnlyRequestedLanguageBanners() {
repository.saveAndFlush(
AudioContentBanner(
thumbnailImage = "banner/ko.png",
type = AudioContentBannerType.LINK,
lang = Lang.KO
).apply {
link = "https://ko.example.com"
}
)
repository.saveAndFlush(
AudioContentBanner(
thumbnailImage = "banner/ja.png",
type = AudioContentBannerType.LINK,
lang = Lang.JA
).apply {
link = "https://ja.example.com"
}
)
val banners = repository.getAudioContentMainBannerList(tabId = 1L, isAdult = false, lang = Lang.JA)
assertEquals(1, banners.size)
assertEquals(Lang.JA, banners.first().lang)
assertEquals("https://ja.example.com", banners.first().link)
}
}