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

This commit is contained in:
2026-10-31 20:33:22 +09:00
parent 4a6b2ee9e5
commit 1f24a4b703
3 changed files with 338 additions and 6 deletions
@@ -30,6 +30,7 @@ import kr.co.vividnext.sodalive.member.MemberKind
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.member.following.QCreatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
@@ -58,6 +59,7 @@ class DefaultHomeRecommendationQueryRepository(
private val queryFactory: JPAQueryFactory,
private val entityManager: EntityManager,
private val contentTranslationRepository: ContentTranslationRepository,
private val eventNowProvider: () -> LocalDateTime = { LocalDateTime.now() },
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) : HomeRecommendationQueryRepository {
override fun findLiveRecommendations(
@@ -137,7 +139,7 @@ class DefaultHomeRecommendationQueryRepository(
audioContentBanner.lang.eq(lang),
audioContentBanner.isVisibleAt(nowProvider()),
includeAdultBannerCondition(includeAdultBanners),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner, lang)
)
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
.limit(limit.toLong())
@@ -1298,8 +1300,16 @@ class DefaultHomeRecommendationQueryRepository(
private fun activeBannerTargetCondition(
memberId: Long?,
bannerCreator: QMember,
seriesOwner: 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))
@@ -1309,11 +1319,24 @@ class DefaultHomeRecommendationQueryRepository(
.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 BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression {
return if (condition == null) this else and(condition)
}
@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.v2.api.home
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.live.room.LiveRoom
@@ -11,6 +14,7 @@ import kr.co.vividnext.sodalive.member.MemberKind
import kr.co.vividnext.sodalive.member.MemberRepository
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.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
@@ -393,6 +397,76 @@ class HomeRecommendationControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.aiCharacters").isEmpty)
}
@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 shouldApplyEventBannerDetailPolicyForHomeAggregate(
viewerType: String,
country: String,
requestLanguage: String,
eventLang: Lang,
expected: String
) {
val viewer = if (viewerType == "ANONYMOUS") {
null
} else {
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
var candidate = saveMember("home-event-viewer", role)
while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) {
candidate = saveMember("home-event-viewer-${candidate.id}", role)
}
candidate
}
if (viewerType == "VERIFIED") {
entityManager.persist(Auth("verified", "19900101", "home-event-ci", "home-event-di", 1).apply { member = viewer })
}
viewer?.let {
entityManager.persist(
MemberContentPreference(isAdultContentVisible = viewerType == "UNVERIFIED").apply {
member = it
}
)
}
val events = mutableMapOf<String, Event>()
Lang.values().forEach { lang ->
listOf(null, false, true).forEachIndexed { index, audience ->
val name = audience?.toString() ?: "all"
events["$lang-$name"] = saveHomeEventBanner(name, lang, audience, index)
}
}
saveHomeEventBanner("inactive", eventLang, null, 3).isActive = false
saveHomeEventBanner("future", eventLang, null, 4).startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
saveHomeEventBanner("expired", eventLang, null, 5).endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
saveHomeEventBanner("wrong-event-language", eventLang, null, 6).lang =
if (eventLang == Lang.KO) Lang.JA else Lang.KO
entityManager.persist(AudioContentBanner("missing-event", AudioContentBannerType.EVENT, lang = eventLang, orders = 7))
entityManager.flush()
entityManager.clear()
val request = get("/api/v2/home/recommendations")
.header("CloudFront-Viewer-Country", country)
.header("Accept-Language", requestLanguage)
viewer?.let { request.with(user(MemberAdapter(it))) }
val response = mockMvc.perform(request)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.aiCharacters").isArray)
.andExpect(jsonPath("$.data.aiCharacters").isEmpty)
.andExpect(jsonPath("$.data.banners.length()").value(expected.split("|").size))
expected.split("|").forEachIndexed { index, name ->
response.andExpect(jsonPath("$.data.banners[$index].eventItem.id").value(events.getValue("$eventLang-$name").id))
}
}
@Test
@DisplayName("메인 홈 통합 조회 실패는 응답 시간과 함께 로그로 관측된다")
fun shouldLogHomeRecommendationFailure(output: CapturedOutput) {
@@ -723,6 +797,27 @@ class HomeRecommendationControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.recentlyActiveCreators[1].targetId").doesNotExist())
}
private fun saveHomeEventBanner(name: String, lang: Lang, audience: Boolean?, order: Int): 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
}
)
return event
}
private fun saveMember(seed: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
@@ -40,6 +40,7 @@ import kr.co.vividnext.sodalive.member.Gender
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.auth.Auth
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
@@ -52,6 +53,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.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyString
@@ -76,11 +78,209 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
contentTranslationRepository: ContentTranslationRepository
) {
private var bannerNow = AudioContentBannerReservationFixtures.now
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager, contentTranslationRepository) {
private var eventNow = LocalDateTime.of(2026, 10, 31, 23, 59, 59, 123456789)
private val repository = DefaultHomeRecommendationQueryRepository(
queryFactory,
entityManager,
contentTranslationRepository,
eventNowProvider = { eventNow }
) {
bannerNow
}
private val scorePolicy = RecommendationScorePolicy()
@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 shouldApplyHomeEventVisibilityPolicyBeforeLimit(
viewerType: String,
lang: Lang,
includeAdultBanners: Boolean,
expected: String
) {
val viewer = saveEventViewer(viewerType)
saveEventViewer("VERIFIED", "other-verified")
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, true, event = event, lang = lang)
}
flushAndClear()
assertEquals(
expected.split("|"),
repository.findHomeBanners(20, viewer?.id, includeAdultBanners, lang).map { it.thumbnailImage }
)
}
@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 shouldExcludeIneligibleHomeEventsForAnonymousAndAdmin(viewerType: String, hiddenReason: String) {
val viewer = saveEventViewer(viewerType)
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, true, event = event)
flushAndClear()
assertEquals(emptyList<String>(), repository.findHomeBanners(20, memberId = viewer?.id).map { it.thumbnailImage })
}
@Test
@DisplayName("홈 EVENT는 부적격 첫 후보를 limit 전에 제외하고 적격 두 번째 후보를 반환한다")
fun shouldExcludeHiddenHomeEventBeforeLimit() {
val hiddenEvent = saveEvent("hidden-first").apply { isAdult = true }
val visibleEvent = saveEvent("visible-second").apply { isAdult = false }
saveBanner("hidden-first", AudioContentBannerType.EVENT, 0, true, event = hiddenEvent)
saveBanner("visible-second", AudioContentBannerType.EVENT, 1, true, event = visibleEvent)
flushAndClear()
assertEquals(listOf("visible-second"), repository.findHomeBanners(1).map { it.thumbnailImage })
}
@Test
@DisplayName("홈 EVENT 후보가 모두 인증 대상에서 제외되면 빈 목록을 반환한다")
fun shouldReturnEmptyWhenAllHomeEventAudiencesAreHidden() {
repeat(2) { index ->
val event = saveEvent("hidden-$index").apply { isAdult = true }
saveBanner("hidden-$index", AudioContentBannerType.EVENT, index, true, event = event)
}
flushAndClear()
assertEquals(emptyList<String>(), repository.findHomeBanners(20).map { it.thumbnailImage })
}
@Test
@DisplayName("관리자의 EVENT 대상자 우회는 배너 자체 활성·탭·언어·예약·성인 조건을 우회하지 않는다")
fun shouldKeepBannerOwnFiltersForAdminHomeEvents() {
val admin = saveEventViewer("ADMIN")
val event = saveEvent("admin-event").apply { isAdult = true }
listOf("inactive", "tab", "language", "future", "expired", "adult", "visible").forEachIndexed { index, name ->
saveBanner(name, AudioContentBannerType.EVENT, index, true, event = event).apply {
when (name) {
"inactive" -> isActive = false
"tab" -> tab = saveMainTab("event-tab")
"language" -> lang = Lang.JA
"future" -> startDate = bannerNow.plusYears(1).withNano(0)
"expired" -> endDate = bannerNow.minusYears(1).withNano(0)
"adult" -> isAdult = true
}
}
}
flushAndClear()
assertEquals(listOf("visible"), repository.findHomeBanners(20, admin?.id).map { it.thumbnailImage })
assertEquals(
listOf("adult", "visible"),
repository.findHomeBanners(20, admin?.id, includeAdultBanners = true).map { it.thumbnailImage }
)
}
@Test
@DisplayName("EVENT 상세 정책은 LINK·CREATOR·SERIES 배너에 적용하지 않는다")
fun shouldNotApplyEventPolicyToNonEventHomeBanners() {
val creator = saveMember("non-event-creator", MemberRole.CREATOR)
val series = saveSeries("non-event-series", creator, true)
val event = saveEvent("non-event-hidden", isActive = false).apply {
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, true, creator = creator, series = series, event = event)
}
flushAndClear()
assertEquals(listOf("LINK", "CREATOR", "SERIES"), repository.findHomeBanners(20).map { it.thumbnailImage })
}
@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 shouldApplyExactHomeEventBoundariesIndependentlyOfBannerClock(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 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, true, 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("boundary") else emptyList(),
repository.findHomeBanners(1).map { it.thumbnailImage }
)
}
@Test
@DisplayName("홈 배너는 limit 전에 미래 및 만료 예약을 제외한다")
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
@@ -2825,6 +3025,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
return member
}
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 saveCharacter(name: String, isActive: Boolean, originalWork: OriginalWork? = null): ChatCharacter {
val creatorMember = Member(
email = null,
@@ -3039,8 +3253,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
popupImage = null,
link = "https://$title.test",
title = title,
startDate = LocalDateTime.of(2026, 5, 1, 0, 0),
endDate = LocalDateTime.of(2026, 6, 1, 0, 0),
startDate = eventNow.minusDays(1),
endDate = eventNow.plusDays(1),
isActive = isActive
)
entityManager.persist(event)