From 4f348c36437eacd29f275762c3b719e7433d1bfa Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 06:05:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=9D=91=EC=9B=90=20=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20=EC=A7=91=EA=B3=84=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=A5=BC=20=EB=B3=80=EA=B2=BD=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/can/use/UseCanCalculate.kt | 2 +- ...efaultHomeRecommendationQueryRepository.kt | 28 +++--- .../port/out/HomeRecommendationQueryPort.kt | 2 +- ...ltHomeRecommendationQueryRepositoryTest.kt | 86 +++++++++++++------ 4 files changed, 78 insertions(+), 40 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt index 3b0efbe7..b15c5aa4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCanCalculate.kt @@ -12,7 +12,7 @@ import javax.persistence.JoinColumn import javax.persistence.ManyToOne @Entity -data class UseCanCalculate( +class UseCanCalculate( val can: Int, @Column(nullable = false) 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 442d69ba..8def3509 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 @@ -570,9 +570,10 @@ class DefaultHomeRecommendationQueryRepository( override fun findCheerCreatorSnapshots( 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 @@ -595,14 +596,14 @@ class DefaultHomeRecommendationQueryRepository( donation_stats as ( select ucc.recipient_creator_id as creator_id, coalesce(sum(ucc.can), 0) as donation_amount, - count(ucc.id) as donation_count + count(distinct ucc.use_can_id) as donation_count from use_can_calculate ucc join use_can uc on uc.id = ucc.use_can_id where ucc.status = 'RECEIVED' and uc.is_refund = false and uc.can_usage = 'CHANNEL_DONATION' and ucc.created_at >= :windowStart - and ucc.created_at <= :snapshotAt + and ucc.created_at < :windowEndExclusive group by ucc.recipient_creator_id ), fan_talk_stats as ( @@ -610,17 +611,17 @@ class DefaultHomeRecommendationQueryRepository( from creator_cheers ch where ch.is_active = true and ch.created_at >= :windowStart - and ch.created_at <= :snapshotAt + and ch.created_at < :windowEndExclusive group by ch.creator_id ) select m.id as target_id, ((coalesce(ds.donation_amount, 0) * ${RecommendationScoreSpec.CHEER_DONATION_AMOUNT_WEIGHT} + coalesce(fts.fan_talk_count, 0) * ${RecommendationScoreSpec.CHEER_FAN_TALK_WEIGHT} + - coalesce(ds.donation_count, 0) * ${RecommendationScoreSpec.CHEER_DONATION_COUNT_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} + coalesce(ds.donation_count, 0) * ${RecommendationScoreSpec.CHEER_DONATION_COUNT_WEIGHT}) * + case + when cd.debut_at >= :boost10Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_10_DAYS} + when cd.debut_at >= :boost20Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_20_DAYS} + when cd.debut_at >= :boost30Start then ${RecommendationScoreSpec.CHEER_NEW_BOOST_30_DAYS} else ${RecommendationScoreSpec.DEFAULT_NEW_BOOST} end) as score, rand() as random_tie_breaker @@ -635,7 +636,14 @@ class DefaultHomeRecommendationQueryRepository( limit :limit """.trimIndent() - return executeSnapshotQuery(sql, RecommendedSectionType.CHEER_CREATOR, windowStart, snapshotAt, limit) + return executeSnapshotQuery( + sql, + RecommendedSectionType.CHEER_CREATOR, + windowStart, + snapshotAt, + limit, + windowEndExclusive + ) } override fun findPopularCommunitySnapshots( 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 b1640f33..8d90d83a 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 @@ -46,7 +46,7 @@ interface HomeRecommendationQueryPort { fun findCheerCreatorSnapshots( 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 37172326..766670f1 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 @@ -773,32 +773,34 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( } @Test - @DisplayName("최근 응원 스냅샷은 CHANNEL_DONATION 후원 금액과 후원 수만 집계한다") + @DisplayName("최근 응원 스냅샷은 전날 half-open 범위의 CHANNEL_DONATION 금액과 distinct 후원 수만 집계한다") fun shouldFindCheerCreatorSnapshotsWithChannelDonationOnly() { - val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0) - val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + val windowStart = LocalDateTime.of(2026, 5, 28, 15, 0) + val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0) + val snapshotAt = windowEndExclusive.minusSeconds(1) val creator = saveMember("cheer-creator", MemberRole.CREATOR) val donor = saveMember("cheer-donor", MemberRole.USER) saveAudioContent(creator, LocalDateTime.of(2026, 5, 20, 12, 0), isActive = true) saveLiveRoom(creator, LocalDateTime.of(2026, 5, 10, 12, 0), channelName = "cheer-channel") - saveUseCanCalculate( + val firstDonation = saveUseCanCalculate( donor, creator, CanUsage.CHANNEL_DONATION, can = 100, status = UseCanCalculateStatus.RECEIVED, isRefund = false, - createdAt = windowStart.plusDays(1) + createdAt = windowStart.plusHours(1) ) + saveUseCanCalculateForUseCan(firstDonation.useCan!!, creator, can = 50, createdAt = windowStart.plusHours(2)) saveUseCanCalculate( donor, creator, CanUsage.CHANNEL_DONATION, - can = 50, + can = 999, status = UseCanCalculateStatus.RECEIVED, isRefund = false, - createdAt = snapshotAt + createdAt = windowEndExclusive ) saveUseCanCalculate( donor, @@ -807,7 +809,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( can = 1_000, status = UseCanCalculateStatus.RECEIVED, isRefund = false, - createdAt = windowStart.plusDays(1) + createdAt = windowStart.plusHours(1) ) saveUseCanCalculate( donor, @@ -816,7 +818,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( can = 1_000, status = UseCanCalculateStatus.RECEIVED, isRefund = false, - createdAt = windowStart.plusDays(1) + createdAt = windowStart.plusHours(1) ) saveUseCanCalculate( donor, @@ -825,7 +827,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( can = 1_000, status = UseCanCalculateStatus.RECEIVED, isRefund = false, - createdAt = windowStart.plusDays(1) + createdAt = windowStart.plusHours(1) ) saveUseCanCalculate( donor, @@ -834,7 +836,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( can = 1_000, status = UseCanCalculateStatus.REFUND, isRefund = false, - createdAt = windowStart.plusDays(1) + createdAt = windowStart.plusHours(1) ) saveUseCanCalculate( donor, @@ -843,7 +845,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( can = 1_000, status = UseCanCalculateStatus.RECEIVED, isRefund = true, - createdAt = windowStart.plusDays(1) + createdAt = windowStart.plusHours(1) ) saveUseCanCalculate( donor, @@ -861,28 +863,29 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( can = 1_000, status = UseCanCalculateStatus.RECEIVED, isRefund = false, - createdAt = windowStart.plusDays(1) + createdAt = windowStart.plusHours(1) ) val cheer1 = saveCreatorCheers(donor, creator, isActive = true) val cheer2 = saveCreatorCheers(donor, creator, isActive = true) val inactiveCheer = saveCreatorCheers(donor, creator, isActive = false) - updateCreatedAt("CreatorCheers", cheer1.id!!, windowStart.plusDays(1)) - updateCreatedAt("CreatorCheers", cheer2.id!!, snapshotAt) - updateCreatedAt("CreatorCheers", inactiveCheer.id!!, windowStart.plusDays(1)) + updateCreatedAt("CreatorCheers", cheer1.id!!, windowStart.plusHours(1)) + updateCreatedAt("CreatorCheers", cheer2.id!!, windowEndExclusive) + updateCreatedAt("CreatorCheers", inactiveCheer.id!!, windowStart.plusHours(1)) updateCreatedAt("Member", creator.id!!, LocalDateTime.of(2026, 5, 10, 12, 0)) flushAndClear() - val snapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10) + val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10) val expectedScore = scorePolicy.calculateCheerScore( donationAmount = 150, - fanTalkCount = 2, - donationCount = 2, - newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt) + fanTalkCount = 1, + donationCount = 1, + newBoost = scorePolicy.calculateCheerCreatorNewBoost(LocalDateTime.of(2026, 5, 10, 12, 0), snapshotAt) ) assertEquals(1, snapshots.size) assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType) assertEquals(creator.id, snapshots.single().targetId) + assertEquals(snapshotAt, snapshots.single().snapshotAt) assertEquals(expectedScore, snapshots.single().score, 0.0001) } @@ -891,6 +894,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldFindCheerCreatorSnapshotsWithDbScoreOrderAndLimit() { 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 donor = saveMember("cheer-score-donor", MemberRole.USER) val oldCreator = saveMember("old-cheer-score-creator", MemberRole.CREATOR) val newCreator = saveMember("new-cheer-score-creator", MemberRole.CREATOR) @@ -916,17 +920,18 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( ) flushAndClear() - val snapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 1) + val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 1) val expectedScore = scorePolicy.calculateCheerScore( donationAmount = 2, fanTalkCount = 0, donationCount = 1, - newBoost = scorePolicy.calculateCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) + newBoost = scorePolicy.calculateCheerCreatorNewBoost(LocalDateTime.of(2026, 5, 20, 0, 0), snapshotAt) ) assertEquals(1, snapshots.size) assertEquals(RecommendedSectionType.CHEER_CREATOR, snapshots.single().sectionType) assertEquals(newCreator.id, snapshots.single().targetId) + assertEquals(snapshotAt, snapshots.single().snapshotAt) assertEquals(expectedScore, snapshots.single().score, 0.0001) } @@ -1046,6 +1051,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldExcludeCreatorSnapshotsWithoutActualDebutAt() { 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("creator-without-debut", MemberRole.CREATOR) val donor = saveMember("donor-without-debut", MemberRole.USER) val community = saveCommunity(creator, isCommentAvailable = true) @@ -1063,7 +1069,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) flushAndClear() - val cheerSnapshots = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10) + val cheerSnapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10) val communitySnapshots = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10) assertEquals(emptyList(), cheerSnapshots) @@ -1075,6 +1081,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldUseActualDebutAtInsteadOfMemberCreatedAtForCreatorSnapshots() { 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 memberCreatedAt = LocalDateTime.of(2026, 1, 1, 0, 0) val firstLiveAt = LocalDateTime.of(2026, 5, 10, 12, 0) val firstContentAt = LocalDateTime.of(2026, 5, 20, 12, 0) @@ -1098,14 +1105,14 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) flushAndClear() - val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10).single() + val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10).single() val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single() val expectedCheerScore = scorePolicy.calculateCheerScore( donationAmount = 100, fanTalkCount = 0, donationCount = 1, - newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt) + newBoost = scorePolicy.calculateCheerCreatorNewBoost(firstLiveAt, snapshotAt) ) val expectedCommunityScore = scorePolicy.calculateCommunityScore( likeCount = 1, @@ -1114,6 +1121,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( newBoost = scorePolicy.calculateCreatorNewBoost(firstLiveAt, snapshotAt) ) assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) + assertEquals(snapshotAt, cheerSnapshot.snapshotAt) assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001) } @@ -1122,6 +1130,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( fun shouldExcludeBlankChannelNameLiveFromSnapshotDebutAt() { 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 blankLiveAt = LocalDateTime.of(2026, 5, 1, 12, 0) val contentDebutAt = LocalDateTime.of(2026, 5, 20, 12, 0) val creator = saveMember("blank-live-debut-creator", MemberRole.CREATOR) @@ -1143,14 +1152,14 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( updateCreatedAt("CreatorCommunityLike", like.id!!, windowStart.plusDays(1)) flushAndClear() - val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, snapshotAt, limit = 10).single() + val cheerSnapshot = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 10).single() val communitySnapshot = repository.findPopularCommunitySnapshots(windowStart, snapshotAt, limit = 10).single() val expectedCheerScore = scorePolicy.calculateCheerScore( donationAmount = 100, fanTalkCount = 0, donationCount = 1, - newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt) + newBoost = scorePolicy.calculateCheerCreatorNewBoost(contentDebutAt, snapshotAt) ) val expectedCommunityScore = scorePolicy.calculateCommunityScore( likeCount = 1, @@ -1159,6 +1168,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( newBoost = scorePolicy.calculateCreatorNewBoost(contentDebutAt, snapshotAt) ) assertEquals(expectedCheerScore, cheerSnapshot.score, 0.0001) + assertEquals(snapshotAt, cheerSnapshot.snapshotAt) assertEquals(expectedCommunityScore, communitySnapshot.score, 0.0001) } @@ -2194,7 +2204,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( status: UseCanCalculateStatus, isRefund: Boolean, createdAt: LocalDateTime - ) { + ): UseCanCalculate { val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0, isRefund = isRefund) useCan.member = donor entityManager.persist(useCan) @@ -2204,6 +2214,26 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor( entityManager.persist(calculate) entityManager.flush() updateCreatedAt("UseCanCalculate", calculate.id!!, createdAt) + return calculate + } + + private fun saveUseCanCalculateForUseCan( + useCan: UseCan, + creator: Member, + can: Int, + createdAt: LocalDateTime + ): UseCanCalculate { + val calculate = UseCanCalculate( + can = can, + paymentGateway = PaymentGateway.PG, + status = UseCanCalculateStatus.RECEIVED + ) + calculate.useCan = useCan + calculate.recipientCreatorId = creator.id + entityManager.persist(calculate) + entityManager.flush() + updateCreatedAt("UseCanCalculate", calculate.id!!, createdAt) + return calculate } private fun saveCreatorCheers(member: Member, creator: Member, isActive: Boolean): CreatorCheers {