fix(audio-recommendation): 이벤트 상세 노출 조건으로 배너를 필터링한다

This commit is contained in:
2026-10-31 20:59:52 +09:00
parent 1f24a4b703
commit 969f999ae9
3 changed files with 401 additions and 5 deletions
@@ -18,8 +18,10 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri
import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.event.QEvent.event
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
@@ -45,6 +47,7 @@ class DefaultAudioRecommendationQueryRepository(
private val contentTranslationRepository: ContentTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String,
private val eventNowProvider: () -> LocalDateTime = { LocalDateTime.now() },
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) : AudioRecommendationQueryRepository {
override fun findBanners(
@@ -79,7 +82,7 @@ class DefaultAudioRecommendationQueryRepository(
audioContentBanner.lang.eq(lang),
audioContentBanner.isVisibleAt(nowProvider()),
adultBannerCondition(canViewAdultContent),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner, lang)
)
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
.limit(limit.toLong())
@@ -560,7 +563,19 @@ class DefaultAudioRecommendationQueryRepository(
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
}
private fun activeBannerTargetCondition(memberId: Long?, bannerCreator: QMember, seriesOwner: QMember): BooleanExpression {
private fun activeBannerTargetCondition(
memberId: Long?,
bannerCreator: QMember,
seriesOwner: QMember,
lang: Lang
): BooleanExpression {
val eventNow = eventNowProvider()
val eventCondition = audioContentBanner.type.eq(AudioContentBannerType.EVENT)
.and(event.isActive.isTrue)
.and(event.startDate.loe(eventNow))
.and(event.endDate.goe(eventNow))
.and(event.lang.eq(lang))
.and(eventAudienceCondition(memberId))
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
.and(bannerCreator.isActive.isTrue)
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
@@ -570,11 +585,24 @@ class DefaultAudioRecommendationQueryRepository(
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
.or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue))
.or(eventCondition)
.or(creatorCondition)
.or(seriesCondition)
}
private fun eventAudienceCondition(memberId: Long?): BooleanExpression {
if (memberId == null) return event.isAdult.isNull.or(event.isAdult.isFalse)
val eventViewer = QMember("eventViewer")
val isAdmin = JPAExpressions.selectOne().from(eventViewer)
.where(eventViewer.id.eq(memberId), eventViewer.role.eq(MemberRole.ADMIN))
.exists()
val viewerAuth = JPAExpressions.selectOne().from(auth).where(auth.member.id.eq(memberId))
return event.isAdult.isNull
.or(isAdmin)
.or(event.isAdult.isTrue.and(viewerAuth.exists()))
.or(event.isAdult.isFalse.and(viewerAuth.notExists()))
}
private fun Tuple.toEventItem(): EventItem? {
val eventId = get(event.id) ?: return null
val thumbnailImage = get(event.thumbnailImage) ?: return null
@@ -3,21 +3,32 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -41,6 +52,95 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@ParameterizedTest
@CsvSource(
"ANONYMOUS, KR, ja, KO, all|false",
"ANONYMOUS, JP, ko, JA, all|false",
"UNVERIFIED, KR, ja, KO, all|false",
"UNVERIFIED, JP, ko, JA, all|false",
"VERIFIED, KR, ja, KO, all|true",
"VERIFIED, JP, ko, JA, all|true",
"ADMIN, KR, ja, KO, all|false|true",
"ADMIN, JP, ko, JA, all|false|true"
)
@DisplayName("오디오 EVENT 응답은 요청 언어가 아닌 국가와 인증 대상 정책을 따르고 AI 필드를 추가하지 않는다")
fun shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema(
viewerType: String,
country: String,
requestLanguage: String,
eventLang: Lang,
expected: String
) {
val fixture = transactionTemplate.execute {
entityManager.createQuery("delete from AudioContentBanner").executeUpdate()
if (entityManager.find(AudioContentMainTab::class.java, 2L) == null) {
entityManager.createNativeQuery(
"insert into audio_content_main_tab (id, title, is_active) values (2, '추천', true)"
).executeUpdate()
}
val tab = entityManager.getReference(AudioContentMainTab::class.java, 2L)
val viewer = if (viewerType == "ANONYMOUS") {
null
} else {
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
var candidate = saveMember("audio-event-viewer", role)
while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) {
candidate = saveMember("audio-event-viewer-${candidate.id}", role)
}
candidate
}
if (viewerType == "VERIFIED") {
entityManager.persist(
Auth("verified", "19900101", "audio-event-ci-${viewer?.id}", "audio-event-di-${viewer?.id}", 1).apply {
member = viewer
}
)
}
viewer?.let {
entityManager.persist(
MemberContentPreference(isAdultContentVisible = viewerType == "UNVERIFIED").apply {
member = it
}
)
}
val events = mutableMapOf<String, Event>()
listOf(Lang.KO, Lang.JA).forEach { lang ->
listOf(null, false, true).forEachIndexed { index, audience ->
val name = audience?.toString() ?: "all"
events["$lang-$name"] = saveAudioEventBanner(name, lang, audience, index, tab)
}
}
saveAudioEventBanner("inactive", eventLang, null, 3, tab).isActive = false
saveAudioEventBanner("future", eventLang, null, 4, tab).startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
saveAudioEventBanner("expired", eventLang, null, 5, tab).endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
saveAudioEventBanner("wrong-event-language", eventLang, null, 6, tab).lang =
if (eventLang == Lang.KO) Lang.JA else Lang.KO
entityManager.persist(
AudioContentBanner("missing-event", AudioContentBannerType.EVENT, lang = eventLang, orders = 7).apply {
this.tab = tab
}
)
entityManager.flush()
entityManager.clear()
viewer to events
}!!
val request = get("/api/v2/audio/recommendations")
.header("CloudFront-Viewer-Country", country)
.header("Accept-Language", requestLanguage)
fixture.first?.let { request.with(user(MemberAdapter(it))) }
val response = mockMvc.perform(request)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.aiCharacters").doesNotHaveJsonPath())
.andExpect(jsonPath("$.data.banners.length()").value(expected.split("|").size))
expected.split("|").forEachIndexed { index, name ->
response.andExpect(
jsonPath("$.data.banners[$index].eventItem.id").value(fixture.second.getValue("$eventLang-$name").id)
)
}
}
@Test
@DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다")
fun shouldReturnRecommendationsThroughControllerServiceAndRepository() {
@@ -114,6 +214,34 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
)
}
private fun saveAudioEventBanner(
name: String,
lang: Lang,
audience: Boolean?,
order: Int,
tab: AudioContentMainTab
): Event {
val event = Event(
thumbnailImage = "$lang-$name.png",
detailImage = null,
popupImage = null,
link = null,
title = name,
lang = lang,
isAdult = audience,
startDate = LocalDateTime.of(2000, 1, 1, 0, 0),
endDate = LocalDateTime.of(2100, 1, 1, 0, 0)
)
entityManager.persist(event)
entityManager.persist(
AudioContentBanner("$lang-$name", AudioContentBannerType.EVENT, lang = lang, orders = order).apply {
this.event = event
this.tab = tab
}
)
return event
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1)
@@ -17,9 +17,11 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicy
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
@@ -29,6 +31,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.MethodSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
@@ -49,13 +52,218 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
contentTranslationRepository: ContentTranslationRepository
) {
private var bannerNow = AudioContentBannerReservationFixtures.now
private var eventNow = LocalDateTime.of(2026, 10, 31, 23, 59, 59, 123456789)
private val repository = DefaultAudioRecommendationQueryRepository(
queryFactory,
entityManager,
contentTranslationRepository,
"https://cdn.test"
"https://cdn.test",
eventNowProvider = { eventNow }
) { bannerNow }
@ParameterizedTest
@CsvSource(
"ANONYMOUS, KO, false, all|false",
"ANONYMOUS, JA, false, all|false",
"UNVERIFIED, KO, true, all|false",
"UNVERIFIED, JA, true, all|false",
"VERIFIED, KO, false, all|true",
"VERIFIED, JA, false, all|true",
"ADMIN, KO, false, all|false|true",
"ADMIN, JA, false, all|false|true"
)
@DisplayName("오디오 EVENT 대상자는 배너 성인 선호와 무관하게 조회자의 인증 기록으로 판정한다")
fun shouldApplyAudioEventVisibilityPolicyBeforeLimit(
viewerType: String,
lang: Lang,
canViewAdultContent: Boolean,
expected: String
) {
val viewer = saveEventViewer(viewerType)
saveEventViewer("VERIFIED", "other-verified")
val tab = saveContentRecommendationTab()
listOf(null, false, true).forEachIndexed { index, audience ->
val name = audience?.toString() ?: "all"
val event = saveEvent(name).apply {
this.lang = lang
isAdult = audience
}
saveBanner(name, AudioContentBannerType.EVENT, index, tab = tab, lang = lang, event = event)
}
flushAndClear()
assertEquals(
expected.split("|").map { "https://cdn.test/$it" },
repository.findBanners(20, viewer?.id, canViewAdultContent, lang).map { it.imageUrl }
)
}
@ParameterizedTest
@CsvSource(
"ANONYMOUS, MISSING",
"ANONYMOUS, INACTIVE",
"ANONYMOUS, FUTURE",
"ANONYMOUS, EXPIRED",
"ANONYMOUS, WRONG_LANGUAGE",
"ADMIN, MISSING",
"ADMIN, INACTIVE",
"ADMIN, FUTURE",
"ADMIN, EXPIRED",
"ADMIN, WRONG_LANGUAGE"
)
@DisplayName("오디오 EVENT는 관리자도 누락·비활성·기간 밖·다른 언어의 이벤트를 볼 수 없다")
fun shouldExcludeIneligibleAudioEventsForAnonymousAndAdmin(viewerType: String, hiddenReason: String) {
val viewer = saveEventViewer(viewerType)
val tab = saveContentRecommendationTab()
val event = if (hiddenReason == "MISSING") {
null
} else {
saveEvent("hidden").apply {
when (hiddenReason) {
"INACTIVE" -> isActive = false
"FUTURE" -> startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
"EXPIRED" -> endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
"WRONG_LANGUAGE" -> lang = Lang.JA
}
}
}
saveBanner("hidden", AudioContentBannerType.EVENT, 0, tab = tab, event = event)
flushAndClear()
assertEquals(emptyList<String>(), repository.findBanners(20, viewer?.id, false).map { it.imageUrl })
}
@Test
@DisplayName("오디오 EVENT는 부적격 첫 후보를 limit 전에 제외하고 적격 두 번째 후보를 반환한다")
fun shouldExcludeHiddenAudioEventBeforeLimit() {
val tab = saveContentRecommendationTab()
val hiddenEvent = saveEvent("hidden-first").apply { isAdult = true }
val visibleEvent = saveEvent("visible-second").apply { isAdult = false }
saveBanner("hidden-first", AudioContentBannerType.EVENT, 0, tab = tab, event = hiddenEvent)
saveBanner("visible-second", AudioContentBannerType.EVENT, 1, tab = tab, event = visibleEvent)
flushAndClear()
assertEquals(listOf("https://cdn.test/visible-second"), repository.findBanners(1, null, false).map { it.imageUrl })
}
@Test
@DisplayName("오디오 EVENT 후보가 모두 인증 대상에서 제외되면 빈 목록을 반환한다")
fun shouldReturnEmptyWhenAllAudioEventAudiencesAreHidden() {
val tab = saveContentRecommendationTab()
repeat(2) { index ->
val event = saveEvent("hidden-$index").apply { isAdult = true }
saveBanner("hidden-$index", AudioContentBannerType.EVENT, index, tab = tab, event = event)
}
flushAndClear()
assertEquals(emptyList<String>(), repository.findBanners(20, null, false).map { it.imageUrl })
}
@Test
@DisplayName("관리자의 EVENT 대상자 우회는 오디오 배너 자체 활성·탭·언어·예약·성인 조건을 우회하지 않는다")
fun shouldKeepBannerOwnFiltersForAdminAudioEvents() {
val admin = saveEventViewer("ADMIN")
val tab = saveContentRecommendationTab()
val event = saveEvent("admin-event").apply { isAdult = true }
listOf("inactive", "tab", "language", "future", "expired", "adult", "visible").forEachIndexed { index, name ->
saveBanner(name, AudioContentBannerType.EVENT, index, tab = tab, event = event).apply {
when (name) {
"inactive" -> isActive = false
"tab" -> this.tab = null
"language" -> lang = Lang.JA
"future" -> startDate = bannerNow.plusYears(1).withNano(0)
"expired" -> endDate = bannerNow.minusYears(1).withNano(0)
"adult" -> isAdult = true
}
}
}
flushAndClear()
assertEquals(listOf("https://cdn.test/visible"), repository.findBanners(20, admin?.id, false).map { it.imageUrl })
assertEquals(
listOf("https://cdn.test/adult", "https://cdn.test/visible"),
repository.findBanners(20, admin?.id, true).map { it.imageUrl }
)
}
@Test
@DisplayName("EVENT 상세 정책은 오디오 LINK·CREATOR·SERIES 배너에 적용하지 않는다")
fun shouldNotApplyEventPolicyToNonEventAudioBanners() {
val tab = saveContentRecommendationTab()
val creator = saveMember("non-event-creator", MemberRole.CREATOR)
val series = saveSeries("non-event-series", creator, true)
val event = saveEvent("non-event-hidden").apply {
isActive = false
isAdult = true
lang = Lang.JA
endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
}
listOf(AudioContentBannerType.LINK, AudioContentBannerType.CREATOR, AudioContentBannerType.SERIES)
.forEachIndexed { index, type ->
saveBanner(type.name, type, index, creator = creator, tab = tab, event = event, series = series)
}
flushAndClear()
assertEquals(
listOf("LINK", "CREATOR", "SERIES").map { "https://cdn.test/$it" },
repository.findBanners(20, null, false).map { it.imageUrl }
)
}
@ParameterizedTest
@CsvSource(
"0, START, true",
"123456789, START, true",
"999999999, START, true",
"0, END, true",
"123456789, END, true",
"999999999, END, true",
"0, BOTH, true",
"123456789, BOTH, true",
"999999999, BOTH, true",
"0, BEFORE, false",
"123456789, BEFORE, false",
"999999999, BEFORE, false",
"0, AFTER, false",
"123456789, AFTER, false",
"999999999, AFTER, false"
)
@DisplayName("오디오 EVENT는 나노초 시작·종료를 포함하고 1ns 밖을 제외하며 배너 UTC 초 clock과 독립이다")
fun shouldApplyExactAudioEventBoundariesIndependentlyOfBannerClock(nano: Int, boundary: String, visible: Boolean) {
eventNow = eventNow.withNano(nano)
bannerNow = bannerNow.withNano(999999999)
entityManager.createNativeQuery("alter table event alter column start_date timestamp(9)").executeUpdate()
entityManager.createNativeQuery("alter table event alter column end_date timestamp(9)").executeUpdate()
val tab = saveContentRecommendationTab()
val start = when (boundary) {
"START", "BOTH" -> eventNow
"BEFORE" -> eventNow.plusNanos(1)
else -> eventNow.minusDays(1)
}
val end = when (boundary) {
"END", "BOTH" -> eventNow
"AFTER" -> eventNow.minusNanos(1)
else -> eventNow.plusDays(1)
}
val event = saveEvent("boundary").apply {
startDate = start
endDate = end
}
saveBanner("boundary", AudioContentBannerType.EVENT, 0, tab = tab, event = event).apply {
startDate = bannerNow.withNano(0)
endDate = bannerNow.withNano(0)
}
flushAndClear()
val stored = entityManager.find(Event::class.java, event.id)
assertEquals(start, stored.startDate)
assertEquals(end, stored.endDate)
assertEquals(
if (visible) listOf("https://cdn.test/boundary") else emptyList(),
repository.findBanners(1, null, false).map { it.imageUrl }
)
}
@Test
@DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다")
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
@@ -594,6 +802,34 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
)
}
private fun saveEventViewer(viewerType: String, seed: String = "event-viewer"): Member? {
if (viewerType == "ANONYMOUS") return null
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
val viewer = saveMember(seed, role)
if (viewerType == "VERIFIED") {
entityManager.persist(
Auth("verified", "19900101", "event-ci-${viewer.id}", "event-di-${viewer.id}", 1).apply {
member = viewer
}
)
}
return viewer
}
private fun saveEvent(title: String): Event {
val event = Event(
thumbnailImage = "$title.png",
detailImage = null,
popupImage = null,
link = null,
title = title,
startDate = eventNow.minusDays(1),
endDate = eventNow.plusDays(1)
)
entityManager.persist(event)
return event
}
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
val member = Member(
email = "$nickname@test.com",
@@ -621,7 +857,9 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
creator: Member? = null,
link: String? = null,
tab: AudioContentMainTab? = null,
lang: Lang = Lang.KO
lang: Lang = Lang.KO,
event: Event? = null,
series: Series? = null
): AudioContentBanner {
val banner = AudioContentBanner(
thumbnailImage = thumbnailImage,
@@ -634,6 +872,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
banner.creator = creator
banner.link = link
banner.tab = tab
banner.event = event
banner.series = series
entityManager.persist(banner)
return banner
}