From 49f2238b37897ba3dc7211e929e40028b89ed03d Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 8 Jun 2026 15:24:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking):=20=EB=9E=AD=ED=82=B9=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/CreatorRankingSnapshot.kt | 68 +++++ .../CreatorRankingSnapshotRepository.kt | 50 ++++ ...DefaultCreatorRankingSnapshotRepository.kt | 91 +++++++ .../port/out/CreatorRankingSnapshotPort.kt | 42 ++++ ...ultCreatorRankingSnapshotRepositoryTest.kt | 232 ++++++++++++++++++ 5 files changed, 483 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt new file mode 100644 index 00000000..c94c370f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import java.time.LocalDateTime +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Table + +@Entity +@Table(name = "creator_ranking_snapshot") +class CreatorRankingSnapshot( + @Column(name = "aggregation_start_at_utc", nullable = false, updatable = false) + val aggregationStartAtUtc: LocalDateTime, + + @Column(name = "aggregation_end_at_utc", nullable = false, updatable = false) + val aggregationEndAtUtc: LocalDateTime, + + @Column(name = "creator_id", nullable = false, updatable = false) + val creatorId: Long, + + @Column(name = "nickname", nullable = false, updatable = false, length = 100) + val nickname: String, + + @Column(name = "profile_image_url", updatable = false, length = 500) + val profileImageUrl: String?, + + @Column(name = "final_score", nullable = false, updatable = false) + val finalScore: Double, + + @Column(name = "content_live_score", nullable = false, updatable = false) + val contentLiveScore: Double, + + @Column(name = "engagement_score", nullable = false, updatable = false) + val engagementScore: Double, + + @Column(name = "support_score", nullable = false, updatable = false) + val supportScore: Double, + + @Column(name = "fan_loyalty_score", nullable = false, updatable = false) + val fanLoyaltyScore: Double, + + @Column(name = "live_can_amount", nullable = false, updatable = false) + val liveCanAmount: Long, + + @Column(name = "content_purchase_can_amount", nullable = false, updatable = false) + val contentPurchaseCanAmount: Long, + + @Column(name = "content_like_count", nullable = false, updatable = false) + val contentLikeCount: Long, + + @Column(name = "content_comment_count", nullable = false, updatable = false) + val contentCommentCount: Long, + + @Column(name = "channel_donation_can_amount", nullable = false, updatable = false) + val channelDonationCanAmount: Long, + + @Column(name = "channel_donation_count", nullable = false, updatable = false) + val channelDonationCount: Long, + + @Column(name = "fan_talk_count", nullable = false, updatable = false) + val fanTalkCount: Long, + + @Column(name = "final_follower_count", nullable = false, updatable = false) + val finalFollowerCount: Long, + + @Column(name = "follow_increase", nullable = false, updatable = false) + val followIncrease: Long +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt new file mode 100644 index 00000000..ac3f78a9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt @@ -0,0 +1,50 @@ +package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDateTime + +interface CreatorRankingSnapshotRepository : JpaRepository { + fun findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByFinalScoreDesc( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime + ): List + + @Query( + value = """ + select * + from creator_ranking_snapshot crs + where crs.aggregation_end_at_utc = ( + select max(latest.aggregation_end_at_utc) + from creator_ranking_snapshot latest + ) + order by crs.final_score desc + """, + nativeQuery = true + ) + fun findLatestSnapshots(): List + + @Query( + value = """ + select * + from creator_ranking_snapshot crs + where crs.aggregation_end_at_utc = ( + select max(previous.aggregation_end_at_utc) + from creator_ranking_snapshot previous + where previous.aggregation_end_at_utc < ( + select max(latest.aggregation_end_at_utc) + from creator_ranking_snapshot latest + ) + ) + order by crs.final_score desc + """, + nativeQuery = true + ) + fun findPreviousCompletedSnapshots(): List + + fun deleteByAggregationStartAtUtcAndAggregationEndAtUtc( + @Param("aggregationStartAtUtc") aggregationStartAtUtc: LocalDateTime, + @Param("aggregationEndAtUtc") aggregationEndAtUtc: LocalDateTime + ) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt new file mode 100644 index 00000000..5acccb76 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt @@ -0,0 +1,91 @@ +package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Repository +class DefaultCreatorRankingSnapshotRepository( + private val repository: CreatorRankingSnapshotRepository +) : CreatorRankingSnapshotPort { + override fun findSnapshotsByAggregationPeriod( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime + ): List { + return repository.findAllByAggregationStartAtUtcAndAggregationEndAtUtcOrderByFinalScoreDesc( + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc + ).map { it.toRecord() } + } + + override fun findLatestSnapshots(): List { + return repository.findLatestSnapshots().map { it.toRecord() } + } + + override fun findPreviousCompletedSnapshots(): List { + return repository.findPreviousCompletedSnapshots().map { it.toRecord() } + } + + @Transactional + override fun replaceSnapshots( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + newSnapshots: List + ) { + repository.deleteByAggregationStartAtUtcAndAggregationEndAtUtc( + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc + ) + repository.saveAll(newSnapshots.map { it.toEntity() }) + } + + private fun CreatorRankingSnapshot.toRecord(): CreatorRankingSnapshotRecord { + return CreatorRankingSnapshotRecord( + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl, + finalScore = finalScore, + contentLiveScore = contentLiveScore, + engagementScore = engagementScore, + supportScore = supportScore, + fanLoyaltyScore = fanLoyaltyScore, + liveCanAmount = liveCanAmount, + contentPurchaseCanAmount = contentPurchaseCanAmount, + contentLikeCount = contentLikeCount, + contentCommentCount = contentCommentCount, + channelDonationCanAmount = channelDonationCanAmount, + channelDonationCount = channelDonationCount, + fanTalkCount = fanTalkCount, + finalFollowerCount = finalFollowerCount, + followIncrease = followIncrease + ) + } + + private fun CreatorRankingSnapshotRecord.toEntity(): CreatorRankingSnapshot { + return CreatorRankingSnapshot( + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + creatorId = creatorId, + nickname = nickname, + profileImageUrl = profileImageUrl, + finalScore = finalScore, + contentLiveScore = contentLiveScore, + engagementScore = engagementScore, + supportScore = supportScore, + fanLoyaltyScore = fanLoyaltyScore, + liveCanAmount = liveCanAmount, + contentPurchaseCanAmount = contentPurchaseCanAmount, + contentLikeCount = contentLikeCount, + contentCommentCount = contentCommentCount, + channelDonationCanAmount = channelDonationCanAmount, + channelDonationCount = channelDonationCount, + fanTalkCount = fanTalkCount, + finalFollowerCount = finalFollowerCount, + followIncrease = followIncrease + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt new file mode 100644 index 00000000..dc57ad9b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt @@ -0,0 +1,42 @@ +package kr.co.vividnext.sodalive.v2.ranking.port.out + +import java.time.LocalDateTime + +interface CreatorRankingSnapshotPort { + fun findSnapshotsByAggregationPeriod( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime + ): List + + fun findLatestSnapshots(): List + + fun findPreviousCompletedSnapshots(): List + + fun replaceSnapshots( + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime, + newSnapshots: List + ) +} + +data class CreatorRankingSnapshotRecord( + val aggregationStartAtUtc: LocalDateTime, + val aggregationEndAtUtc: LocalDateTime, + val creatorId: Long, + val nickname: String, + val profileImageUrl: String?, + val finalScore: Double, + val contentLiveScore: Double, + val engagementScore: Double, + val supportScore: Double, + val fanLoyaltyScore: Double, + val liveCanAmount: Long, + val contentPurchaseCanAmount: Long, + val contentLikeCount: Long, + val contentCommentCount: Long, + val channelDonationCanAmount: Long, + val channelDonationCount: Long, + val fanTalkCount: Long, + val finalFollowerCount: Long, + val followIncrease: Long +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt new file mode 100644 index 00000000..55d7df75 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt @@ -0,0 +1,232 @@ +package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.context.annotation.Import +import java.time.LocalDateTime + +@DataJpaTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" + ] +) +@Import(QueryDslConfig::class) +class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( + private val repository: CreatorRankingSnapshotRepository +) { + private val adapter = DefaultCreatorRankingSnapshotRepository(repository) + + @Test + @DisplayName("같은 집계 기간의 스냅샷은 삭제 후 새 후보로 교체한다") + fun shouldReplaceSnapshotsByAggregationPeriod() { + val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) + val oldStartAt = startAt.minusWeeks(1) + val oldEndAt = endAt.minusWeeks(1) + repository.saveAll( + listOf( + snapshot(creatorId = 1L, aggregationStartAtUtc = oldStartAt, aggregationEndAtUtc = oldEndAt), + snapshot(creatorId = 2L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt) + ) + ) + + adapter.replaceSnapshots( + aggregationStartAtUtc = startAt, + aggregationEndAtUtc = endAt, + newSnapshots = listOf(snapshotRecord(creatorId = 3L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)) + ) + + val allSnapshots = repository.findAll() + assertEquals(listOf(1L, 3L), allSnapshots.map { it.creatorId }.sorted()) + assertEquals(listOf(3L), adapter.findLatestSnapshots().map { it.creatorId }) + } + + @Test + @DisplayName("최신 완료 주차 스냅샷은 최신 종료 시각 기준으로 최종 점수 내림차순 조회한다") + fun shouldFindLatestSnapshotsByLatestAggregationEndAndFinalScoreDescending() { + val oldStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) + val oldEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) + repository.saveAll( + listOf( + snapshot( + creatorId = 1L, + finalScore = 999.0, + aggregationStartAtUtc = oldStartAt, + aggregationEndAtUtc = oldEndAt + ), + snapshot( + creatorId = 2L, + finalScore = 100.0, + aggregationStartAtUtc = latestStartAt, + aggregationEndAtUtc = latestEndAt + ), + snapshot( + creatorId = 3L, + finalScore = 300.0, + aggregationStartAtUtc = latestStartAt, + aggregationEndAtUtc = latestEndAt + ), + snapshot( + creatorId = 4L, + finalScore = 200.0, + aggregationStartAtUtc = latestStartAt, + aggregationEndAtUtc = latestEndAt + ) + ) + ) + + val latestSnapshots = adapter.findLatestSnapshots() + + assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.creatorId }) + assertEquals(listOf(latestStartAt, latestStartAt, latestStartAt), latestSnapshots.map { it.aggregationStartAtUtc }) + assertEquals(listOf(latestEndAt, latestEndAt, latestEndAt), latestSnapshots.map { it.aggregationEndAtUtc }) + } + + @Test + @DisplayName("직전 완료 주차 스냅샷은 최신 종료 시각보다 이전인 가장 큰 종료 시각 기준으로 조회한다") + fun shouldFindPreviousCompletedSnapshotsBeforeLatestPeriod() { + val oldestStartAt = LocalDateTime.of(2026, 5, 17, 15, 0) + val oldestEndAt = LocalDateTime.of(2026, 5, 24, 15, 0) + val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) + val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) + repository.saveAll( + listOf( + snapshot(creatorId = 1L, aggregationStartAtUtc = oldestStartAt, aggregationEndAtUtc = oldestEndAt), + snapshot( + creatorId = 2L, + finalScore = 200.0, + aggregationStartAtUtc = previousStartAt, + aggregationEndAtUtc = previousEndAt + ), + snapshot( + creatorId = 3L, + finalScore = 300.0, + aggregationStartAtUtc = previousStartAt, + aggregationEndAtUtc = previousEndAt + ), + snapshot(creatorId = 4L, aggregationStartAtUtc = latestStartAt, aggregationEndAtUtc = latestEndAt) + ) + ) + + val previousSnapshots = adapter.findPreviousCompletedSnapshots() + + assertEquals(listOf(3L, 2L), previousSnapshots.map { it.creatorId }) + assertEquals(listOf(previousEndAt, previousEndAt), previousSnapshots.map { it.aggregationEndAtUtc }) + } + + @Test + @DisplayName("요청한 집계 기간에 스냅샷이 없으면 이전 주차를 대신 반환하지 않는다") + fun shouldReturnEmptyWhenRequestedAggregationPeriodHasNoSnapshots() { + val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0) + val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val requestedStartAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val requestedEndAt = LocalDateTime.of(2026, 6, 7, 15, 0) + repository.save( + snapshot( + creatorId = 1L, + aggregationStartAtUtc = previousStartAt, + aggregationEndAtUtc = previousEndAt + ) + ) + + val snapshots = adapter.findSnapshotsByAggregationPeriod( + aggregationStartAtUtc = requestedStartAt, + aggregationEndAtUtc = requestedEndAt + ) + + assertEquals(emptyList(), snapshots) + } + + @Test + @DisplayName("20위 점수 경계 동점 후보는 저장소에서 누락 없이 저장하고 조회할 수 있다") + fun shouldPersistAllCandidatesTiedAtTwentiethScoreBoundary() { + val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) + val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) + val candidates = (1L..19L).map { creatorId -> + snapshotRecord( + creatorId = creatorId, + finalScore = 1000.0 - creatorId, + aggregationStartAtUtc = startAt, + aggregationEndAtUtc = endAt + ) + } + listOf( + snapshotRecord(creatorId = 20L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt), + snapshotRecord(creatorId = 21L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt), + snapshotRecord(creatorId = 22L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt) + ) + + adapter.replaceSnapshots(startAt, endAt, candidates) + + val latestSnapshots = adapter.findLatestSnapshots() + assertEquals(22, latestSnapshots.size) + assertEquals(setOf(20L, 21L, 22L), latestSnapshots.takeLast(3).map { it.creatorId }.toSet()) + } + + private fun snapshot( + creatorId: Long, + finalScore: Double = 100.0, + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime + ): CreatorRankingSnapshot { + return CreatorRankingSnapshot( + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + creatorId = creatorId, + nickname = "creator-$creatorId", + profileImageUrl = "profile-$creatorId.png", + finalScore = finalScore, + contentLiveScore = 10.0, + engagementScore = 20.0, + supportScore = 30.0, + fanLoyaltyScore = 40.0, + liveCanAmount = 100, + contentPurchaseCanAmount = 200, + contentLikeCount = 3, + contentCommentCount = 4, + channelDonationCanAmount = 500, + channelDonationCount = 6, + fanTalkCount = 7, + finalFollowerCount = 8, + followIncrease = -1 + ) + } + + private fun snapshotRecord( + creatorId: Long, + finalScore: Double = 100.0, + aggregationStartAtUtc: LocalDateTime, + aggregationEndAtUtc: LocalDateTime + ): CreatorRankingSnapshotRecord { + return CreatorRankingSnapshotRecord( + aggregationStartAtUtc = aggregationStartAtUtc, + aggregationEndAtUtc = aggregationEndAtUtc, + creatorId = creatorId, + nickname = "creator-$creatorId", + profileImageUrl = "profile-$creatorId.png", + finalScore = finalScore, + contentLiveScore = 10.0, + engagementScore = 20.0, + supportScore = 30.0, + fanLoyaltyScore = 40.0, + liveCanAmount = 100, + contentPurchaseCanAmount = 200, + contentLikeCount = 3, + contentCommentCount = 4, + channelDonationCanAmount = 500, + channelDonationCount = 6, + fanTalkCount = 7, + finalFollowerCount = 8, + followIncrease = -1 + ) + } +}