diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index e2935b9b..553bd63c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -53,7 +53,11 @@ class HomeRecommendationFacade( memberId = member?.id, includeAdultLives = includeAdult ).map { it.toItem() }, - banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id) + banners = queryService.findHomeBanners( + limit = HOME_BANNER_LIMIT, + memberId = member?.id, + includeAdultBanners = includeAdult + ) .map { RecommendationBannerResponse.from(it.toBanner()) }, recentlyActiveCreators = queryService.findRecentlyActiveCreators( HOME_ACTIVE_CREATOR_LIMIT, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 10fdd418..8aa33a36 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -63,7 +63,8 @@ class DefaultAudioRecommendationQueryRepository( .leftJoin(series.member, seriesOwner) .where( audioContentBanner.isActive.isTrue, - audioContentBanner.tab.isNull, + audioContentBanner.tab.id.eq(2L), + adultBannerCondition(canViewAdultContent), activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) ) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) @@ -544,6 +545,10 @@ class DefaultAudioRecommendationQueryRepository( return if (canViewAdultContent) null else series.isAdult.isFalse } + private fun adultBannerCondition(canViewAdultContent: Boolean): BooleanExpression? { + return if (canViewAdultContent) null else audioContentBanner.isAdult.isFalse + } + private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? { return if (canViewAdultContent) null else audioContent.isAdult.isFalse } 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 84ea3df4..edefca60 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 @@ -87,7 +87,8 @@ class DefaultHomeRecommendationQueryRepository( override fun findHomeBanners( limit: Int, - memberId: Long? + memberId: Long?, + includeAdultBanners: Boolean ): List { val bannerCreator = QMember("bannerCreator") val seriesOwner = QMember("seriesOwner") @@ -115,6 +116,7 @@ class DefaultHomeRecommendationQueryRepository( .where( audioContentBanner.isActive.isTrue, audioContentBanner.tab.isNull, + includeAdultBannerCondition(includeAdultBanners), activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) ) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) @@ -1265,6 +1267,10 @@ class DefaultHomeRecommendationQueryRepository( return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse } + private fun includeAdultBannerCondition(includeAdultBanners: Boolean): BooleanExpression? { + return if (includeAdultBanners) null else audioContentBanner.isAdult.isFalse + } + private fun includeAdultLiveCondition(includeAdultLives: Boolean): BooleanExpression? { return if (includeAdultLives) null else liveRoom.isAdult.isFalse } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt index 9c877a3d..af617b1e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt @@ -43,9 +43,10 @@ class HomeRecommendationQueryService( fun findHomeBanners( limit: Int = DEFAULT_BANNER_LIMIT, - memberId: Long? = null + memberId: Long? = null, + includeAdultBanners: Boolean = false ): List { - return queryPort.findHomeBanners(limit, memberId) + return queryPort.findHomeBanners(limit, memberId, includeAdultBanners) } fun findRecentlyActiveCreators( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt index 5a45bc0b..45be5f4c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt @@ -13,7 +13,8 @@ interface HomeRecommendationQueryPort { fun findHomeBanners( limit: Int, - memberId: Long? = null + memberId: Long? = null, + includeAdultBanners: Boolean = false ): List fun findRecentlyActiveCreators( diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index a6bbae1e..494fb245 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.home.application +import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType @@ -44,6 +45,17 @@ class HomeRecommendationFacadeTest { assertFalse(response.firstAudioContents[2].isPointAvailable) } + @Test + @DisplayName("홈 추천은 회원의 성인 콘텐츠 조회 가능 여부를 배너 조회에 전달한다") + fun shouldPassAdultVisibilityToHomeBannerQuery() { + val member = Member(password = "password", nickname = "member").apply { id = 10L } + Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) + + facade.getHomeRecommendations(member) + + assertTrue(queryPort.bannerIncludeAdultBanners) + } + private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord { return HomeFirstAudioContentRecord( contentId = price.toLong() + 1, @@ -61,6 +73,7 @@ class HomeRecommendationFacadeTest { private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { var firstAudioContents: List = emptyList() + var bannerIncludeAdultBanners: Boolean = false override fun findLiveRecommendations( offset: Long, @@ -69,7 +82,14 @@ class HomeRecommendationFacadeTest { includeAdultLives: Boolean ): List = emptyList() - override fun findHomeBanners(limit: Int, memberId: Long?): List = emptyList() + override fun findHomeBanners( + limit: Int, + memberId: Long?, + includeAdultBanners: Boolean + ): List { + bannerIncludeAdultBanners = includeAdultBanners + return emptyList() + } override fun findRecentlyActiveCreators( limit: Int, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index 58a12c13..81c60357 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.like.AudioContentLike 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.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent @@ -41,25 +42,58 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test") @Test - @DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다") - fun shouldFindBannersWithHomeBannerPolicy() { + @DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다") + fun shouldFindBannersForContentRecommendationTabWithAdultVisibility() { val viewer = saveMember("viewer", MemberRole.USER) val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR) val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) - val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator) - val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test") - saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test") - saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator) + val recommendationTab = saveContentRecommendationTab() + val visibleBanner = saveBanner( + "visible.png", + AudioContentBannerType.CREATOR, + 1, + creator = visibleCreator, + tab = recommendationTab + ) + val adultBanner = saveBanner( + "adult.png", + AudioContentBannerType.LINK, + 2, + isAdult = true, + link = "https://adult.test", + tab = recommendationTab + ) + saveBanner( + "inactive.png", + AudioContentBannerType.LINK, + 3, + isActive = false, + link = "https://inactive.test", + tab = recommendationTab + ) + saveBanner( + "blocked.png", + AudioContentBannerType.CREATOR, + 4, + creator = blockedCreator, + tab = recommendationTab + ) + saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test") saveBlock(viewer, blockedCreator) flushAndClear() - val banners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false) + val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false) + val allBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = true) assertEquals( - listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"), - banners.map { it.imageUrl } + listOf("https://cdn.test/${visibleBanner.thumbnailImage}"), + safeBanners.map { it.imageUrl } ) - assertEquals(visibleCreator.id, banners.first().creatorId) + assertEquals( + listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"), + allBanners.map { it.imageUrl } + ) + assertEquals(visibleCreator.id, safeBanners.first().creatorId) } @Test @@ -369,7 +403,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( isActive: Boolean = true, isAdult: Boolean = false, creator: Member? = null, - link: String? = null + link: String? = null, + tab: AudioContentMainTab? = null ): AudioContentBanner { val banner = AudioContentBanner( thumbnailImage = thumbnailImage, @@ -380,10 +415,18 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( ) banner.creator = creator banner.link = link + banner.tab = tab entityManager.persist(banner) return banner } + private fun saveContentRecommendationTab(): AudioContentMainTab { + entityManager.createNativeQuery( + "insert into audio_content_main_tab (id, title, is_active) values (2, '추천', true)" + ).executeUpdate() + return entityManager.getReference(AudioContentMainTab::class.java, 2L) + } + private fun saveSeries( title: String, creator: 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 fc49c9ca..4fa2e799 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 @@ -262,6 +262,36 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage }) } + @Test + @DisplayName("홈 배너는 성인 콘텐츠 조회가 불가능하면 비성인 배너만 반환한다") + fun shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible() { + val safeBanner = saveBanner( + "safe-home-banner.png", + AudioContentBannerType.LINK, + orders = 1, + isActive = true, + link = "https://safe-home-banner.test" + ) + val adultBanner = saveBanner( + "adult-home-banner.png", + AudioContentBannerType.LINK, + orders = 2, + isActive = true, + isAdult = true, + link = "https://adult-home-banner.test" + ) + flushAndClear() + + val safeBanners = repository.findHomeBanners(limit = 20) + val allBanners = repository.findHomeBanners(limit = 20, includeAdultBanners = true) + + assertEquals(listOf(safeBanner.thumbnailImage), safeBanners.map { it.thumbnailImage }) + assertEquals( + listOf(safeBanner.thumbnailImage, adultBanner.thumbnailImage), + allBanners.map { it.thumbnailImage } + ) + } + @Test @DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다") fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() { @@ -2658,6 +2688,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( type: AudioContentBannerType, orders: Int, isActive: Boolean, + isAdult: Boolean = false, creator: Member? = null, event: Event? = null, series: Series? = null, @@ -2668,7 +2699,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( thumbnailImage = thumbnailImage, type = type, lang = Lang.KO, - isAdult = false, + isAdult = isAdult, isActive = isActive, orders = orders ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt index 2da88b6e..1f4cac79 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt @@ -86,12 +86,13 @@ class HomeRecommendationQueryServiceTest { } @Test - @DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다") + @DisplayName("홈 배너 추천은 기본 20개와 성인 노출 여부를 조회 포트에 위임한다") fun shouldFindHomeBannersWithDefaultLimit() { - val banners = service.findHomeBanners(memberId = 100L) + val banners = service.findHomeBanners(memberId = 100L, includeAdultBanners = true) assertEquals(20, port.bannerLimit) assertEquals(100L, port.bannerMemberId) + assertEquals(true, port.bannerIncludeAdultBanners) assertEquals(port.banners, banners) } @@ -964,6 +965,7 @@ class HomeRecommendationQueryServiceTest { var liveIncludeAdultLives: Boolean? = null var bannerLimit: Int? = null var bannerMemberId: Long? = null + var bannerIncludeAdultBanners: Boolean? = null var activeCreatorLimit: Int? = null var activeCreatorMemberId: Long? = null var activeCreatorIncludeAdultActivities: Boolean? = null @@ -1063,9 +1065,14 @@ class HomeRecommendationQueryServiceTest { return liveRecommendations } - override fun findHomeBanners(limit: Int, memberId: Long?): List { + override fun findHomeBanners( + limit: Int, + memberId: Long?, + includeAdultBanners: Boolean + ): List { bannerLimit = limit bannerMemberId = memberId + bannerIncludeAdultBanners = includeAdultBanners return banners }