fix(recommendation): 추천 탭 배너 조회 조건을 보정한다

This commit is contained in:
2026-08-05 21:16:55 +09:00
parent 8538880d81
commit 0193b77700
9 changed files with 140 additions and 22 deletions

View File

@@ -53,7 +53,11 @@ class HomeRecommendationFacade(
memberId = member?.id, memberId = member?.id,
includeAdultLives = includeAdult includeAdultLives = includeAdult
).map { it.toItem() }, ).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()) }, .map { RecommendationBannerResponse.from(it.toBanner()) },
recentlyActiveCreators = queryService.findRecentlyActiveCreators( recentlyActiveCreators = queryService.findRecentlyActiveCreators(
HOME_ACTIVE_CREATOR_LIMIT, HOME_ACTIVE_CREATOR_LIMIT,

View File

@@ -63,7 +63,8 @@ class DefaultAudioRecommendationQueryRepository(
.leftJoin(series.member, seriesOwner) .leftJoin(series.member, seriesOwner)
.where( .where(
audioContentBanner.isActive.isTrue, audioContentBanner.isActive.isTrue,
audioContentBanner.tab.isNull, audioContentBanner.tab.id.eq(2L),
adultBannerCondition(canViewAdultContent),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
) )
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
@@ -544,6 +545,10 @@ class DefaultAudioRecommendationQueryRepository(
return if (canViewAdultContent) null else series.isAdult.isFalse 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? { private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse return if (canViewAdultContent) null else audioContent.isAdult.isFalse
} }

View File

@@ -87,7 +87,8 @@ class DefaultHomeRecommendationQueryRepository(
override fun findHomeBanners( override fun findHomeBanners(
limit: Int, limit: Int,
memberId: Long? memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> { ): List<HomeBannerRecommendationRecord> {
val bannerCreator = QMember("bannerCreator") val bannerCreator = QMember("bannerCreator")
val seriesOwner = QMember("seriesOwner") val seriesOwner = QMember("seriesOwner")
@@ -115,6 +116,7 @@ class DefaultHomeRecommendationQueryRepository(
.where( .where(
audioContentBanner.isActive.isTrue, audioContentBanner.isActive.isTrue,
audioContentBanner.tab.isNull, audioContentBanner.tab.isNull,
includeAdultBannerCondition(includeAdultBanners),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
) )
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
@@ -1265,6 +1267,10 @@ class DefaultHomeRecommendationQueryRepository(
return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse 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? { private fun includeAdultLiveCondition(includeAdultLives: Boolean): BooleanExpression? {
return if (includeAdultLives) null else liveRoom.isAdult.isFalse return if (includeAdultLives) null else liveRoom.isAdult.isFalse
} }

View File

@@ -43,9 +43,10 @@ class HomeRecommendationQueryService(
fun findHomeBanners( fun findHomeBanners(
limit: Int = DEFAULT_BANNER_LIMIT, limit: Int = DEFAULT_BANNER_LIMIT,
memberId: Long? = null memberId: Long? = null,
includeAdultBanners: Boolean = false
): List<HomeBannerRecommendationRecord> { ): List<HomeBannerRecommendationRecord> {
return queryPort.findHomeBanners(limit, memberId) return queryPort.findHomeBanners(limit, memberId, includeAdultBanners)
} }
fun findRecentlyActiveCreators( fun findRecentlyActiveCreators(

View File

@@ -13,7 +13,8 @@ interface HomeRecommendationQueryPort {
fun findHomeBanners( fun findHomeBanners(
limit: Int, limit: Int,
memberId: Long? = null memberId: Long? = null,
includeAdultBanners: Boolean = false
): List<HomeBannerRecommendationRecord> ): List<HomeBannerRecommendationRecord>
fun findRecentlyActiveCreators( fun findRecentlyActiveCreators(

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.home.application 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.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
@@ -44,6 +45,17 @@ class HomeRecommendationFacadeTest {
assertFalse(response.firstAudioContents[2].isPointAvailable) 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 { private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord( return HomeFirstAudioContentRecord(
contentId = price.toLong() + 1, contentId = price.toLong() + 1,
@@ -61,6 +73,7 @@ class HomeRecommendationFacadeTest {
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList() var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
var bannerIncludeAdultBanners: Boolean = false
override fun findLiveRecommendations( override fun findLiveRecommendations(
offset: Long, offset: Long,
@@ -69,7 +82,14 @@ class HomeRecommendationFacadeTest {
includeAdultLives: Boolean includeAdultLives: Boolean
): List<HomeLiveRecommendationRecord> = emptyList() ): List<HomeLiveRecommendationRecord> = emptyList()
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> = emptyList() override fun findHomeBanners(
limit: Int,
memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> {
bannerIncludeAdultBanners = includeAdultBanners
return emptyList()
}
override fun findRecentlyActiveCreators( override fun findRecentlyActiveCreators(
limit: Int, limit: Int,

View File

@@ -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.like.AudioContentLike
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner 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.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.theme.AudioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.Series 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.creator.admin.content.series.SeriesContent
@@ -41,25 +42,58 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test") private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
@Test @Test
@DisplayName("배너는 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다") @DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
fun shouldFindBannersWithHomeBannerPolicy() { fun shouldFindBannersForContentRecommendationTabWithAdultVisibility() {
val viewer = saveMember("viewer", MemberRole.USER) val viewer = saveMember("viewer", MemberRole.USER)
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR) val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator) val recommendationTab = saveContentRecommendationTab()
val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test") val visibleBanner = saveBanner(
saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test") "visible.png",
saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator) 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) saveBlock(viewer, blockedCreator)
flushAndClear() 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( assertEquals(
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"), listOf("https://cdn.test/${visibleBanner.thumbnailImage}"),
banners.map { it.imageUrl } 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 @Test
@@ -369,7 +403,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
isActive: Boolean = true, isActive: Boolean = true,
isAdult: Boolean = false, isAdult: Boolean = false,
creator: Member? = null, creator: Member? = null,
link: String? = null link: String? = null,
tab: AudioContentMainTab? = null
): AudioContentBanner { ): AudioContentBanner {
val banner = AudioContentBanner( val banner = AudioContentBanner(
thumbnailImage = thumbnailImage, thumbnailImage = thumbnailImage,
@@ -380,10 +415,18 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
) )
banner.creator = creator banner.creator = creator
banner.link = link banner.link = link
banner.tab = tab
entityManager.persist(banner) entityManager.persist(banner)
return 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( private fun saveSeries(
title: String, title: String,
creator: Member, creator: Member,

View File

@@ -262,6 +262,36 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage }) 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 @Test
@DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다") @DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다")
fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() { fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() {
@@ -2658,6 +2688,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
type: AudioContentBannerType, type: AudioContentBannerType,
orders: Int, orders: Int,
isActive: Boolean, isActive: Boolean,
isAdult: Boolean = false,
creator: Member? = null, creator: Member? = null,
event: Event? = null, event: Event? = null,
series: Series? = null, series: Series? = null,
@@ -2668,7 +2699,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
thumbnailImage = thumbnailImage, thumbnailImage = thumbnailImage,
type = type, type = type,
lang = Lang.KO, lang = Lang.KO,
isAdult = false, isAdult = isAdult,
isActive = isActive, isActive = isActive,
orders = orders orders = orders
) )

View File

@@ -86,12 +86,13 @@ class HomeRecommendationQueryServiceTest {
} }
@Test @Test
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다") @DisplayName("홈 배너 추천은 기본 20개와 성인 노출 여부를 조회 포트에 위임한다")
fun shouldFindHomeBannersWithDefaultLimit() { fun shouldFindHomeBannersWithDefaultLimit() {
val banners = service.findHomeBanners(memberId = 100L) val banners = service.findHomeBanners(memberId = 100L, includeAdultBanners = true)
assertEquals(20, port.bannerLimit) assertEquals(20, port.bannerLimit)
assertEquals(100L, port.bannerMemberId) assertEquals(100L, port.bannerMemberId)
assertEquals(true, port.bannerIncludeAdultBanners)
assertEquals(port.banners, banners) assertEquals(port.banners, banners)
} }
@@ -964,6 +965,7 @@ class HomeRecommendationQueryServiceTest {
var liveIncludeAdultLives: Boolean? = null var liveIncludeAdultLives: Boolean? = null
var bannerLimit: Int? = null var bannerLimit: Int? = null
var bannerMemberId: Long? = null var bannerMemberId: Long? = null
var bannerIncludeAdultBanners: Boolean? = null
var activeCreatorLimit: Int? = null var activeCreatorLimit: Int? = null
var activeCreatorMemberId: Long? = null var activeCreatorMemberId: Long? = null
var activeCreatorIncludeAdultActivities: Boolean? = null var activeCreatorIncludeAdultActivities: Boolean? = null
@@ -1063,9 +1065,14 @@ class HomeRecommendationQueryServiceTest {
return liveRecommendations return liveRecommendations
} }
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> { override fun findHomeBanners(
limit: Int,
memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> {
bannerLimit = limit bannerLimit = limit
bannerMemberId = memberId bannerMemberId = memberId
bannerIncludeAdultBanners = includeAdultBanners
return banners return banners
} }