feat(home): 크리에이터 랭킹 후보 집계를 변경한다

This commit is contained in:
2026-07-10 14:42:34 +09:00
parent c7b6d0d7c5
commit e596cb0826
3 changed files with 317 additions and 315 deletions

View File

@@ -1,10 +1,10 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationResult
import org.springframework.stereotype.Repository
import java.sql.Timestamp
import java.time.LocalDateTime
import javax.persistence.EntityManager
@@ -12,8 +12,6 @@ import javax.persistence.EntityManager
class DefaultCreatorRankingAggregationRepository(
private val entityManager: EntityManager
) : CreatorRankingAggregationPort {
private val scorePolicy = CreatorRankingScorePolicy()
override fun aggregateCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
@@ -26,13 +24,9 @@ class DefaultCreatorRankingAggregationRepository(
endExclusiveUtc: LocalDateTime
): CreatorRankingAggregationResult {
val candidates = aggregateAllCandidates(startInclusiveUtc, endExclusiveUtc)
val includedCandidates = candidates
.filter { candidate -> candidate.finalScore >= MINIMUM_FINAL_SCORE }
.sortedWith(compareByDescending<CreatorRankingSnapshotCandidate> { it.finalScore }.thenBy { it.creatorId })
return CreatorRankingAggregationResult(
candidates = includedCandidates,
lowScoreExcludedCount = candidates.size - includedCandidates.size
candidates = candidates,
lowScoreExcludedCount = 0
)
}
@@ -49,42 +43,15 @@ class DefaultCreatorRankingAggregationRepository(
}
private fun Array<*>.toCandidate(): CreatorRankingSnapshotCandidate {
val creatorId = this[0].toLong()
val nickname = this[1] as String
val profileImageUrl = this[2] as String?
val liveCanAmount = this[3].toLong()
val contentPurchaseCanAmount = this[4].toLong()
val contentLikeCount = this[5].toLong()
val contentCommentCount = this[6].toLong()
val channelDonationCanAmount = this[7].toLong()
val channelDonationCount = this[8].toLong()
val fanTalkCount = this[9].toLong()
val finalFollowerCount = this[10].toLong()
val followIncrease = this[11].toLong()
val contentLiveScore = scorePolicy.calculateContentLiveScore(liveCanAmount, contentPurchaseCanAmount)
val engagementScore = scorePolicy.calculateEngagementScore(contentLikeCount, contentCommentCount)
val supportScore = scorePolicy.calculateSupportScore(channelDonationCanAmount, channelDonationCount, fanTalkCount)
val fanLoyaltyScore = scorePolicy.calculateFanLoyaltyScore(finalFollowerCount, followIncrease)
val finalScore = scorePolicy.calculateFinalScore(contentLiveScore, engagementScore, supportScore, fanLoyaltyScore)
return CreatorRankingSnapshotCandidate(
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
creatorId = this[0].toLong(),
nickname = this[1] as String,
profileImageUrl = this[2] as String?,
creatorDebutAt = this[3].toLocalDateTimeOrNull(),
liveCanAmount = this[4].toLong(),
contentPurchaseCanAmount = this[5].toLong(),
followIncrease = this[6].toLong().coerceAtLeast(0L),
aiChatCount = this[7].toLong()
)
}
@@ -92,9 +59,16 @@ class DefaultCreatorRankingAggregationRepository(
return (this as Number?)?.toLong() ?: 0L
}
companion object {
private const val MINIMUM_FINAL_SCORE = 1.0
private fun Any?.toLocalDateTimeOrNull(): LocalDateTime? {
return when (this) {
null -> null
is LocalDateTime -> this
is Timestamp -> toLocalDateTime()
else -> throw IllegalStateException("Unsupported creator debut timestamp type: ${this::class.java.name}")
}
}
companion object {
private val AGGREGATION_SQL = """
with active_creators as (
select id, nickname, profile_image
@@ -104,9 +78,7 @@ class DefaultCreatorRankingAggregationRepository(
), can_metrics as (
select ucc.recipient_creator_id as creator_id,
sum(case when uc.can_usage in ('DONATION', 'LIVE', 'SPIN_ROULETTE') then ucc.can else 0 end) as live_can_amount,
sum(case when uc.can_usage = 'ORDER_CONTENT' then ucc.can else 0 end) as content_purchase_can_amount,
sum(case when uc.can_usage = 'CHANNEL_DONATION' then ucc.can else 0 end) as channel_donation_can_amount,
sum(case when uc.can_usage = 'CHANNEL_DONATION' then 1 else 0 end) as channel_donation_count
sum(case when uc.can_usage = 'ORDER_CONTENT' then ucc.can else 0 end) as content_purchase_can_amount
from use_can_calculate ucc
join use_can uc on uc.id = ucc.use_can_id
where ucc.recipient_creator_id is not null
@@ -115,43 +87,6 @@ class DefaultCreatorRankingAggregationRepository(
and ucc.created_at >= :startInclusiveUtc
and ucc.created_at < :endExclusiveUtc
group by ucc.recipient_creator_id
), like_metrics as (
select c.member_id as creator_id,
count(cl.id) as content_like_count
from content_like cl
join content c on c.id = cl.content_id
where c.is_active = true
and cl.is_active = true
and cl.created_at >= :startInclusiveUtc
and cl.created_at < :endExclusiveUtc
group by c.member_id
), comment_metrics as (
select c.member_id as creator_id,
count(cc.id) as content_comment_count
from content_comment cc
join content c on c.id = cc.content_id
where c.is_active = true
and cc.is_active = true
and cc.member_id <> c.member_id
and cc.created_at >= :startInclusiveUtc
and cc.created_at < :endExclusiveUtc
group by c.member_id
), fan_talk_metrics as (
select creator_id,
count(id) as fan_talk_count
from creator_cheers
where is_active = true
and parent_id is null
and created_at >= :startInclusiveUtc
and created_at < :endExclusiveUtc
group by creator_id
), final_follower_metrics as (
select creator_id,
count(id) as final_follower_count
from creator_following
where is_active = true
and created_at < :endExclusiveUtc
group by creator_id
), new_follow_metrics as (
select creator_id,
count(id) as new_follow_count
@@ -167,37 +102,68 @@ class DefaultCreatorRankingAggregationRepository(
and updated_at >= :startInclusiveUtc
and updated_at < :endExclusiveUtc
group by creator_id
), ai_chat_metrics as (
select creator_member.id as creator_id,
count(cm.id) as ai_chat_count
from chat_message cm
join chat_participant cp on cp.id = cm.participant_id
join chat_character cc on cc.id = cp.character_id
join member creator_member on creator_member.id = cc.creator_member_id
where cm.is_active = true
and cp.is_active = true
and cp.participant_type = 'CHARACTER'
and cc.is_active = true
and creator_member.is_active = true
and creator_member.role = 'CREATOR'
and cm.created_at >= :startInclusiveUtc
and cm.created_at < :endExclusiveUtc
group by creator_member.id
), first_live as (
select member_id as creator_id,
min(begin_date_time) as first_live_begin_at
from live_room
where channel_name is not null
and begin_date_time is not null
and begin_date_time <= current_timestamp
group by member_id
), first_content as (
select member_id as creator_id,
min(release_date) as first_content_release_at
from content
where is_active = true
and release_date is not null
and release_date <= current_timestamp
group by member_id
), debut_metrics as (
select ac.id as creator_id,
case
when fl.first_live_begin_at is null then fc.first_content_release_at
when fc.first_content_release_at is null then fl.first_live_begin_at
when fl.first_live_begin_at <= fc.first_content_release_at then fl.first_live_begin_at
else fc.first_content_release_at
end as creator_debut_at
from active_creators ac
left join first_live fl on fl.creator_id = ac.id
left join first_content fc on fc.creator_id = ac.id
)
select ac.id as creator_id,
ac.nickname as nickname,
ac.profile_image as profile_image_url,
dm.creator_debut_at as creator_debut_at,
coalesce(cm.live_can_amount, 0) as live_can_amount,
coalesce(cm.content_purchase_can_amount, 0) as content_purchase_can_amount,
coalesce(lm.content_like_count, 0) as content_like_count,
coalesce(com.content_comment_count, 0) as content_comment_count,
coalesce(cm.channel_donation_can_amount, 0) as channel_donation_can_amount,
coalesce(cm.channel_donation_count, 0) as channel_donation_count,
coalesce(ftm.fan_talk_count, 0) as fan_talk_count,
coalesce(ffm.final_follower_count, 0) as final_follower_count,
coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0) as follow_increase
greatest(coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0), 0) as follow_increase,
coalesce(acm.ai_chat_count, 0) as ai_chat_count
from active_creators ac
left join can_metrics cm on cm.creator_id = ac.id
left join like_metrics lm on lm.creator_id = ac.id
left join comment_metrics com on com.creator_id = ac.id
left join fan_talk_metrics ftm on ftm.creator_id = ac.id
left join final_follower_metrics ffm on ffm.creator_id = ac.id
left join new_follow_metrics nfm on nfm.creator_id = ac.id
left join unfollow_metrics um on um.creator_id = ac.id
where coalesce(cm.live_can_amount, 0) <> 0
or coalesce(cm.content_purchase_can_amount, 0) <> 0
or coalesce(lm.content_like_count, 0) <> 0
or coalesce(com.content_comment_count, 0) <> 0
or coalesce(cm.channel_donation_can_amount, 0) <> 0
or coalesce(cm.channel_donation_count, 0) <> 0
or coalesce(ftm.fan_talk_count, 0) <> 0
or coalesce(ffm.final_follower_count, 0) <> 0
or coalesce(nfm.new_follow_count, 0) <> 0
or coalesce(um.unfollow_count, 0) <> 0
left join ai_chat_metrics acm on acm.creator_id = ac.id
left join debut_metrics dm on dm.creator_id = ac.id
where coalesce(cm.live_can_amount, 0) > 0
or coalesce(cm.content_purchase_can_amount, 0) > 0
or greatest(coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0), 0) > 0
or coalesce(acm.ai_chat_count, 0) > 0
""".trimIndent()
}
}

View File

@@ -1,21 +1,14 @@
package kr.co.vividnext.sodalive.v2.ranking.domain
import java.time.LocalDateTime
data class CreatorRankingSnapshotCandidate(
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 creatorDebutAt: LocalDateTime?,
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
val followIncrease: Long,
val aiChatCount: Long
)