feat(home): 크리에이터 랭킹 점수 정책을 변경한다
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user