From 1f24a4b703c941bdadc86ddd034e6518fb167925 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 31 Oct 2026 20:33:22 +0900 Subject: [PATCH] =?UTF-8?q?fix(home-recommendation):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EB=85=B8=EC=B6=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EB=B0=B0=EB=84=88=EB=A5=BC=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 29 ++- .../home/HomeRecommendationControllerTest.kt | 95 ++++++++ ...ltHomeRecommendationQueryRepositoryTest.kt | 220 +++++++++++++++++- 3 files changed, 338 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index c9c89b8e..e750362b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -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) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt index 8423b51b..54b581c5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -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() + 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( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 684f3ffa..738673c7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -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(), 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(), 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)