From ea331ce66f3b983eb6e1f366937256d302c62964 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 08:56:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=9D=B8=EA=B8=B0=20=EC=BB=A4?= =?UTF-8?q?=EB=AE=A4=EB=8B=88=ED=8B=B0=20=EC=A7=91=EA=B3=84=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...efaultHomeRecommendationQueryRepository.kt | 32 +++-- .../port/out/HomeRecommendationQueryPort.kt | 2 +- ...ltHomeRecommendationQueryRepositoryTest.kt | 110 +++++++++++++++--- 3 files changed, 116 insertions(+), 28 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 8def3509..15b1ae9a 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 @@ -648,9 +648,10 @@ class DefaultHomeRecommendationQueryRepository( override fun findPopularCommunitySnapshots( windowStart: LocalDateTime, - snapshotAt: LocalDateTime, + windowEndExclusive: LocalDateTime, limit: Int ): List { + val snapshotAt = windowEndExclusive.minusSeconds(1) val sql = """ with creator_debut as ( select debut_events.creator_id as creator_id, min(debut_events.debut_at) as debut_at @@ -675,7 +676,7 @@ class DefaultHomeRecommendationQueryRepository( from creator_community_like ccl where ccl.is_active = true and ccl.created_at >= :windowStart - and ccl.created_at <= :snapshotAt + and ccl.created_at < :windowEndExclusive group by ccl.creator_community_id ), comment_stats as ( @@ -683,7 +684,7 @@ class DefaultHomeRecommendationQueryRepository( from creator_community_comment ccc where ccc.is_active = true and ccc.created_at >= :windowStart - and ccc.created_at <= :snapshotAt + and ccc.created_at < :windowEndExclusive group by ccc.creator_community_id ), follower_stats as ( @@ -697,9 +698,9 @@ class DefaultHomeRecommendationQueryRepository( (case when cc.is_comment_available = true then coalesce(cs.comment_count, 0) else 0 end) * ${RecommendationScoreSpec.COMMUNITY_COMMENT_WEIGHT} + coalesce(fs.follower_count, 0) * ${RecommendationScoreSpec.COMMUNITY_FOLLOWER_WEIGHT}) * case - when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.NEW_BOOST_10_DAYS} - when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.NEW_BOOST_20_DAYS} - when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.NEW_BOOST_30_DAYS} + when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_10_DAYS} + when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_20_DAYS} + when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.COMMUNITY_NEW_BOOST_30_DAYS} else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} end) as score, rand() as random_tie_breaker @@ -711,15 +712,27 @@ class DefaultHomeRecommendationQueryRepository( left join follower_stats fs on fs.creator_id = cc.member_id where cc.is_active = true and m.is_active = true - and cc.price = 0 + and cc.price <= 0 and cc.is_fixed = false - and cc.created_at <= :snapshotAt + and cc.created_at < :windowEndExclusive and cd.debut_at is not null + and ( + coalesce(ls.like_count, 0) > 0 + or (cc.is_comment_available = true and coalesce(cs.comment_count, 0) > 0) + or coalesce(fs.follower_count, 0) > 0 + ) order by score desc, random_tie_breaker asc limit :limit """.trimIndent() - return executeSnapshotQuery(sql, RecommendedSectionType.POPULAR_COMMUNITY, windowStart, snapshotAt, limit) + return executeSnapshotQuery( + sql, + RecommendedSectionType.POPULAR_COMMUNITY, + windowStart, + snapshotAt, + limit, + windowEndExclusive + ) } override fun findAiCharacterRecommendationDetails( @@ -832,6 +845,7 @@ class DefaultHomeRecommendationQueryRepository( creatorCommunity.isActive.isTrue, member.isActive.isTrue, creatorCommunity.isFixed.isFalse, + creatorCommunity.price.loe(0), includeAdultCommunityCondition(includeAdultCommunities), notBlockedCreatorCondition(memberId, member.id), creatorCommunity.id.`in`(communityIds) 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 8d90d83a..eaedcb4e 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 @@ -52,7 +52,7 @@ interface HomeRecommendationQueryPort { fun findPopularCommunitySnapshots( windowStart: LocalDateTime, - snapshotAt: LocalDateTime, + windowEndExclusive: LocalDateTime, limit: Int ): List 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 766670f1..cc6d2639 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 @@ -940,6 +940,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() { val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) val creator = saveMember("community-creator", MemberRole.CREATOR) val member1 = saveMember("community-member-1", MemberRole.USER) val member2 = saveMember("community-member-2", MemberRole.USER) @@ -970,20 +971,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 4, 1, 12, 0)) flushAndClear() - val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) + val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10) .associateBy { it.targetId } val expectedPostScore = scorePolicy.calculateCommunityScore( likeCount = 2, commentCount = 2, followerCount = 2, - newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) + newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) ) val expectedCommentDisabledScore = scorePolicy.calculateCommunityScore( likeCount = 0, commentCount = 0, followerCount = 2, - newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) + newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 4, 1, 12, 0), snapshotAt) ) assertEquals(expectedPostScore, snapshots[post.id]!!.score, 0.0001) assertEquals(expectedCommentDisabledScore, snapshots[commentDisabledPost.id]!!.score, 0.0001) @@ -994,6 +995,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldIncludeAdultCommunitiesInPopularCommunitySnapshots() { val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) val creator = saveMember("adult-community-creator", MemberRole.CREATOR) val member = saveMember("adult-community-member", MemberRole.USER) saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "adult-community-live") @@ -1007,7 +1009,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("CreatorCommunityLike", adultLike.id!!, windowStart.plusDays(1)) flushAndClear() - val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) + val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10) assertEquals(setOf(normalPost.id, adultPost.id), snapshots.map { it.targetId }.toSet()) } @@ -1017,6 +1019,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldFindPopularCommunitySnapshotsWithDbScoreOrderAndLimit() { val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) val liker = saveMember("community-score-liker", MemberRole.USER) val oldCreator = saveMember("old-community-score-creator", MemberRole.CREATOR) val newCreator = saveMember("new-community-score-creator", MemberRole.CREATOR) @@ -1032,13 +1035,13 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("CreatorCommunityLike", newLike.id!!, windowStart.plusDays(1)) flushAndClear() - val snapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 1) + val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 1) val expectedScore = scorePolicy.calculateCommunityScore( likeCount = 1, commentCount = 0, followerCount = 0, - newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) + newBoost = scorePolicy.calculateCommunityCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) ) assertEquals(1, snapshots.size) assertEquals(RecommendedSectionType.POPULAR_COMMUNITY, snapshots.single().sectionType) @@ -1046,6 +1049,56 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(expectedScore, snapshots.single().score, 0.0001) } + @Test + @DisplayName("인기 커뮤니티 스냅샷은 좋아요 댓글 팔로워가 모두 0인 게시글을 제외한다") + fun shouldExcludeZeroMetricPopularCommunitySnapshots() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val creator = saveMember("zero-community-creator", MemberRole.CREATOR) + val liker = saveMember("zero-community-liker", MemberRole.USER) + saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "zero-community-live") + val zeroPost = saveCommunity(creator, isCommentAvailable = true) + val scoredPost = saveCommunity(creator, isCommentAvailable = true) + val like = saveCommunityLike(liker, scoredPost, isActive = true) + updateCreatedAt("CreatorCommunity", zeroPost.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunity", scoredPost.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) + flushAndClear() + + val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10) + + assertEquals(listOf(scoredPost.id), snapshots.map { it.targetId }) + } + + @Test + @DisplayName("인기 커뮤니티 스냅샷 후보는 점수가 있어도 유료와 고정 게시글을 제외한다") + fun shouldExcludePaidAndFixedPostsFromPopularCommunitySnapshots() { + val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowEndExclusive = snapshotAt.plusSeconds(1) + val creator = saveMember("paid-fixed-community-creator", MemberRole.CREATOR) + val liker = saveMember("paid-fixed-community-liker", MemberRole.USER) + saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "paid-fixed-community-live") + val eligiblePost = saveCommunity(creator, isCommentAvailable = true) + val paidPost = saveCommunity(creator, isCommentAvailable = true, price = 10) + val fixedPost = saveCommunity(creator, isCommentAvailable = true, isFixed = true) + val eligibleLike = saveCommunityLike(liker, eligiblePost, isActive = true) + val paidLike = saveCommunityLike(liker, paidPost, isActive = true) + val fixedLike = saveCommunityLike(liker, fixedPost, isActive = true) + listOf(eligiblePost, paidPost, fixedPost).forEach { post -> + updateCreatedAt("CreatorCommunity", post.id!!, windowStart.plusDays(1)) + } + listOf(eligibleLike, paidLike, fixedLike).forEach { like -> + updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) + } + flushAndClear() + + val snapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10) + + assertEquals(listOf(eligiblePost.id), snapshots.map { it.targetId }) + } + @Test @DisplayName("실제 데뷔일이 없는 크리에이터는 최근 응원과 인기 커뮤니티 스냅샷에서 제외한다") fun shouldExcludeCreatorSnapshotsWithoutActualDebutAt() { @@ -1070,7 +1123,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( flushAndClear() val cheerSnapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10) - val communitySnapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) + val communitySnapshots = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10) assertEquals(emptyList(), cheerSnapshots) assertEquals(emptyList(), communitySnapshots) @@ -1106,7 +1159,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( flushAndClear() val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10).single() - val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single() + val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10).single() val expectedCheerScore = scorePolicy.calculateCheerScore( donationAmount = 100, @@ -1118,7 +1171,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( likeCount = 1, commentCount = 0, followerCount = 0, - newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt) + newBoost = scorePolicy.calculateCommunityCreatorNewBoost(firstLiveAt, snapshotAt) ) assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) assertEquals(snapshotAt, cheerSnapshot.snapshotAt) @@ -1153,7 +1206,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( flushAndClear() val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10).single() - val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single() + val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, windowEndExclusive, limit = 10).single() val expectedCheerScore = scorePolicy.calculateCheerScore( donationAmount = 100, @@ -1165,7 +1218,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( likeCount = 1, commentCount = 0, followerCount = 0, - newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt) + newBoost = scorePolicy.calculateCommunityCreatorNewBoost(contentDebutAt, snapshotAt) ) assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) assertEquals(snapshotAt, cheerSnapshot.snapshotAt) @@ -1200,6 +1253,30 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(newHighScoreCreator.id, newLowScoreCreator.id), creators.map { it.creatorId }) } + @Test + @DisplayName("최근 데뷔 크리에이터는 인기 커뮤니티 전용 부스트가 아니라 기존 신규 부스트를 유지한다") + fun shouldKeepOriginalNewBoostForRecentDebutCreators() { + val now = LocalDateTime.of(2026, 5, 31, 10, 0) + val boostedCreator = saveMember("original-boost-debut", MemberRole.CREATOR) + val baseCreator = saveMember("base-boost-debut", MemberRole.CREATOR) + val fan = saveMember("original-boost-fan", MemberRole.USER) + val boostedContent = saveAudioContent(boostedCreator, now.minusDays(5), isActive = true) + val baseContent = saveAudioContent(baseCreator, now.minusDays(20), isActive = true) + val boostedFollowing = saveFollowing(fan, boostedCreator, isActive = true) + val baseLike1 = saveAudioContentLike(fan, baseContent, isActive = true) + val baseLike2 = saveAudioContentLike(fan, baseContent, isActive = true) + updateCreatedAt("AudioContent", boostedContent.id!!, now.minusDays(5)) + updateCreatedAt("AudioContent", baseContent.id!!, now.minusDays(20)) + updateCreatedAt("CreatorFollowing", boostedFollowing.id!!, now.minusDays(1)) + updateCreatedAt("AudioContentLike", baseLike1.id!!, now.minusDays(1)) + updateCreatedAt("AudioContentLike", baseLike2.id!!, now.minusDays(1)) + flushAndClear() + + val creators = repository.findRecentDebutCreators(now, limit = 2) + + assertEquals(listOf(boostedCreator.id, baseCreator.id), creators.map { it.creatorId }) + } + @Test @DisplayName("최근 데뷔 크리에이터 전체보기 조회는 offset/limit과 성인 콘텐츠 제외를 DB에서 적용한다") fun shouldFindPagedRecentDebutCreatorsWithAdultFilter() { @@ -1604,19 +1681,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( ) val detailById = details.associateBy { it.communityId } - assertEquals(setOf(eligible.id, paid.id), detailById.keys) + assertEquals(setOf(eligible.id), detailById.keys) assertEquals("content", detailById[eligible.id]!!.content) assertEquals("community/detail-image.png", detailById[eligible.id]!!.imagePath) assertEquals("community/detail-audio.mp3", detailById[eligible.id]!!.audioPath) assertEquals(0, detailById[eligible.id]!!.price) - assertEquals(10, detailById[paid.id]!!.price) assertEquals(LocalDateTime.of(2026, 5, 29, 1, 0), detailById[eligible.id]!!.createdAt) assertEquals(2L, detailById[eligible.id]!!.likeCount) assertEquals(1L, detailById[eligible.id]!!.commentCount) assertEquals(false, detailById[eligible.id]!!.existOrdered) - assertEquals(true, detailById[paid.id]!!.existOrdered) assertEquals(true, detailById[eligible.id]!!.isLiked) - assertEquals(false, detailById[paid.id]!!.isLiked) assertEquals(creator.id, detailById[eligible.id]!!.creatorId) assertEquals("community-detail-creator", detailById[eligible.id]!!.creatorNickname) } @@ -1625,16 +1699,16 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( @DisplayName("인기 커뮤니티 상세는 비회원에게 구매 여부를 false로 반환한다") fun shouldReturnFalseOrderStatusForAnonymousPopularCommunityDetails() { val creator = saveMember("anonymous-community-creator", MemberRole.CREATOR) - val paid = saveCommunity(creator, isCommentAvailable = true, price = 10) + val free = saveCommunity(creator, isCommentAvailable = true, price = 0) flushAndClear() val details = repository.findPopularCommunityRecommendationDetails( - listOf(paid.id!!), + listOf(free.id!!), memberId = null, includeAdultCommunities = false ) - assertEquals(listOf(paid.id), details.map { it.communityId }) + assertEquals(listOf(free.id), details.map { it.communityId }) assertEquals(listOf(false), details.map { it.existOrdered }) assertEquals(listOf(false), details.map { it.isLiked }) }