From e4706d6699b15d396b56dbbc99e94d4ebd9afe71 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 24 Jun 2026 12:37:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=A0=90=EC=88=98=20=EC=A0=95=EC=B1=85=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ranking/domain/AudioRankingScorePolicy.kt | 131 ++++++++++++++ .../domain/AudioRankingScorePolicyTest.kt | 171 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt new file mode 100644 index 00000000..2b57fc5f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicy.kt @@ -0,0 +1,131 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.domain + +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit +import kotlin.math.max + +class AudioRankingScorePolicy { + fun calculateWeeklyPopularScore( + revenue: Long, + salesCount: Long, + viewCount: Long, + likeCount: Long, + commentCount: Long, + isPaid: Boolean + ): Double { + return if (isPaid) { + revenue * WEEKLY_PAID_REVENUE_WEIGHT + + salesCount * WEEKLY_PAID_SALES_COUNT_WEIGHT + + likeCount * WEEKLY_PAID_LIKE_COUNT_WEIGHT + + commentCount * WEEKLY_PAID_COMMENT_COUNT_WEIGHT + } else { + viewCount * WEEKLY_FREE_VIEW_COUNT_WEIGHT + + likeCount * WEEKLY_FREE_LIKE_COUNT_WEIGHT + + commentCount * WEEKLY_FREE_COMMENT_COUNT_WEIGHT + } + } + + fun normalizeScore(currentScore: Double, maxScore: Double): Double { + if (maxScore <= 0.0) { + return 0.0 + } + + return currentScore / maxScore * 100.0 + } + + fun calculateRisingScore( + recentSalesCount: Long, + previousSalesCount: Long, + recentViewCount: Long, + previousViewCount: Long, + recentLikeCount: Long, + previousLikeCount: Long, + recentCommentCount: Long, + previousCommentCount: Long, + releaseDate: LocalDateTime, + aggregationEndAt: LocalDateTime, + isPaid: Boolean + ): Double { + val salesGrowth = applyMinimumThreshold( + growthRate(recentSalesCount, previousSalesCount), + recentSalesCount, + RISING_SALES_COUNT_THRESHOLD + ) + val viewGrowth = applyMinimumThreshold( + growthRate(recentViewCount, previousViewCount), + recentViewCount, + RISING_VIEW_COUNT_THRESHOLD + ) + val likeGrowth = applyMinimumThreshold( + growthRate(recentLikeCount, previousLikeCount), + recentLikeCount, + RISING_LIKE_COUNT_THRESHOLD + ) + val commentGrowth = applyMinimumThreshold( + growthRate(recentCommentCount, previousCommentCount), + recentCommentCount, + RISING_COMMENT_COUNT_THRESHOLD + ) + val contentGrowthScore = if (isPaid) { + salesGrowth * RISING_PAID_SALES_GROWTH_WEIGHT + + viewGrowth * RISING_PAID_VIEW_GROWTH_WEIGHT + } else { + viewGrowth * RISING_FREE_VIEW_GROWTH_WEIGHT + + likeGrowth * RISING_FREE_LIKE_GROWTH_WEIGHT + + commentGrowth * RISING_FREE_COMMENT_GROWTH_WEIGHT + } + + return ( + contentGrowthScore * RISING_CONTENT_GROWTH_SCORE_WEIGHT + + likeGrowth * RISING_LIKE_GROWTH_WEIGHT + + commentGrowth * RISING_COMMENT_GROWTH_WEIGHT + ) * releaseBoost(releaseDate, aggregationEndAt) + } + + fun applyMinimumThreshold(growthRate: Double, recentCount: Long, minimumThreshold: Long): Double { + return if (recentCount < minimumThreshold) 0.0 else growthRate + } + + fun releaseBoost(releaseDate: LocalDateTime, aggregationEndAt: LocalDateTime): Double { + val days = ChronoUnit.DAYS.between(releaseDate, aggregationEndAt).coerceAtLeast(0) + return when { + days <= 3 -> RELEASE_BOOST_WITHIN_THREE_DAYS + days <= 7 -> RELEASE_BOOST_WITHIN_SEVEN_DAYS + days <= 14 -> RELEASE_BOOST_WITHIN_FOURTEEN_DAYS + else -> RELEASE_BOOST_DEFAULT + } + } + + private fun growthRate(recentCount: Long, previousCount: Long): Double { + return (recentCount - previousCount).toDouble() / max(previousCount, 1).toDouble() + } + + companion object { + const val WEEKLY_PAID_REVENUE_WEIGHT = 0.45 + const val WEEKLY_PAID_SALES_COUNT_WEIGHT = 0.35 + const val WEEKLY_PAID_LIKE_COUNT_WEIGHT = 0.1 + const val WEEKLY_PAID_COMMENT_COUNT_WEIGHT = 0.1 + const val WEEKLY_FREE_VIEW_COUNT_WEIGHT = 0.5 + const val WEEKLY_FREE_LIKE_COUNT_WEIGHT = 0.25 + const val WEEKLY_FREE_COMMENT_COUNT_WEIGHT = 0.25 + + const val RISING_CONTENT_GROWTH_SCORE_WEIGHT = 0.5 + const val RISING_LIKE_GROWTH_WEIGHT = 0.25 + const val RISING_COMMENT_GROWTH_WEIGHT = 0.25 + const val RISING_PAID_SALES_GROWTH_WEIGHT = 0.6 + const val RISING_PAID_VIEW_GROWTH_WEIGHT = 0.4 + const val RISING_FREE_VIEW_GROWTH_WEIGHT = 0.5 + const val RISING_FREE_LIKE_GROWTH_WEIGHT = 0.25 + const val RISING_FREE_COMMENT_GROWTH_WEIGHT = 0.25 + + const val RISING_VIEW_COUNT_THRESHOLD = 10L + const val RISING_LIKE_COUNT_THRESHOLD = 3L + const val RISING_COMMENT_COUNT_THRESHOLD = 3L + const val RISING_SALES_COUNT_THRESHOLD = 3L + + const val RELEASE_BOOST_WITHIN_THREE_DAYS = 1.5 + const val RELEASE_BOOST_WITHIN_SEVEN_DAYS = 1.3 + const val RELEASE_BOOST_WITHIN_FOURTEEN_DAYS = 1.15 + const val RELEASE_BOOST_DEFAULT = 1.0 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt new file mode 100644 index 00000000..4fc6a8d3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingScorePolicyTest.kt @@ -0,0 +1,171 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class AudioRankingScorePolicyTest { + private val policy = AudioRankingScorePolicy() + private val aggregationEndAt = LocalDateTime.of(2026, 6, 22, 0, 0) + + @Test + @DisplayName("유료 주간 인기 원점수는 매출 45%, 판매량 35%, 좋아요 10%, 댓글 10%로 계산한다") + fun shouldCalculatePaidWeeklyPopularRawScore() { + assertEquals(0.45, AudioRankingScorePolicy.WEEKLY_PAID_REVENUE_WEIGHT, 0.0001) + assertEquals(0.35, AudioRankingScorePolicy.WEEKLY_PAID_SALES_COUNT_WEIGHT, 0.0001) + assertEquals(0.1, AudioRankingScorePolicy.WEEKLY_PAID_LIKE_COUNT_WEIGHT, 0.0001) + assertEquals(0.1, AudioRankingScorePolicy.WEEKLY_PAID_COMMENT_COUNT_WEIGHT, 0.0001) + + val score = policy.calculateWeeklyPopularScore( + revenue = 1000, + salesCount = 100, + viewCount = 0, + likeCount = 30, + commentCount = 20, + isPaid = true + ) + + assertEquals(490.0, score, 0.0001) + } + + @Test + @DisplayName("무료 주간 인기 원점수는 조회수 50%, 좋아요 25%, 댓글 25%로 계산한다") + fun shouldCalculateFreeWeeklyPopularRawScore() { + assertEquals(0.5, AudioRankingScorePolicy.WEEKLY_FREE_VIEW_COUNT_WEIGHT, 0.0001) + assertEquals(0.25, AudioRankingScorePolicy.WEEKLY_FREE_LIKE_COUNT_WEIGHT, 0.0001) + assertEquals(0.25, AudioRankingScorePolicy.WEEKLY_FREE_COMMENT_COUNT_WEIGHT, 0.0001) + + val score = policy.calculateWeeklyPopularScore( + revenue = 0, + salesCount = 0, + viewCount = 200, + likeCount = 20, + commentCount = 8, + isPaid = false + ) + + assertEquals(107.0, score, 0.0001) + } + + @Test + @DisplayName("정규화 점수는 그룹 최고 점수 기준 0~100으로 계산하고 최고 점수가 0 이하면 0으로 처리한다") + fun shouldNormalizeScoreToZeroToOneHundred() { + assertEquals(100.0, policy.normalizeScore(currentScore = 80.0, maxScore = 80.0), 0.0001) + assertEquals(25.0, policy.normalizeScore(currentScore = 20.0, maxScore = 80.0), 0.0001) + assertEquals(0.0, policy.normalizeScore(currentScore = 20.0, maxScore = 0.0), 0.0001) + assertEquals(0.0, policy.normalizeScore(currentScore = -20.0, maxScore = -10.0), 0.0001) + } + + @Test + @DisplayName("유료 지금 뜨는 중 점수는 판매/조회 콘텐츠 성장 점수와 좋아요/댓글 증가율 및 신규 부스트로 계산한다") + fun shouldCalculatePaidRisingScore() { + assertEquals(0.5, AudioRankingScorePolicy.RISING_CONTENT_GROWTH_SCORE_WEIGHT, 0.0001) + assertEquals(0.25, AudioRankingScorePolicy.RISING_LIKE_GROWTH_WEIGHT, 0.0001) + assertEquals(0.25, AudioRankingScorePolicy.RISING_COMMENT_GROWTH_WEIGHT, 0.0001) + assertEquals(0.6, AudioRankingScorePolicy.RISING_PAID_SALES_GROWTH_WEIGHT, 0.0001) + assertEquals(0.4, AudioRankingScorePolicy.RISING_PAID_VIEW_GROWTH_WEIGHT, 0.0001) + + val score = policy.calculateRisingScore( + recentSalesCount = 9, + previousSalesCount = 3, + recentViewCount = 30, + previousViewCount = 10, + recentLikeCount = 8, + previousLikeCount = 4, + recentCommentCount = 6, + previousCommentCount = 3, + releaseDate = aggregationEndAt.minusDays(2), + aggregationEndAt = aggregationEndAt, + isPaid = true + ) + + assertEquals(2.25, score, 0.0001) + } + + @Test + @DisplayName("무료 지금 뜨는 중 점수는 조회/좋아요/댓글 콘텐츠 성장 점수와 좋아요/댓글 증가율 및 신규 부스트로 계산한다") + fun shouldCalculateFreeRisingScore() { + assertEquals(0.5, AudioRankingScorePolicy.RISING_FREE_VIEW_GROWTH_WEIGHT, 0.0001) + assertEquals(0.25, AudioRankingScorePolicy.RISING_FREE_LIKE_GROWTH_WEIGHT, 0.0001) + assertEquals(0.25, AudioRankingScorePolicy.RISING_FREE_COMMENT_GROWTH_WEIGHT, 0.0001) + + val score = policy.calculateRisingScore( + recentSalesCount = 0, + previousSalesCount = 0, + recentViewCount = 30, + previousViewCount = 10, + recentLikeCount = 8, + previousLikeCount = 4, + recentCommentCount = 6, + previousCommentCount = 3, + releaseDate = aggregationEndAt.minusDays(8), + aggregationEndAt = aggregationEndAt, + isPaid = false + ) + + assertEquals(1.4375, score, 0.0001) + } + + @Test + @DisplayName("최소 기준 미만 지표만 증가율 반영값을 0으로 처리한다") + fun shouldApplyMinimumThresholdPerMetric() { + assertEquals(10, AudioRankingScorePolicy.RISING_VIEW_COUNT_THRESHOLD) + assertEquals(3, AudioRankingScorePolicy.RISING_LIKE_COUNT_THRESHOLD) + assertEquals(3, AudioRankingScorePolicy.RISING_COMMENT_COUNT_THRESHOLD) + assertEquals(3, AudioRankingScorePolicy.RISING_SALES_COUNT_THRESHOLD) + + assertEquals(0.0, policy.applyMinimumThreshold(growthRate = 5.0, recentCount = 9, minimumThreshold = 10), 0.0001) + assertEquals(5.0, policy.applyMinimumThreshold(growthRate = 5.0, recentCount = 10, minimumThreshold = 10), 0.0001) + + val score = policy.calculateRisingScore( + recentSalesCount = 2, + previousSalesCount = 1, + recentViewCount = 9, + previousViewCount = 1, + recentLikeCount = 2, + previousLikeCount = 1, + recentCommentCount = 3, + previousCommentCount = 1, + releaseDate = aggregationEndAt.minusDays(20), + aggregationEndAt = aggregationEndAt, + isPaid = true + ) + + assertEquals(0.5, score, 0.0001) + } + + @Test + @DisplayName("최소 기준을 통과한 지표의 음수 증가율은 지금 뜨는 중 점수에 그대로 반영한다") + fun shouldPreserveNegativeGrowthWhenThresholdPasses() { + val score = policy.calculateRisingScore( + recentSalesCount = 3, + previousSalesCount = 6, + recentViewCount = 10, + previousViewCount = 20, + recentLikeCount = 3, + previousLikeCount = 6, + recentCommentCount = 3, + previousCommentCount = 6, + releaseDate = aggregationEndAt.minusDays(20), + aggregationEndAt = aggregationEndAt, + isPaid = true + ) + + assertEquals(-0.5, score, 0.0001) + } + + @Test + @DisplayName("신규 콘텐츠 부스트는 집계 종료일 기준 3일/7일/14일 경계를 포함해 적용한다") + fun shouldReturnReleaseBoostByBoundaries() { + assertEquals(1.5, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_THREE_DAYS, 0.0001) + assertEquals(1.3, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_SEVEN_DAYS, 0.0001) + assertEquals(1.15, AudioRankingScorePolicy.RELEASE_BOOST_WITHIN_FOURTEEN_DAYS, 0.0001) + assertEquals(1.0, AudioRankingScorePolicy.RELEASE_BOOST_DEFAULT, 0.0001) + + assertEquals(1.5, policy.releaseBoost(aggregationEndAt.minusDays(3), aggregationEndAt), 0.0001) + assertEquals(1.3, policy.releaseBoost(aggregationEndAt.minusDays(7), aggregationEndAt), 0.0001) + assertEquals(1.15, policy.releaseBoost(aggregationEndAt.minusDays(14), aggregationEndAt), 0.0001) + assertEquals(1.0, policy.releaseBoost(aggregationEndAt.minusDays(15), aggregationEndAt), 0.0001) + } +}