diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt index 36ac22d5..aabf0170 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence +import com.querydsl.core.types.Expression import com.querydsl.core.types.Projections import com.querydsl.core.types.dsl.BooleanExpression import com.querydsl.core.types.dsl.Expressions +import com.querydsl.jpa.JPAExpressions import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.chat.character.QChatCharacter.chatCharacter import kr.co.vividnext.sodalive.chat.original.QOriginalWork @@ -19,6 +21,7 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorC import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember.member +import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScoreSpec import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType @@ -46,6 +49,7 @@ class DefaultHomeRecommendationQueryRepository( override fun findLiveRecommendations( offset: Int, limit: Int, + memberId: Long?, includeAdultLives: Boolean ): List { return queryFactory @@ -69,6 +73,7 @@ class DefaultHomeRecommendationQueryRepository( liveRoom.channelName.isNotNull, liveRoom.channelName.isNotEmpty, includeAdultLiveCondition(includeAdultLives), + notBlockedCreatorCondition(memberId, member.id), member.isActive.isTrue ) .orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc()) @@ -77,7 +82,10 @@ class DefaultHomeRecommendationQueryRepository( .fetch() } - override fun findHomeBanners(limit: Int): List { + override fun findHomeBanners( + limit: Int, + memberId: Long? + ): List { val bannerCreator = QMember("bannerCreator") val seriesOwner = QMember("seriesOwner") val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") @@ -105,7 +113,7 @@ class DefaultHomeRecommendationQueryRepository( .where( audioContentBanner.isActive.isTrue, audioContentBanner.tab.isNull, - activeBannerTargetCondition(bannerCreator, seriesOwner) + activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) ) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) .limit(limit.toLong()) @@ -114,6 +122,7 @@ class DefaultHomeRecommendationQueryRepository( override fun findRecentlyActiveCreators( limit: Int, + memberId: Long?, includeAdultActivities: Boolean ): List { val sql = """ @@ -175,12 +184,14 @@ class DefaultHomeRecommendationQueryRepository( ) activities ) ranked where ranked.creator_rank = 1 + and ${notBlockedCreatorSql("ranked.creator_id")} order by ranked.activity_at desc, ranked.target_sort_id desc limit :limit """.trimIndent() val query = entityManager.createNativeQuery(sql) .setParameter("liveReplayTheme", LIVE_REPLAY_THEME) + .setParameter("memberId", memberId) .setParameter("includeAdultActivities", includeAdultActivities) .setParameter("limit", limit) @@ -203,6 +214,7 @@ class DefaultHomeRecommendationQueryRepository( now: LocalDateTime, offset: Int, limit: Int, + memberId: Long?, includeAdultContents: Boolean ): List { val sql = """ @@ -321,6 +333,7 @@ class DefaultHomeRecommendationQueryRepository( where m.is_active = true and cd.debut_at >= :boost30Start and cd.debut_at <= :now + and ${notBlockedCreatorSql("m.id")} order by score desc, random_tie_breaker asc limit :limit offset :offset @@ -329,6 +342,7 @@ class DefaultHomeRecommendationQueryRepository( val query = entityManager.createNativeQuery(sql) .setRecommendationQueryParameters(now, limit) .setParameter("offset", offset) + .setParameter("memberId", memberId) .setParameter("includeAdultContents", includeAdultContents) @Suppress("UNCHECKED_CAST") @@ -350,6 +364,7 @@ class DefaultHomeRecommendationQueryRepository( now: LocalDateTime, offset: Int, limit: Int, + memberId: Long?, includeAdultContents: Boolean ): List { val sql = """ @@ -423,6 +438,7 @@ class DefaultHomeRecommendationQueryRepository( and cd.debut_at >= :boost30Start and cd.debut_at <= :now and ec.release_date >= :boost30Start + and ${notBlockedCreatorSql("m.id")} order by recency_score desc, random_tie_breaker asc limit :limit offset :offset @@ -432,6 +448,7 @@ class DefaultHomeRecommendationQueryRepository( .setParameter("now", now) .setParameter("limit", limit) .setParameter("offset", offset) + .setParameter("memberId", memberId) .setParameter("includeAdultContents", includeAdultContents) .setParameter( "boost30Start", @@ -712,7 +729,8 @@ class DefaultHomeRecommendationQueryRepository( } override fun findCheerCreatorRecommendationDetails( - creatorIds: List + creatorIds: List, + memberId: Long? ): List { if (creatorIds.isEmpty()) return emptyList() @@ -726,12 +744,13 @@ class DefaultHomeRecommendationQueryRepository( ) ) .from(member) - .where(member.isActive.isTrue, member.id.`in`(creatorIds)) + .where(member.isActive.isTrue, member.id.`in`(creatorIds), notBlockedCreatorCondition(memberId, member.id)) .fetch() } override fun findPopularCommunityRecommendationDetails( communityIds: List, + memberId: Long?, includeAdultCommunities: Boolean ): List { if (communityIds.isEmpty()) return emptyList() @@ -766,6 +785,7 @@ class DefaultHomeRecommendationQueryRepository( creatorCommunity.price.eq(0), creatorCommunity.isFixed.isFalse, includeAdultCommunityCondition(includeAdultCommunities), + notBlockedCreatorCondition(memberId, member.id), creatorCommunity.id.`in`(communityIds) ) .groupBy( @@ -846,7 +866,17 @@ class DefaultHomeRecommendationQueryRepository( and cf.creator_id = m.id and cf.is_active = true ) - ) + and not exists ( + select 1 + from block_member bm + where :memberId is not null + and bm.is_active = true + and ( + (bm.member_id = :memberId and bm.blocked_member_id = m.id) + or (bm.member_id = m.id and bm.blocked_member_id = :memberId) + ) + ) + ) ) selected order by selected.source_rank asc, selected.random_tie_breaker asc limit :targetLimit @@ -897,6 +927,16 @@ class DefaultHomeRecommendationQueryRepository( and cf.creator_id = m.id and cf.is_active = true ) + and not exists ( + select 1 + from block_member bm + where :memberId is not null + and bm.is_active = true + and ( + (bm.member_id = :memberId and bm.blocked_member_id = m.id) + or (bm.member_id = m.id and bm.blocked_member_id = :memberId) + ) + ) group by m.id, m.nickname, m.profile_image ) candidates order by rand() asc @@ -960,17 +1000,26 @@ class DefaultHomeRecommendationQueryRepository( } private fun activeBannerTargetCondition( + memberId: Long?, bannerCreator: QMember, seriesOwner: QMember ): BooleanExpression { + val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR) + .and(bannerCreator.isActive.isTrue) + .withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id)) + val seriesCondition = audioContentBanner.type.eq(AudioContentBannerType.SERIES) + .and(series.isActive.isTrue) + .and(seriesOwner.isActive.isTrue) + .withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id)) + return audioContentBanner.type.eq(AudioContentBannerType.LINK) .or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue)) - .or(audioContentBanner.type.eq(AudioContentBannerType.CREATOR).and(bannerCreator.isActive.isTrue)) - .or( - audioContentBanner.type.eq(AudioContentBannerType.SERIES) - .and(series.isActive.isTrue) - .and(seriesOwner.isActive.isTrue) - ) + .or(creatorCondition) + .or(seriesCondition) + } + + private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression { + return if (condition == null) this else and(condition) } private fun includeAdultCommunityCondition(includeAdultCommunities: Boolean): BooleanExpression? { @@ -981,6 +1030,35 @@ class DefaultHomeRecommendationQueryRepository( return if (includeAdultLives) null else liveRoom.isAdult.isFalse } + private fun notBlockedCreatorCondition(memberId: Long?, creatorIdPath: Expression): BooleanExpression? { + if (memberId == null) return null + val blockMember = QBlockMember("recommendationBlockMember") + return JPAExpressions + .selectOne() + .from(blockMember) + .where( + blockMember.isActive.isTrue, + blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath)) + .or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId))) + ) + .notExists() + } + + private fun notBlockedCreatorSql(creatorIdExpression: String): String { + return """ + not exists ( + select 1 + from block_member bm + where :memberId is not null + and bm.is_active = true + and ( + (bm.member_id = :memberId and bm.blocked_member_id = $creatorIdExpression) + or (bm.member_id = $creatorIdExpression and bm.blocked_member_id = :memberId) + ) + ) + """.trimIndent() + } + private fun javax.persistence.Query.setRecommendationQueryParameters( now: LocalDateTime, limit: Int diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt index 97d291c1..750326f8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryService.kt @@ -26,38 +26,45 @@ class HomeRecommendationQueryService( fun findLiveRecommendations( offset: Int = 0, limit: Int = DEFAULT_LIVE_LIMIT, + memberId: Long? = null, includeAdultLives: Boolean = false ): List { - return queryPort.findLiveRecommendations(offset, limit, includeAdultLives) + return queryPort.findLiveRecommendations(offset, limit, memberId, includeAdultLives) } - fun findHomeBanners(limit: Int = DEFAULT_BANNER_LIMIT): List { - return queryPort.findHomeBanners(limit) + fun findHomeBanners( + limit: Int = DEFAULT_BANNER_LIMIT, + memberId: Long? = null + ): List { + return queryPort.findHomeBanners(limit, memberId) } fun findRecentlyActiveCreators( limit: Int = DEFAULT_ACTIVE_CREATOR_LIMIT, + memberId: Long? = null, includeAdultActivities: Boolean = false ): List { - return queryPort.findRecentlyActiveCreators(limit, includeAdultActivities) + return queryPort.findRecentlyActiveCreators(limit, memberId, includeAdultActivities) } fun findRecentDebutCreators( now: LocalDateTime, offset: Int = 0, limit: Int = DEFAULT_RECENT_DEBUT_CREATOR_LIMIT, + memberId: Long? = null, includeAdultContents: Boolean = false ): List { - return queryPort.findRecentDebutCreators(now, offset, limit, includeAdultContents) + return queryPort.findRecentDebutCreators(now, offset, limit, memberId, includeAdultContents) } fun findFirstAudioContents( now: LocalDateTime, offset: Int = 0, limit: Int = DEFAULT_FIRST_AUDIO_CONTENT_LIMIT, + memberId: Long? = null, includeAdultContents: Boolean = false ): List { - return queryPort.findFirstAudioContents(now, offset, limit, includeAdultContents) + return queryPort.findFirstAudioContents(now, offset, limit, memberId, includeAdultContents) } fun findAiCharacterRecommendations( @@ -72,23 +79,26 @@ class HomeRecommendationQueryService( } fun findCheerCreatorRecommendations( - limit: Int = DEFAULT_CHEER_CREATOR_LIMIT + limit: Int = DEFAULT_CHEER_CREATOR_LIMIT, + memberId: Long? = null ): List { - val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).take(limit) - val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }) + val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).take(CHEER_CREATOR_CANDIDATE_LIMIT) + val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId) .associateBy { it.creatorId } - return snapshots.mapNotNull { detailsById[it.targetId] } + return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit) } fun findPopularCommunityRecommendations( limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT, + memberId: Long? = null, includeAdultCommunities: Boolean = false ): List { val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY) .take(POPULAR_COMMUNITY_CANDIDATE_LIMIT) val detailsById = queryPort.findPopularCommunityRecommendationDetails( snapshots.map { it.targetId }, + memberId, includeAdultCommunities ) .associateBy { it.communityId } @@ -132,6 +142,7 @@ class HomeRecommendationQueryService( private const val DEFAULT_FIRST_AUDIO_CONTENT_LIMIT = 10 private const val DEFAULT_AI_CHARACTER_LIMIT = 10 private const val DEFAULT_CHEER_CREATOR_LIMIT = 8 + private const val CHEER_CREATOR_CANDIDATE_LIMIT = 16 private const val DEFAULT_POPULAR_COMMUNITY_LIMIT = 10 private const val DEFAULT_GENRE_CREATOR_GENRE_LIMIT = 5 private const val DEFAULT_GENRE_CREATOR_CREATOR_LIMIT = 8 diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt index 8c239cf8..430c1358 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/HomeRecommendationQueryPort.kt @@ -7,17 +7,26 @@ interface HomeRecommendationQueryPort { fun findLiveRecommendations( offset: Int = 0, limit: Int, + memberId: Long? = null, includeAdultLives: Boolean = false ): List - fun findHomeBanners(limit: Int): List + fun findHomeBanners( + limit: Int, + memberId: Long? = null + ): List - fun findRecentlyActiveCreators(limit: Int, includeAdultActivities: Boolean = false): List + fun findRecentlyActiveCreators( + limit: Int, + memberId: Long? = null, + includeAdultActivities: Boolean = false + ): List fun findRecentDebutCreators( now: LocalDateTime, offset: Int = 0, limit: Int, + memberId: Long? = null, includeAdultContents: Boolean = false ): List @@ -25,6 +34,7 @@ interface HomeRecommendationQueryPort { now: LocalDateTime, offset: Int = 0, limit: Int, + memberId: Long? = null, includeAdultContents: Boolean = false ): List @@ -48,10 +58,14 @@ interface HomeRecommendationQueryPort { fun findAiCharacterRecommendationDetails(characterIds: List): List - fun findCheerCreatorRecommendationDetails(creatorIds: List): List + fun findCheerCreatorRecommendationDetails( + creatorIds: List, + memberId: Long? = null + ): List fun findPopularCommunityRecommendationDetails( communityIds: List, + memberId: Long? = null, includeAdultCommunities: Boolean ): List diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt index 33a1ac82..8659a60c 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt @@ -32,6 +32,7 @@ import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendationScorePolicy import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedActivityType @@ -105,6 +106,26 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(oldest.id), page1.map { it.liveRoomId }) } + @Test + @DisplayName("라이브 추천은 회원과 크리에이터의 양방향 차단 관계를 제외한다") + fun shouldExcludeBidirectionalBlockedCreatorsFromLiveRecommendations() { + val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0) + val viewer = saveMember("blocked-live-viewer", MemberRole.USER) + val viewerBlockedCreator = saveMember("viewer-blocked-live", MemberRole.CREATOR) + val creatorBlockedViewer = saveMember("creator-blocked-live", MemberRole.CREATOR) + val visibleCreator = saveMember("visible-live", MemberRole.CREATOR) + saveLiveRoom(viewerBlockedCreator, baseAt.plusMinutes(3), channelName = "viewer-blocked-live") + saveLiveRoom(creatorBlockedViewer, baseAt.plusMinutes(2), channelName = "creator-blocked-live") + val visibleLive = saveLiveRoom(visibleCreator, baseAt.plusMinutes(1), channelName = "visible-live") + saveBlock(viewer, viewerBlockedCreator) + saveBlock(creatorBlockedViewer, viewer) + flushAndClear() + + val lives = repository.findLiveRecommendations(limit = 10, memberId = viewer.id) + + assertEquals(listOf(visibleLive.id), lives.map { it.liveRoomId }) + } + @Test @DisplayName("홈 배너는 활성 배너를 orders 오름차순 최대 20개로 조회하고 동일 orders는 랜덤 tie-breaker를 적용한다") fun shouldFindActiveHomeBannersWithOrderLimitAndRandomTieBreaker() { @@ -234,6 +255,77 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( ) } + @Test + @DisplayName("홈 배너는 크리에이터와 시리즈 소유자의 양방향 차단 관계를 제외한다") + fun shouldExcludeBidirectionalBlockedCreatorsFromHomeBanners() { + val viewer = saveMember("blocked-banner-viewer", MemberRole.USER) + val viewerBlockedCreator = saveMember("viewer-blocked-banner", MemberRole.CREATOR) + val creatorBlockedViewer = saveMember("creator-blocked-banner", MemberRole.CREATOR) + val visibleCreator = saveMember("visible-banner", MemberRole.CREATOR) + val viewerBlockedSeries = saveSeries("viewer-blocked-series-banner", viewerBlockedCreator, isActive = true) + val visibleSeries = saveSeries("visible-series-banner", visibleCreator, isActive = true) + val event = saveEvent("blocked-banner-event") + val visibleEventBanner = saveBanner( + "visible-event-banner.png", + AudioContentBannerType.EVENT, + orders = 1, + isActive = true, + event = event + ) + saveBanner( + "viewer-blocked-creator-banner.png", + AudioContentBannerType.CREATOR, + orders = 2, + isActive = true, + creator = viewerBlockedCreator + ) + saveBanner( + "creator-blocked-viewer-banner.png", + AudioContentBannerType.CREATOR, + orders = 3, + isActive = true, + creator = creatorBlockedViewer + ) + val visibleCreatorBanner = saveBanner( + "visible-creator-banner.png", + AudioContentBannerType.CREATOR, + orders = 4, + isActive = true, + creator = visibleCreator + ) + saveBanner( + "viewer-blocked-series-banner.png", + AudioContentBannerType.SERIES, + orders = 5, + isActive = true, + series = viewerBlockedSeries + ) + val visibleSeriesBanner = saveBanner( + "visible-series-banner.png", + AudioContentBannerType.SERIES, + orders = 6, + isActive = true, + series = visibleSeries + ) + val visibleLinkBanner = saveBanner( + "visible-link-banner.png", + AudioContentBannerType.LINK, + orders = 7, + isActive = true, + link = "https://visible-link.test" + ) + saveBlock(viewer, viewerBlockedCreator) + saveBlock(creatorBlockedViewer, viewer) + flushAndClear() + + val banners = repository.findHomeBanners(limit = 20, memberId = viewer.id) + + assertEquals( + listOf(visibleEventBanner.id, visibleCreatorBanner.id, visibleSeriesBanner.id, visibleLinkBanner.id), + banners.map { it.bannerId } + ) + } + @Test @DisplayName("최근 활동 크리에이터는 크리에이터당 최신 활동 1개를 LIVE/AUDIO/COMMUNITY/LIVE_REPLAY로 분류한다") fun shouldFindOneLatestActivityPerCreatorWithActivityType() { @@ -302,6 +394,28 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(RecommendedActivityType.COMMUNITY, visibleCreators[3].activityType) } + @Test + @DisplayName("최근 활동 크리에이터는 회원과 크리에이터의 양방향 차단 관계를 제외한다") + fun shouldExcludeBidirectionalBlockedCreatorsFromRecentlyActiveCreators() { + val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0) + val viewer = saveMember("blocked-activity-viewer", MemberRole.USER) + val viewerBlockedCreator = saveMember("viewer-blocked-activity", MemberRole.CREATOR) + val creatorBlockedViewer = saveMember("creator-blocked-activity", MemberRole.CREATOR) + val visibleCreator = saveMember("visible-activity", MemberRole.CREATOR) + saveLiveRoom(viewerBlockedCreator, baseAt.plusMinutes(3), channelName = "viewer-blocked-activity") + saveAudioContent(creatorBlockedViewer, baseAt.plusMinutes(2), isActive = true) + val community = saveCommunity(visibleCreator, isCommentAvailable = true) + updateCreatedAt("CreatorCommunity", community.id!!, baseAt.plusMinutes(1)) + saveBlock(viewer, viewerBlockedCreator) + saveBlock(creatorBlockedViewer, viewer) + flushAndClear() + + val creators = repository.findRecentlyActiveCreators(limit = 10, memberId = viewer.id) + + assertEquals(listOf(visibleCreator.id), creators.map { it.creatorId }) + assertEquals(RecommendedActivityType.COMMUNITY, creators.single().activityType) + } + @Test @DisplayName("AI 캐릭터 스냅샷은 AI 발화 수와 중복 없는 활성 사용자 수를 집계하고 팔로우 증가량은 제외한다") fun shouldFindAiCharacterSnapshotsWithoutFollowIncrease() { @@ -863,6 +977,26 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(normalOldest.id), page1.map { it.creatorId }) } + @Test + @DisplayName("최근 데뷔 크리에이터는 회원과 크리에이터의 양방향 차단 관계를 제외한다") + fun shouldExcludeBidirectionalBlockedCreatorsFromRecentDebutCreators() { + val now = LocalDateTime.of(2026, 5, 31, 10, 0) + val viewer = saveMember("blocked-debut-viewer", MemberRole.USER) + val viewerBlockedCreator = saveMember("viewer-blocked-debut", MemberRole.CREATOR) + val creatorBlockedViewer = saveMember("creator-blocked-debut", MemberRole.CREATOR) + val visibleCreator = saveMember("visible-debut", MemberRole.CREATOR) + saveAudioContent(viewerBlockedCreator, now.minusDays(3), isActive = true) + saveAudioContent(creatorBlockedViewer, now.minusDays(2), isActive = true) + saveAudioContent(visibleCreator, now.minusDays(1), isActive = true) + saveBlock(viewer, viewerBlockedCreator) + saveBlock(creatorBlockedViewer, viewer) + flushAndClear() + + val creators = repository.findRecentDebutCreators(now, limit = 10, memberId = viewer.id) + + assertEquals(listOf(visibleCreator.id), creators.map { it.creatorId }) + } + @Test @DisplayName("최근 데뷔 크리에이터 전체보기 동점 후보는 page size 1에서도 안정적으로 겹치지 않는다") fun shouldFindPagedRecentDebutCreatorsWithStableTieOrdering() { @@ -970,6 +1104,26 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(oldest.id), page1.map { it.contentId }) } + @Test + @DisplayName("첫 오디오 콘텐츠는 회원과 크리에이터의 양방향 차단 관계를 제외한다") + fun shouldExcludeBidirectionalBlockedCreatorsFromFirstAudioContents() { + val now = LocalDateTime.of(2026, 5, 31, 10, 0) + val viewer = saveMember("blocked-first-audio-viewer", MemberRole.USER) + val viewerBlockedCreator = saveMember("viewer-blocked-first-audio", MemberRole.CREATOR) + val creatorBlockedViewer = saveMember("creator-blocked-first-audio", MemberRole.CREATOR) + val visibleCreator = saveMember("visible-first-audio", MemberRole.CREATOR) + saveAudioContent(viewerBlockedCreator, now.minusDays(3), isActive = true) + saveAudioContent(creatorBlockedViewer, now.minusDays(2), isActive = true) + val visibleContent = saveAudioContent(visibleCreator, now.minusDays(1), isActive = true) + saveBlock(viewer, viewerBlockedCreator) + saveBlock(creatorBlockedViewer, viewer) + flushAndClear() + + val contents = repository.findFirstAudioContents(now, limit = 10, memberId = viewer.id) + + assertEquals(listOf(visibleContent.id), contents.map { it.contentId }) + } + @Test @DisplayName("첫 오디오 전체보기 동점 후보는 page size 1에서도 안정적으로 겹치지 않는다") fun shouldFindPagedFirstAudioContentsWithStableTieOrdering() { @@ -1046,6 +1200,25 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals("cheer-detail-active", detailById[activeCreator.id]!!.creatorNickname) } + @Test + @DisplayName("최근 응원 크리에이터 상세는 회원과 크리에이터의 양방향 차단 관계를 제외한다") + fun shouldExcludeBidirectionalBlockedCreatorsFromCheerCreatorDetails() { + val viewer = saveMember("blocked-cheer-viewer", MemberRole.USER) + val viewerBlockedCreator = saveMember("viewer-blocked-cheer", MemberRole.CREATOR) + val creatorBlockedViewer = saveMember("creator-blocked-cheer", MemberRole.CREATOR) + val visibleCreator = saveMember("visible-cheer", MemberRole.CREATOR) + saveBlock(viewer, viewerBlockedCreator) + saveBlock(creatorBlockedViewer, viewer) + flushAndClear() + + val details = repository.findCheerCreatorRecommendationDetails( + listOf(viewerBlockedCreator.id!!, creatorBlockedViewer.id!!, visibleCreator.id!!), + memberId = viewer.id + ) + + assertEquals(listOf(visibleCreator.id), details.map { it.creatorId }) + } + @Test @DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다") fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() { @@ -1114,6 +1287,29 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(1L, detailById[adult.id]!!.likeCount) } + @Test + @DisplayName("인기 커뮤니티 상세는 회원과 크리에이터의 양방향 차단 관계를 제외한다") + fun shouldExcludeBidirectionalBlockedCreatorsFromPopularCommunityDetails() { + val viewer = saveMember("blocked-community-viewer", MemberRole.USER) + val viewerBlockedCreator = saveMember("viewer-blocked-community", MemberRole.CREATOR) + val creatorBlockedViewer = saveMember("creator-blocked-community", MemberRole.CREATOR) + val visibleCreator = saveMember("visible-community", MemberRole.CREATOR) + val viewerBlockedPost = saveCommunity(viewerBlockedCreator, isCommentAvailable = true) + val creatorBlockedPost = saveCommunity(creatorBlockedViewer, isCommentAvailable = true) + val visiblePost = saveCommunity(visibleCreator, isCommentAvailable = true) + saveBlock(viewer, viewerBlockedCreator) + saveBlock(creatorBlockedViewer, viewer) + flushAndClear() + + val details = repository.findPopularCommunityRecommendationDetails( + listOf(viewerBlockedPost.id!!, creatorBlockedPost.id!!, visiblePost.id!!), + memberId = viewer.id, + includeAdultCommunities = false + ) + + assertEquals(listOf(visiblePost.id), details.map { it.communityId }) + } + @Test @DisplayName("인기 커뮤니티 상세는 빈 id 목록이면 빈 배열을 반환한다") fun shouldReturnEmptyPopularCommunityRecommendationDetailsWhenIdsAreEmpty() { @@ -1176,6 +1372,32 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(true, recommendations.flatMap { it.creators }.any { it.creatorId == fallbackCreator.id }) } + @Test + @DisplayName("장르 기반 크리에이터 추천은 회원과 크리에이터의 양방향 차단 관계를 제외한다") + fun shouldExcludeBidirectionalBlockedCreatorsFromGenreCreatorRecommendations() { + val viewer = saveMember("blocked-genre-viewer", MemberRole.USER) + val viewerBlockedCreator = saveMember("viewer-blocked-creator", MemberRole.CREATOR) + val creatorBlockedViewer = saveMember("creator-blocked-viewer", MemberRole.CREATOR) + val visibleCreator = saveMember("visible-block-creator", MemberRole.CREATOR) + val theme = saveTheme("blocked-genre-theme") + saveAudioContent(viewerBlockedCreator, LocalDateTime.of(2026, 5, 30, 10, 0), isActive = true, theme = theme) + saveAudioContent(creatorBlockedViewer, LocalDateTime.of(2026, 5, 30, 11, 0), isActive = true, theme = theme) + saveAudioContent(visibleCreator, LocalDateTime.of(2026, 5, 30, 12, 0), isActive = true, theme = theme) + saveBlock(member = viewer, blockedMember = viewerBlockedCreator) + saveBlock(member = creatorBlockedViewer, blockedMember = viewer) + flushAndClear() + + val recommendations = repository.findGenreCreatorRecommendations( + memberId = viewer.id!!, + includeAdultGenres = false, + genreLimit = 1, + creatorLimit = 8 + ) + + assertEquals(listOf(theme.id), recommendations.map { it.genreId }) + assertEquals(listOf(visibleCreator.id), recommendations.single().creators.map { it.creatorId }) + } + @Test @DisplayName("장르 기반 크리에이터 추천은 series_genre가 아니라 content_theme 기준으로 그룹을 만든다") fun shouldGroupGenreCreatorRecommendationsByContentThemeNotSeriesGenre() { @@ -1621,6 +1843,14 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( return following } + private fun saveBlock(member: Member, blockedMember: Member): BlockMember { + val blockMember = BlockMember(isActive = true) + blockMember.member = member + blockMember.blockedMember = blockedMember + entityManager.persist(blockMember) + return blockMember + } + private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id") .setParameter("createdAt", createdAt) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt index bac0a74a..27f794b7 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/HomeRecommendationQueryServiceTest.kt @@ -67,27 +67,30 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("홈 라이브 추천은 기본 20개를 최신순 조회 포트에 위임한다") fun shouldFindLatestLiveRecommendationsWithDefaultLimit() { - val recommendations = service.findLiveRecommendations() + val recommendations = service.findLiveRecommendations(memberId = 100L) assertEquals(20, port.liveLimit) + assertEquals(100L, port.liveMemberId) assertEquals(port.liveRecommendations, recommendations) } @Test @DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다") fun shouldFindHomeBannersWithDefaultLimit() { - val banners = service.findHomeBanners() + val banners = service.findHomeBanners(memberId = 100L) assertEquals(20, port.bannerLimit) + assertEquals(100L, port.bannerMemberId) assertEquals(port.banners, banners) } @Test @DisplayName("최근 활동 크리에이터는 기본 10개를 최신 활동 조회 포트에 위임한다") fun shouldFindRecentlyActiveCreatorsWithDefaultLimit() { - val creators = service.findRecentlyActiveCreators() + val creators = service.findRecentlyActiveCreators(memberId = 100L) assertEquals(10, port.activeCreatorLimit) + assertEquals(100L, port.activeCreatorMemberId) assertEquals(false, port.activeCreatorIncludeAdultActivities) assertEquals(port.activeCreators, creators) } @@ -95,9 +98,10 @@ class HomeRecommendationQueryServiceTest { @Test @DisplayName("최근 활동 크리에이터는 성인 활동 노출 여부를 조회 포트에 위임한다") fun shouldFindRecentlyActiveCreatorsWithAdultVisibilityPolicy() { - val creators = service.findRecentlyActiveCreators(limit = 8, includeAdultActivities = true) + val creators = service.findRecentlyActiveCreators(limit = 8, memberId = 101L, includeAdultActivities = true) assertEquals(8, port.activeCreatorLimit) + assertEquals(101L, port.activeCreatorMemberId) assertEquals(true, port.activeCreatorIncludeAdultActivities) assertEquals(port.activeCreators, creators) } @@ -107,9 +111,10 @@ class HomeRecommendationQueryServiceTest { fun shouldFindRecentDebutCreatorsWithDefaultLimitAndNow() { val now = LocalDateTime.of(2026, 5, 31, 10, 0) - val creators = service.findRecentDebutCreators(now) + val creators = service.findRecentDebutCreators(now, memberId = 102L) assertEquals(now, port.recentDebutNow) + assertEquals(102L, port.recentDebutMemberId) assertEquals(10, port.recentDebutLimit) assertEquals(port.recentDebutCreators, creators) } @@ -119,9 +124,10 @@ class HomeRecommendationQueryServiceTest { fun shouldFindFirstAudioContentsWithDefaultLimitAndNow() { val now = LocalDateTime.of(2026, 5, 31, 10, 0) - val contents = service.findFirstAudioContents(now) + val contents = service.findFirstAudioContents(now, memberId = 103L) assertEquals(now, port.firstAudioNow) + assertEquals(103L, port.firstAudioMemberId) assertEquals(10, port.firstAudioLimit) assertEquals(port.firstAudioContents, contents) } @@ -192,9 +198,10 @@ class HomeRecommendationQueryServiceTest { ) ) - val creators = service.findCheerCreatorRecommendations() + val creators = service.findCheerCreatorRecommendations(memberId = 104L) - assertEquals((1L..8L).toList(), port.cheerCreatorDetailIds) + assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds) + assertEquals(104L, port.cheerCreatorMemberId) assertEquals(listOf(1L, 2L), creators.map { it.creatorId }) assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname }) } @@ -243,9 +250,10 @@ class HomeRecommendationQueryServiceTest { ) ) - val communities = service.findPopularCommunityRecommendations(includeAdultCommunities = true) + val communities = service.findPopularCommunityRecommendations(memberId = 105L, includeAdultCommunities = true) assertEquals((1L..11L).toList(), port.popularCommunityDetailIds) + assertEquals(105L, port.popularCommunityMemberId) assertEquals(true, port.popularCommunityIncludeAdultCommunities) assertEquals(listOf(1L, 3L), communities.map { it.communityId }) assertEquals(listOf(10L, 11L), communities.map { it.creatorId }) @@ -401,17 +409,22 @@ class HomeRecommendationQueryServiceTest { private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort { var liveLimit: Int? = null var liveOffset: Int? = null + var liveMemberId: Long? = null var liveIncludeAdultLives: Boolean? = null var bannerLimit: Int? = null + var bannerMemberId: Long? = null var activeCreatorLimit: Int? = null + var activeCreatorMemberId: Long? = null var activeCreatorIncludeAdultActivities: Boolean? = null var recentDebutNow: LocalDateTime? = null var recentDebutLimit: Int? = null var recentDebutOffset: Int? = null + var recentDebutMemberId: Long? = null var recentDebutIncludeAdultContents: Boolean? = null var firstAudioNow: LocalDateTime? = null var firstAudioLimit: Int? = null var firstAudioOffset: Int? = null + var firstAudioMemberId: Long? = null var firstAudioIncludeAdultContents: Boolean? = null var aiCharacterDetailIds: List = emptyList() var cheerCreatorDetailIds: List = emptyList() @@ -481,30 +494,37 @@ class HomeRecommendationQueryServiceTest { ) var aiCharacterDetails: List = emptyList() var cheerCreatorDetails: List = emptyList() + var cheerCreatorMemberId: Long? = null var popularCommunityDetails: List = emptyList() + var popularCommunityMemberId: Long? = null var genreCreatorRecommendations: List = emptyList() override fun findLiveRecommendations( offset: Int, limit: Int, + memberId: Long?, includeAdultLives: Boolean ): List { liveOffset = offset liveLimit = limit + liveMemberId = memberId liveIncludeAdultLives = includeAdultLives return liveRecommendations } - override fun findHomeBanners(limit: Int): List { + override fun findHomeBanners(limit: Int, memberId: Long?): List { bannerLimit = limit + bannerMemberId = memberId return banners } override fun findRecentlyActiveCreators( limit: Int, + memberId: Long?, includeAdultActivities: Boolean ): List { activeCreatorLimit = limit + activeCreatorMemberId = memberId activeCreatorIncludeAdultActivities = includeAdultActivities return activeCreators } @@ -513,11 +533,13 @@ class HomeRecommendationQueryServiceTest { now: LocalDateTime, offset: Int, limit: Int, + memberId: Long?, includeAdultContents: Boolean ): List { recentDebutNow = now recentDebutOffset = offset recentDebutLimit = limit + recentDebutMemberId = memberId recentDebutIncludeAdultContents = includeAdultContents return recentDebutCreators } @@ -526,11 +548,13 @@ class HomeRecommendationQueryServiceTest { now: LocalDateTime, offset: Int, limit: Int, + memberId: Long?, includeAdultContents: Boolean ): List { firstAudioNow = now firstAudioOffset = offset firstAudioLimit = limit + firstAudioMemberId = memberId firstAudioIncludeAdultContents = includeAdultContents return firstAudioContents } @@ -558,16 +582,22 @@ class HomeRecommendationQueryServiceTest { return aiCharacterDetails } - override fun findCheerCreatorRecommendationDetails(creatorIds: List): List { + override fun findCheerCreatorRecommendationDetails( + creatorIds: List, + memberId: Long? + ): List { cheerCreatorDetailIds = creatorIds + cheerCreatorMemberId = memberId return cheerCreatorDetails } override fun findPopularCommunityRecommendationDetails( communityIds: List, + memberId: Long?, includeAdultCommunities: Boolean ): List { popularCommunityDetailIds = communityIds + popularCommunityMemberId = memberId popularCommunityIncludeAdultCommunities = includeAdultCommunities return popularCommunityDetails }