From 6d6fa5830b829a19bceef57c51e93d0a7f8b00c3 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 8 Jun 2026 15:23:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(ranking):=20=ED=81=AC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=9E=AD=ED=82=B9=20=EC=A0=90=EC=88=98=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/CreatorRankingScorePolicy.kt | 49 ++++++++++++ .../ranking/domain/CreatorRankingScoreSpec.kt | 21 +++++ .../domain/CreatorRankingScorePolicyTest.kt | 76 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt new file mode 100644 index 00000000..3b9803b6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicy.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.v2.ranking.domain + +class CreatorRankingScorePolicy { + fun calculateContentLiveScore( + liveCanAmount: Long, + contentPurchaseCanAmount: Long + ): Double { + return (liveCanAmount * CreatorRankingScoreSpec.CONTENT_LIVE_CAN_WEIGHT) + + (contentPurchaseCanAmount * CreatorRankingScoreSpec.CONTENT_PURCHASE_CAN_WEIGHT) + } + + fun calculateEngagementScore( + contentLikeCount: Long, + contentCommentCount: Long + ): Double { + return (contentLikeCount * CreatorRankingScoreSpec.CONTENT_LIKE_COUNT_WEIGHT) + + (contentCommentCount * CreatorRankingScoreSpec.CONTENT_COMMENT_COUNT_WEIGHT) + } + + fun calculateSupportScore( + channelDonationCanAmount: Long, + channelDonationCount: Long, + fanTalkCount: Long + ): Double { + return (channelDonationCanAmount * CreatorRankingScoreSpec.CHANNEL_DONATION_CAN_WEIGHT) + + (channelDonationCount * CreatorRankingScoreSpec.CHANNEL_DONATION_COUNT_WEIGHT) + + (fanTalkCount * CreatorRankingScoreSpec.FAN_TALK_COUNT_WEIGHT) + } + + fun calculateFanLoyaltyScore( + finalFollowerCount: Long, + followIncrease: Long + ): Double { + return (finalFollowerCount * CreatorRankingScoreSpec.FINAL_FOLLOWER_COUNT_WEIGHT) + + (followIncrease * CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT) + } + + fun calculateFinalScore( + contentLiveScore: Double, + engagementScore: Double, + supportScore: Double, + fanLoyaltyScore: Double + ): Double { + return (contentLiveScore * CreatorRankingScoreSpec.CONTENT_LIVE_SCORE_WEIGHT) + + (engagementScore * CreatorRankingScoreSpec.ENGAGEMENT_SCORE_WEIGHT) + + (supportScore * CreatorRankingScoreSpec.SUPPORT_SCORE_WEIGHT) + + (fanLoyaltyScore * CreatorRankingScoreSpec.FAN_LOYALTY_SCORE_WEIGHT) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt new file mode 100644 index 00000000..6e7a4997 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreSpec.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.v2.ranking.domain + +object CreatorRankingScoreSpec { + const val CONTENT_LIVE_CAN_WEIGHT = 0.7 + const val CONTENT_PURCHASE_CAN_WEIGHT = 0.3 + + const val CONTENT_LIKE_COUNT_WEIGHT = 0.5 + const val CONTENT_COMMENT_COUNT_WEIGHT = 0.5 + + const val CHANNEL_DONATION_CAN_WEIGHT = 0.6 + const val CHANNEL_DONATION_COUNT_WEIGHT = 0.2 + const val FAN_TALK_COUNT_WEIGHT = 0.2 + + const val FINAL_FOLLOWER_COUNT_WEIGHT = 0.7 + const val FOLLOW_INCREASE_WEIGHT = 0.3 + + const val CONTENT_LIVE_SCORE_WEIGHT = 0.35 + const val ENGAGEMENT_SCORE_WEIGHT = 0.3 + const val SUPPORT_SCORE_WEIGHT = 0.25 + const val FAN_LOYALTY_SCORE_WEIGHT = 0.1 +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt new file mode 100644 index 00000000..e68e8679 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScorePolicyTest.kt @@ -0,0 +1,76 @@ +package kr.co.vividnext.sodalive.v2.ranking.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class CreatorRankingScorePolicyTest { + private val policy = CreatorRankingScorePolicy() + + @Test + @DisplayName("콘텐츠/라이브 점수는 라이브 계열 캔 70%와 콘텐츠 구매 캔 30%를 raw value로 계산한다") + fun shouldCalculateContentLiveScore() { + assertEquals(0.7, CreatorRankingScoreSpec.CONTENT_LIVE_CAN_WEIGHT, 0.0001) + assertEquals(0.3, CreatorRankingScoreSpec.CONTENT_PURCHASE_CAN_WEIGHT, 0.0001) + + val score = policy.calculateContentLiveScore(liveCanAmount = 1000, contentPurchaseCanAmount = 200) + + assertEquals(760.0, score, 0.0001) + } + + @Test + @DisplayName("참여 반응 점수는 좋아요 수와 댓글 수를 각각 50% raw value로 계산한다") + fun shouldCalculateEngagementScore() { + assertEquals(0.5, CreatorRankingScoreSpec.CONTENT_LIKE_COUNT_WEIGHT, 0.0001) + assertEquals(0.5, CreatorRankingScoreSpec.CONTENT_COMMENT_COUNT_WEIGHT, 0.0001) + + val score = policy.calculateEngagementScore(contentLikeCount = 40, contentCommentCount = 20) + + assertEquals(30.0, score, 0.0001) + } + + @Test + @DisplayName("응원 점수는 채널 후원 캔/건수와 팬 Talk 수를 raw value로 계산한다") + fun shouldCalculateSupportScore() { + assertEquals(0.6, CreatorRankingScoreSpec.CHANNEL_DONATION_CAN_WEIGHT, 0.0001) + assertEquals(0.2, CreatorRankingScoreSpec.CHANNEL_DONATION_COUNT_WEIGHT, 0.0001) + assertEquals(0.2, CreatorRankingScoreSpec.FAN_TALK_COUNT_WEIGHT, 0.0001) + + val score = policy.calculateSupportScore( + channelDonationCanAmount = 1000, + channelDonationCount = 10, + fanTalkCount = 20 + ) + + assertEquals(606.0, score, 0.0001) + } + + @Test + @DisplayName("팬 충성도 점수는 음수 팔로우 증가 수를 최종 점수에 그대로 반영한다") + fun shouldCalculateFanLoyaltyScoreWithNegativeFollowIncrease() { + assertEquals(0.7, CreatorRankingScoreSpec.FINAL_FOLLOWER_COUNT_WEIGHT, 0.0001) + assertEquals(0.3, CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT, 0.0001) + + val score = policy.calculateFanLoyaltyScore(finalFollowerCount = 100, followIncrease = -10) + + assertEquals(67.0, score, 0.0001) + } + + @Test + @DisplayName("최종 점수는 카테고리별 점수에 PRD 가중치를 적용하고 0~100 정규화하지 않는다") + fun shouldCalculateFinalScoreWithoutNormalization() { + assertEquals(0.35, CreatorRankingScoreSpec.CONTENT_LIVE_SCORE_WEIGHT, 0.0001) + assertEquals(0.3, CreatorRankingScoreSpec.ENGAGEMENT_SCORE_WEIGHT, 0.0001) + assertEquals(0.25, CreatorRankingScoreSpec.SUPPORT_SCORE_WEIGHT, 0.0001) + assertEquals(0.1, CreatorRankingScoreSpec.FAN_LOYALTY_SCORE_WEIGHT, 0.0001) + + val score = policy.calculateFinalScore( + contentLiveScore = 760.0, + engagementScore = 30.0, + supportScore = 606.0, + fanLoyaltyScore = 67.0 + ) + + assertEquals(433.2, score, 0.0001) + } +}