From c7b6d0d7c5c9589d401a45f8c4dd9bfc8e53ede7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 14:42:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=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=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 --- .../domain/CreatorRankingScoreDetail.kt | 42 +++++++ .../domain/CreatorRankingScorePolicy.kt | 51 ++------ .../ranking/domain/CreatorRankingScoreSpec.kt | 23 ++-- .../domain/CreatorRankingScorePolicyTest.kt | 119 +++++++++--------- 4 files changed, 124 insertions(+), 111 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreDetail.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreDetail.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreDetail.kt new file mode 100644 index 00000000..75dce7fd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingScoreDetail.kt @@ -0,0 +1,42 @@ +package kr.co.vividnext.sodalive.v2.ranking.domain + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import java.time.LocalDateTime + +data class CreatorRankingScoreDetail( + val scorePolicyVersion: String = CreatorRankingScoreSpec.SCORE_POLICY_VERSION, + val formula: String = CreatorRankingScoreSpec.FORMULA, + val metrics: Metrics, + val tieBreakers: TieBreakers +) { + fun toJson(): String = objectMapper.writeValueAsString(this) + + data class Metrics( + val liveRevenue: Metric, + val contentRevenue: Metric, + val followIncrease: Metric, + val aiChat: Metric + ) + + data class Metric( + val rawValue: Long, + val rank: Int, + val rankScore: Double, + val weight: Double, + val weightedScore: Double + ) + + data class TieBreakers( + val creatorDebutAt: LocalDateTime?, + val creatorId: Long + ) + + companion object { + private val objectMapper: ObjectMapper = jacksonObjectMapper() + .registerModule(JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + } +} 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 index 3b9803b6..55d3bf21 100644 --- 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 @@ -1,49 +1,24 @@ 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 calculateRankScore(rawValue: Long, rank: Int): Double { + if (rawValue <= 0L) return 0.0 + return (100 - (5 * (rank - 1))).coerceAtLeast(0).toDouble() } - 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 normalizeFollowIncrease(value: Long): Long { + return value.coerceAtLeast(0L) } fun calculateFinalScore( - contentLiveScore: Double, - engagementScore: Double, - supportScore: Double, - fanLoyaltyScore: Double + liveRevenueRankScore: Double, + contentRevenueRankScore: Double, + followIncreaseRankScore: Double, + aiChatRankScore: 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) + return (liveRevenueRankScore * CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT) + + (contentRevenueRankScore * CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT) + + (followIncreaseRankScore * CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT) + + (aiChatRankScore * CreatorRankingScoreSpec.AI_CHAT_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 index 6e7a4997..97a99ccf 100644 --- 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 @@ -1,21 +1,12 @@ 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 SCORE_POLICY_VERSION = "CREATOR_WEEKLY_POPULARITY_V2" + const val FORMULA = "liveRevenueRankScore * 0.6 + contentRevenueRankScore * 0.2 + " + + "followIncreaseRankScore * 0.1 + aiChatRankScore * 0.1" - 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 + const val LIVE_REVENUE_WEIGHT = 0.6 + const val CONTENT_REVENUE_WEIGHT = 0.2 + const val FOLLOW_INCREASE_WEIGHT = 0.1 + const val AI_CHAT_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 index e68e8679..ad31ca2b 100644 --- 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 @@ -1,76 +1,81 @@ package kr.co.vividnext.sodalive.v2.ranking.domain +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import java.time.LocalDateTime 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) + @DisplayName("순위 점수는 rank 1/2/20/21 경계와 raw value 0 강제 0점을 따른다") + fun shouldCalculateRankScoreBoundaries() { + assertEquals(100.0, policy.calculateRankScore(rawValue = 10, rank = 1), 0.0001) + assertEquals(95.0, policy.calculateRankScore(rawValue = 10, rank = 2), 0.0001) + assertEquals(5.0, policy.calculateRankScore(rawValue = 10, rank = 20), 0.0001) + assertEquals(0.0, policy.calculateRankScore(rawValue = 10, rank = 21), 0.0001) + assertEquals(0.0, policy.calculateRankScore(rawValue = 0, rank = 1), 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) + @DisplayName("최종 점수는 라이브 60%, 콘텐츠 20%, 팔로우 10%, AI 채팅 10% 가중합이다") + fun shouldCalculateWeightedFinalScore() { + assertEquals(0.6, CreatorRankingScoreSpec.LIVE_REVENUE_WEIGHT, 0.0001) + assertEquals(0.2, CreatorRankingScoreSpec.CONTENT_REVENUE_WEIGHT, 0.0001) + assertEquals(0.1, CreatorRankingScoreSpec.FOLLOW_INCREASE_WEIGHT, 0.0001) + assertEquals(0.1, CreatorRankingScoreSpec.AI_CHAT_WEIGHT, 0.0001) val score = policy.calculateFinalScore( - contentLiveScore = 760.0, - engagementScore = 30.0, - supportScore = 606.0, - fanLoyaltyScore = 67.0 + liveRevenueRankScore = 100.0, + contentRevenueRankScore = 95.0, + followIncreaseRankScore = 90.0, + aiChatRankScore = 85.0 ) - assertEquals(433.2, score, 0.0001) + assertEquals(96.5, score, 0.0001) + } + + @Test + @DisplayName("팔로우 증가는 음수이면 0으로 정규화한다") + fun shouldNormalizeNegativeFollowIncrease() { + assertEquals(0L, policy.normalizeFollowIncrease(-5)) + assertEquals(3L, policy.normalizeFollowIncrease(3)) + } + + @Test + @DisplayName("score detail JSON은 정책 버전, 산식, metric 상세, tie-breaker key를 고정한다") + fun shouldSerializeScoreDetailContract() { + val detail = CreatorRankingScoreDetail( + metrics = CreatorRankingScoreDetail.Metrics( + liveRevenue = metric(100, 1, 100.0, 0.6), + contentRevenue = metric(80, 2, 95.0, 0.2), + followIncrease = metric(3, 3, 90.0, 0.1), + aiChat = metric(5, 4, 85.0, 0.1) + ), + tieBreakers = CreatorRankingScoreDetail.TieBreakers( + creatorDebutAt = LocalDateTime.of(2026, 6, 1, 0, 0), + creatorId = 123L + ) + ) + + val json: JsonNode = jacksonObjectMapper().readTree(detail.toJson()) + + assertEquals("CREATOR_WEEKLY_POPULARITY_V2", json["scorePolicyVersion"].asText()) + assertTrue(json.has("formula")) + assertEquals(100, json["metrics"]["liveRevenue"]["rawValue"].asLong()) + assertEquals(1, json["metrics"]["liveRevenue"]["rank"].asInt()) + assertEquals(100.0, json["metrics"]["liveRevenue"]["rankScore"].asDouble(), 0.0001) + assertEquals(0.6, json["metrics"]["liveRevenue"]["weight"].asDouble(), 0.0001) + assertEquals(60.0, json["metrics"]["liveRevenue"]["weightedScore"].asDouble(), 0.0001) + assertEquals(123L, json["tieBreakers"]["creatorId"].asLong()) + assertEquals("2026-06-01T00:00:00", json["tieBreakers"]["creatorDebutAt"].asText()) + } + + private fun metric(rawValue: Long, rank: Int, rankScore: Double, weight: Double): CreatorRankingScoreDetail.Metric { + return CreatorRankingScoreDetail.Metric(rawValue, rank, rankScore, weight, rankScore * weight) } }