feat(ranking): 크리에이터 랭킹 집계 저장소를 추가한다

This commit is contained in:
2026-06-08 17:45:04 +09:00
parent 49f2238b37
commit e5d2d3c815
3 changed files with 525 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
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 org.springframework.stereotype.Repository
import java.time.LocalDateTime
import javax.persistence.EntityManager
@Repository
class DefaultCreatorRankingAggregationRepository(
private val entityManager: EntityManager
) : CreatorRankingAggregationPort {
private val scorePolicy = CreatorRankingScorePolicy()
override fun aggregateCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<CreatorRankingSnapshotCandidate> {
val rows = entityManager.createNativeQuery(AGGREGATION_SQL)
.setParameter("startInclusiveUtc", startInclusiveUtc)
.setParameter("endExclusiveUtc", endExclusiveUtc)
.resultList
return rows
.map { row -> (row as Array<*>).toCandidate() }
.filter { candidate -> candidate.finalScore >= MINIMUM_FINAL_SCORE }
.sortedWith(compareByDescending<CreatorRankingSnapshotCandidate> { it.finalScore }.thenBy { it.creatorId })
}
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
)
}
private fun Any?.toLong(): Long {
return (this as Number?)?.toLong() ?: 0L
}
companion object {
private const val MINIMUM_FINAL_SCORE = 1.0
private val AGGREGATION_SQL = """
with active_creators as (
select id, nickname, profile_image
from member
where role = 'CREATOR'
and is_active = true
), 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
from use_can_calculate ucc
join use_can uc on uc.id = ucc.use_can_id
where ucc.recipient_creator_id is not null
and ucc.status = 'RECEIVED'
and uc.is_refund = false
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
from creator_following
where created_at >= :startInclusiveUtc
and created_at < :endExclusiveUtc
group by creator_id
), unfollow_metrics as (
select creator_id,
count(id) as unfollow_count
from creator_following
where is_active = false
and updated_at >= :startInclusiveUtc
and updated_at < :endExclusiveUtc
group by creator_id
)
select ac.id as creator_id,
ac.nickname as nickname,
ac.profile_image as profile_image_url,
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
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
""".trimIndent()
}
}

View File

@@ -0,0 +1,11 @@
package kr.co.vividnext.sodalive.v2.ranking.port.out
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate
import java.time.LocalDateTime
interface CreatorRankingAggregationPort {
fun aggregateCandidates(
startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime
): List<CreatorRankingSnapshotCandidate>
}