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 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.domain.CreatorRankingSnapshotCandidate
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationResult import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationResult
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
import java.sql.Timestamp
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.EntityManager import javax.persistence.EntityManager
@@ -12,8 +12,6 @@ import javax.persistence.EntityManager
class DefaultCreatorRankingAggregationRepository( class DefaultCreatorRankingAggregationRepository(
private val entityManager: EntityManager private val entityManager: EntityManager
) : CreatorRankingAggregationPort { ) : CreatorRankingAggregationPort {
private val scorePolicy = CreatorRankingScorePolicy()
override fun aggregateCandidates( override fun aggregateCandidates(
startInclusiveUtc: LocalDateTime, startInclusiveUtc: LocalDateTime,
endExclusiveUtc: LocalDateTime endExclusiveUtc: LocalDateTime
@@ -26,13 +24,9 @@ class DefaultCreatorRankingAggregationRepository(
endExclusiveUtc: LocalDateTime endExclusiveUtc: LocalDateTime
): CreatorRankingAggregationResult { ): CreatorRankingAggregationResult {
val candidates = aggregateAllCandidates(startInclusiveUtc, endExclusiveUtc) 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( return CreatorRankingAggregationResult(
candidates = includedCandidates, candidates = candidates,
lowScoreExcludedCount = candidates.size - includedCandidates.size lowScoreExcludedCount = 0
) )
} }
@@ -49,42 +43,15 @@ class DefaultCreatorRankingAggregationRepository(
} }
private fun Array<*>.toCandidate(): CreatorRankingSnapshotCandidate { 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( return CreatorRankingSnapshotCandidate(
creatorId = creatorId, creatorId = this[0].toLong(),
nickname = nickname, nickname = this[1] as String,
profileImageUrl = profileImageUrl, profileImageUrl = this[2] as String?,
finalScore = finalScore, creatorDebutAt = this[3].toLocalDateTimeOrNull(),
contentLiveScore = contentLiveScore, liveCanAmount = this[4].toLong(),
engagementScore = engagementScore, contentPurchaseCanAmount = this[5].toLong(),
supportScore = supportScore, followIncrease = this[6].toLong().coerceAtLeast(0L),
fanLoyaltyScore = fanLoyaltyScore, aiChatCount = this[7].toLong()
liveCanAmount = liveCanAmount,
contentPurchaseCanAmount = contentPurchaseCanAmount,
contentLikeCount = contentLikeCount,
contentCommentCount = contentCommentCount,
channelDonationCanAmount = channelDonationCanAmount,
channelDonationCount = channelDonationCount,
fanTalkCount = fanTalkCount,
finalFollowerCount = finalFollowerCount,
followIncrease = followIncrease
) )
} }
@@ -92,9 +59,16 @@ class DefaultCreatorRankingAggregationRepository(
return (this as Number?)?.toLong() ?: 0L return (this as Number?)?.toLong() ?: 0L
} }
companion object { private fun Any?.toLocalDateTimeOrNull(): LocalDateTime? {
private const val MINIMUM_FINAL_SCORE = 1.0 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 = """ private val AGGREGATION_SQL = """
with active_creators as ( with active_creators as (
select id, nickname, profile_image select id, nickname, profile_image
@@ -104,9 +78,7 @@ class DefaultCreatorRankingAggregationRepository(
), can_metrics as ( ), can_metrics as (
select ucc.recipient_creator_id as creator_id, 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 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 = '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 from use_can_calculate ucc
join use_can uc on uc.id = ucc.use_can_id join use_can uc on uc.id = ucc.use_can_id
where ucc.recipient_creator_id is not null where ucc.recipient_creator_id is not null
@@ -115,43 +87,6 @@ class DefaultCreatorRankingAggregationRepository(
and ucc.created_at >= :startInclusiveUtc and ucc.created_at >= :startInclusiveUtc
and ucc.created_at < :endExclusiveUtc and ucc.created_at < :endExclusiveUtc
group by ucc.recipient_creator_id 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 ( ), new_follow_metrics as (
select creator_id, select creator_id,
count(id) as new_follow_count count(id) as new_follow_count
@@ -167,37 +102,68 @@ class DefaultCreatorRankingAggregationRepository(
and updated_at >= :startInclusiveUtc and updated_at >= :startInclusiveUtc
and updated_at < :endExclusiveUtc and updated_at < :endExclusiveUtc
group by creator_id 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, select ac.id as creator_id,
ac.nickname as nickname, ac.nickname as nickname,
ac.profile_image as profile_image_url, 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.live_can_amount, 0) as live_can_amount,
coalesce(cm.content_purchase_can_amount, 0) as content_purchase_can_amount, coalesce(cm.content_purchase_can_amount, 0) as content_purchase_can_amount,
coalesce(lm.content_like_count, 0) as content_like_count, greatest(coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0), 0) as follow_increase,
coalesce(com.content_comment_count, 0) as content_comment_count, coalesce(acm.ai_chat_count, 0) as ai_chat_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 from active_creators ac
left join can_metrics cm on cm.creator_id = ac.id 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 new_follow_metrics nfm on nfm.creator_id = ac.id
left join unfollow_metrics um on um.creator_id = ac.id left join unfollow_metrics um on um.creator_id = ac.id
where coalesce(cm.live_can_amount, 0) <> 0 left join ai_chat_metrics acm on acm.creator_id = ac.id
or coalesce(cm.content_purchase_can_amount, 0) <> 0 left join debut_metrics dm on dm.creator_id = ac.id
or coalesce(lm.content_like_count, 0) <> 0 where coalesce(cm.live_can_amount, 0) > 0
or coalesce(com.content_comment_count, 0) <> 0 or coalesce(cm.content_purchase_can_amount, 0) > 0
or coalesce(cm.channel_donation_can_amount, 0) <> 0 or greatest(coalesce(nfm.new_follow_count, 0) - coalesce(um.unfollow_count, 0), 0) > 0
or coalesce(cm.channel_donation_count, 0) <> 0 or coalesce(acm.ai_chat_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() """.trimIndent()
} }
} }

View File

@@ -1,21 +1,14 @@
package kr.co.vividnext.sodalive.v2.ranking.domain package kr.co.vividnext.sodalive.v2.ranking.domain
import java.time.LocalDateTime
data class CreatorRankingSnapshotCandidate( data class CreatorRankingSnapshotCandidate(
val creatorId: Long, val creatorId: Long,
val nickname: String, val nickname: String,
val profileImageUrl: String?, val profileImageUrl: String?,
val finalScore: Double, val creatorDebutAt: LocalDateTime?,
val contentLiveScore: Double,
val engagementScore: Double,
val supportScore: Double,
val fanLoyaltyScore: Double,
val liveCanAmount: Long, val liveCanAmount: Long,
val contentPurchaseCanAmount: Long, val contentPurchaseCanAmount: Long,
val contentLikeCount: Long, val followIncrease: Long,
val contentCommentCount: Long, val aiChatCount: Long
val channelDonationCanAmount: Long,
val channelDonationCount: Long,
val fanTalkCount: Long,
val finalFollowerCount: Long,
val followIncrease: Long
) )

View File

@@ -5,18 +5,20 @@ import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
import kr.co.vividnext.sodalive.chat.room.ChatMessage
import kr.co.vividnext.sodalive.chat.room.ChatParticipant
import kr.co.vividnext.sodalive.chat.room.ChatRoom
import kr.co.vividnext.sodalive.chat.room.ParticipantType
import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.like.AudioContentLike
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
@@ -28,25 +30,41 @@ import javax.persistence.EntityManager
@DataJpaTest( @DataJpaTest(
properties = [ properties = [
"spring.cache.type=none", "spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE" "spring.datasource.url=jdbc:h2:mem:creator-ranking-aggregation;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
] ]
) )
@Import(QueryDslConfig::class) @Import(QueryDslConfig::class, DefaultCreatorRankingAggregationRepository::class)
class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
private val adapter: DefaultCreatorRankingAggregationRepository,
private val entityManager: EntityManager private val entityManager: EntityManager
) { ) {
private val adapter = DefaultCreatorRankingAggregationRepository(entityManager) @Test
private val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) @DisplayName("활성 크리에이터만 V2 raw metric 후보로 집계한다")
private val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) fun shouldAggregateOnlyActiveCreators() {
private val inPeriod = LocalDateTime.of(2026, 6, 1, 0, 0) val activeCreator = saveCreator("active-creator")
val inactiveCreator = saveCreator("inactive-creator", isActive = false)
val nonCreator = saveUser("non-creator")
val user = saveUser("active-user")
saveUseCanCalculate(user, activeCreator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, inactiveCreator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, nonCreator, CanUsage.DONATION, 200, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
flushAndClear()
val candidates = aggregate()
assertEquals(listOf(activeCreator.id), candidates.map { it.creatorId })
assertFalse(candidates.any { it.creatorId == inactiveCreator.id })
assertFalse(candidates.any { it.creatorId == nonCreator.id })
}
@Test @Test
@DisplayName("콘텐츠/라이브 캔은 사용 구분, 정산 상태, 환불 여부, UTC 기간으로 집계한다") @DisplayName("캔 지표는 사용 구분, 정산 상태, 환불 여부, half-open UTC 기간으로 집계한다")
fun shouldAggregateLiveAndContentCanAmountsByUsageStatusRefundAndUtcPeriod() { fun shouldAggregateCanMetricsByUsageStatusRefundAndHalfOpenPeriod() {
val creator = saveCreator("can-creator") val creator = saveCreator("can-creator")
val user = saveUser("can-user") val user = saveUser("can-user")
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, startAt) saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, START_AT)
saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, inPeriod) saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate( saveUseCanCalculate(
user, user,
creator, creator,
@@ -54,11 +72,11 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
30, 30,
UseCanCalculateStatus.RECEIVED, UseCanCalculateStatus.RECEIVED,
false, false,
endAt.minusSeconds(1) END_AT.minusSeconds(1)
) )
saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, inPeriod) saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, inPeriod) saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, IN_PERIOD)
saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, inPeriod) saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, IN_PERIOD)
saveUseCanCalculate( saveUseCanCalculate(
user, user,
creator, creator,
@@ -66,9 +84,9 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
300, 300,
UseCanCalculateStatus.RECEIVED, UseCanCalculateStatus.RECEIVED,
false, false,
startAt.minusSeconds(1) START_AT.minusSeconds(1)
) )
saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 400, UseCanCalculateStatus.RECEIVED, false, endAt) saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 400, UseCanCalculateStatus.RECEIVED, false, END_AT)
flushAndClear() flushAndClear()
val candidate = aggregate().single() val candidate = aggregate().single()
@@ -78,139 +96,136 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("활성 콘텐츠의 활성 좋아요와 작성자 본인이 아닌 활성 댓글/대댓글만 집계한") @DisplayName("팔로우 증가는 음수를 0으로 보정하고 양수 신규 팔로우는 후보를 만든")
fun shouldAggregateActiveContentLikesAndCommentsExcludingCreatorSelfResponses() { fun shouldClampNegativeFollowIncreaseAndIncludePositiveFollowCandidate() {
val creator = saveCreator("engagement-creator") val negativeCreator = saveCreator("negative-follow-creator")
val otherCreator = saveCreator("inactive-content-creator") val positiveCreator = saveCreator("positive-follow-creator")
val user = saveUser("engagement-user") val user = saveUser("follow-user")
val content = saveAudioContent(creator, isActive = true) val firstUnfollower = saveUser("first-unfollower")
val inactiveContent = saveAudioContent(otherCreator, isActive = false) val secondUnfollower = saveUser("second-unfollower")
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod) saveUseCanCalculate(user, negativeCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveContentLike(content, user, isActive = true, createdAt = inPeriod) saveFollowing(negativeCreator, firstUnfollower, isActive = false, START_AT.minusDays(3), IN_PERIOD)
saveContentLike(content, user, isActive = false, createdAt = inPeriod) saveFollowing(negativeCreator, secondUnfollower, isActive = false, START_AT.minusDays(2), IN_PERIOD)
saveContentLike(content, user, isActive = true, createdAt = startAt.minusSeconds(1)) saveFollowing(positiveCreator, user, isActive = true, IN_PERIOD, IN_PERIOD)
saveContentLike(inactiveContent, user, isActive = true, createdAt = inPeriod)
val parent = saveContentComment(content, user, isActive = true, createdAt = inPeriod)
saveContentComment(content, user, parent = parent, isActive = true, createdAt = inPeriod)
saveContentComment(content, creator, isActive = true, createdAt = inPeriod)
saveContentComment(content, user, isActive = false, createdAt = inPeriod)
saveContentComment(content, user, isActive = true, createdAt = endAt)
saveContentComment(inactiveContent, user, isActive = true, createdAt = inPeriod)
flushAndClear()
val candidate = aggregate().single { it.creatorId == creator.id }
assertEquals(1, candidate.contentLikeCount)
assertEquals(2, candidate.contentCommentCount)
}
@Test
@DisplayName("채널 후원 캔/건수와 최상위 활성 팬 Talk만 집계한다")
fun shouldAggregateChannelDonationAndTopLevelActiveFanTalks() {
val creator = saveCreator("support-creator")
val user = saveUser("support-user")
saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 100, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 200, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 300, UseCanCalculateStatus.RECEIVED, true, inPeriod)
saveUseCanCalculate(user, creator, CanUsage.CHANNEL_DONATION, 400, UseCanCalculateStatus.REFUND, false, inPeriod)
val topLevel = saveCreatorCheers(creator, user, isActive = true, createdAt = inPeriod)
saveCreatorCheers(creator, user, parent = topLevel, isActive = true, createdAt = inPeriod)
saveCreatorCheers(creator, user, isActive = false, createdAt = inPeriod)
saveCreatorCheers(creator, user, isActive = true, createdAt = endAt)
flushAndClear()
val candidate = aggregate().single()
assertEquals(300, candidate.channelDonationCanAmount)
assertEquals(2, candidate.channelDonationCount)
assertEquals(1, candidate.fanTalkCount)
}
@Test
@DisplayName("팔로우 최종 활성 수와 현재 row 기준 생성/비활성 변경 증가 수를 집계한다")
fun shouldAggregateFinalFollowerCountAndFollowIncreaseFromCurrentRows() {
val creator = saveCreator("follow-creator")
val activeFollower = saveUser("active-follower")
val newFollower = saveUser("new-follower")
val unfollower = saveUser("unfollower")
val oldInactiveFollower = saveUser("old-inactive-follower")
saveUseCanCalculate(activeFollower, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveFollowing(
creator,
activeFollower,
isActive = true,
createdAt = startAt.minusDays(5),
updatedAt = startAt.minusDays(5)
)
saveFollowing(creator, newFollower, isActive = true, createdAt = inPeriod, updatedAt = inPeriod)
saveFollowing(creator, unfollower, isActive = false, createdAt = startAt.minusDays(10), updatedAt = inPeriod)
saveFollowing(
creator,
oldInactiveFollower,
isActive = false,
createdAt = startAt.minusDays(10),
updatedAt = startAt.minusSeconds(1)
)
flushAndClear()
val candidate = aggregate().single()
assertEquals(2, candidate.finalFollowerCount)
// 현재 CreatorFollowing row만으로 집계하므로 기간 내 재팔로우 이력은 별도 이벤트로 복원하지 않는다.
assertEquals(0, candidate.followIncrease)
}
@Test
@DisplayName("활성 크리에이터별 원천 지표를 합쳐 1점 이상 후보만 점수와 함께 반환한다")
fun shouldMergeMetricsForActiveCreatorsAndExcludeInactiveNonCreatorAndLowScoreCandidates() {
val creator = saveCreator("merged-creator", profileImage = "merged.png")
val lowScoreCreator = saveCreator("low-score-creator")
val inactiveCreator = saveCreator("inactive-creator", isActive = false)
val nonCreator = saveUser("non-creator")
val user = saveUser("merged-user")
saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveContentLike(saveAudioContent(creator, isActive = true), user, isActive = true, createdAt = inPeriod)
saveUseCanCalculate(user, inactiveCreator, CanUsage.DONATION, 1000, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveUseCanCalculate(user, nonCreator, CanUsage.DONATION, 1000, UseCanCalculateStatus.RECEIVED, false, inPeriod)
saveFollowing(lowScoreCreator, user, isActive = true, createdAt = startAt.minusDays(1), updatedAt = startAt.minusDays(1))
flushAndClear() flushAndClear()
val candidates = aggregate() val candidates = aggregate()
assertEquals(listOf(creator.id), candidates.map { it.creatorId }) assertEquals(0, candidates.single { it.creatorId == negativeCreator.id }.followIncrease)
val candidate = candidates.single() assertEquals(1, candidates.single { it.creatorId == positiveCreator.id }.followIncrease)
assertEquals("merged-creator", candidate.nickname)
assertEquals("merged.png", candidate.profileImageUrl)
assertEquals(10, candidate.liveCanAmount)
assertEquals(0, candidate.contentPurchaseCanAmount)
assertEquals(1, candidate.contentLikeCount)
assertEquals(7.0, candidate.contentLiveScore, 0.0001)
assertEquals(0.5, candidate.engagementScore, 0.0001)
assertEquals(0.0, candidate.supportScore, 0.0001)
assertEquals(0.0, candidate.fanLoyaltyScore, 0.0001)
assertEquals(2.6, candidate.finalScore, 0.0001)
assertTrue(candidate.finalScore >= 1.0)
assertFalse(candidates.any { it.creatorId == lowScoreCreator.id })
assertFalse(candidates.any { it.creatorId == inactiveCreator.id })
assertFalse(candidates.any { it.creatorId == nonCreator.id })
} }
private fun aggregate() = adapter.aggregateCandidates(startAt, endAt) @Test
@DisplayName("AI 채팅 수는 활성 메시지/캐릭터 참여자/캐릭터/크리에이터와 half-open 기간만 집계한다")
fun shouldAggregateAiChatCountOnlyForActiveCharacterConversationInHalfOpenPeriod() {
val creator = saveCreator("chat-creator")
val inactiveCreator = saveCreator("inactive-chat-creator", isActive = false)
val activeCharacter = saveCharacter(creator, "active-character", isActive = true)
val inactiveCharacter = saveCharacter(saveCreator("inactive-character-creator"), "inactive-character", isActive = false)
val inactiveCreatorCharacter = saveCharacter(inactiveCreator, "inactive-creator-character", isActive = true)
val room = saveChatRoom("chat-room")
val activeParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = activeCharacter, isActive = true)
val inactiveParticipant = saveParticipant(room, ParticipantType.CHARACTER, character = activeCharacter, isActive = false)
val inactiveCharacterParticipant = saveParticipant(
room,
ParticipantType.CHARACTER,
character = inactiveCharacter,
isActive = true
)
val inactiveCreatorParticipant = saveParticipant(
room,
ParticipantType.CHARACTER,
character = inactiveCreatorCharacter,
isActive = true
)
saveChatMessage(room, activeParticipant, isActive = true, START_AT)
saveChatMessage(room, activeParticipant, isActive = true, END_AT.minusSeconds(1))
saveChatMessage(room, activeParticipant, isActive = false, IN_PERIOD)
saveChatMessage(room, inactiveParticipant, isActive = true, IN_PERIOD)
saveChatMessage(room, inactiveCharacterParticipant, isActive = true, IN_PERIOD)
saveChatMessage(room, inactiveCreatorParticipant, isActive = true, IN_PERIOD)
saveChatMessage(room, activeParticipant, isActive = true, START_AT.minusSeconds(1))
saveChatMessage(room, activeParticipant, isActive = true, END_AT)
flushAndClear()
private fun saveCreator(nickname: String, profileImage: String? = null, isActive: Boolean = true): Member { val candidate = aggregate().single()
return saveMember(nickname, MemberRole.CREATOR, profileImage, isActive)
assertEquals(creator.id, candidate.creatorId)
assertEquals(2, candidate.aiChatCount)
}
@Test
@DisplayName("크리에이터 데뷔일은 첫 라이브와 첫 콘텐츠 공개일 중 빠른 값이고 없으면 null이다")
fun shouldUseEarliestLiveOrContentAsCreatorDebutAtAndNullWhenMissing() {
val liveFirstCreator = saveCreator("live-first-creator")
val contentFirstCreator = saveCreator("content-first-creator")
val noDebutCreator = saveCreator("no-debut-creator")
val user = saveUser("debut-user")
val futureDate = LocalDateTime.now().plusYears(1)
saveLiveRoom(liveFirstCreator, LocalDateTime.of(2025, 12, 1, 0, 0), channelName = null)
saveLiveRoom(liveFirstCreator, LocalDateTime.of(2026, 1, 10, 0, 0))
saveAudioContent(liveFirstCreator, LocalDateTime.of(2026, 2, 10, 0, 0))
saveLiveRoom(contentFirstCreator, LocalDateTime.of(2026, 3, 10, 0, 0))
saveAudioContent(contentFirstCreator, LocalDateTime.of(2026, 1, 20, 0, 0))
saveLiveRoom(contentFirstCreator, futureDate)
saveAudioContent(contentFirstCreator, futureDate)
saveUseCanCalculate(user, liveFirstCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, contentFirstCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, noDebutCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
flushAndClear()
val candidates = aggregate().associateBy { it.creatorId }
assertEquals(LocalDateTime.of(2026, 1, 10, 0, 0), candidates.getValue(liveFirstCreator.id!!).creatorDebutAt)
assertEquals(LocalDateTime.of(2026, 1, 20, 0, 0), candidates.getValue(contentFirstCreator.id!!).creatorDebutAt)
assertEquals(null, candidates.getValue(noDebutCreator.id!!).creatorDebutAt)
}
@Test
@DisplayName("모든 지표가 0인 후보는 제외하고 하나라도 양수인 후보는 포함한다")
fun shouldExcludeAllZeroCandidatesAndIncludeAnyPositiveMetricCandidate() {
val zeroCreator = saveCreator("zero-creator")
val liveCanCreator = saveCreator("live-can-creator")
val contentCanCreator = saveCreator("content-can-creator")
val followCreator = saveCreator("follow-creator")
val chatCreator = saveCreator("chat-positive-creator")
val user = saveUser("positive-user")
saveLiveRoom(zeroCreator, IN_PERIOD)
saveUseCanCalculate(user, liveCanCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveUseCanCalculate(user, contentCanCreator, CanUsage.ORDER_CONTENT, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD)
saveFollowing(followCreator, user, isActive = true, IN_PERIOD, IN_PERIOD)
val room = saveChatRoom("positive-chat-room")
val participant = saveParticipant(
room,
ParticipantType.CHARACTER,
character = saveCharacter(chatCreator, "chat-positive-character"),
isActive = true
)
saveChatMessage(room, participant, isActive = true, IN_PERIOD)
flushAndClear()
val creatorIds = aggregate().map { it.creatorId }.toSet()
assertFalse(creatorIds.contains(zeroCreator.id))
assertEquals(setOf(liveCanCreator.id, contentCanCreator.id, followCreator.id, chatCreator.id), creatorIds)
}
private fun aggregate() = adapter.aggregateCandidates(START_AT, END_AT)
private fun saveCreator(nickname: String, isActive: Boolean = true): Member {
return saveMember(nickname = nickname, role = MemberRole.CREATOR, isActive = isActive)
} }
private fun saveUser(nickname: String): Member { private fun saveUser(nickname: String): Member {
return saveMember(nickname, MemberRole.USER, null, true) return saveMember(nickname = nickname, role = MemberRole.USER, isActive = true)
} }
private fun saveMember(nickname: String, role: MemberRole, profileImage: String?, isActive: Boolean): Member { private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",
password = "password", password = "password",
nickname = nickname, nickname = nickname,
profileImage = profileImage, profileImage = "$nickname.png",
role = role, role = role,
isActive = isActive isActive = isActive
) )
@@ -219,22 +234,6 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
return member return member
} }
private fun saveAudioContent(creator: Member, isActive: Boolean): AudioContent {
val theme = AudioContentTheme(theme = "theme-${creator.nickname}", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(
title = "content-${creator.nickname}",
detail = "detail",
languageCode = "ko",
releaseDate = inPeriod
)
content.member = creator
content.theme = theme
content.isActive = isActive
entityManager.persist(content)
return content
}
private fun saveUseCanCalculate( private fun saveUseCanCalculate(
member: Member, member: Member,
creator: Member, creator: Member,
@@ -255,49 +254,6 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
updateTimestamps("use_can_calculate", calculate.id!!, createdAt, createdAt) updateTimestamps("use_can_calculate", calculate.id!!, createdAt, createdAt)
} }
private fun saveContentLike(content: AudioContent, member: Member, isActive: Boolean, createdAt: LocalDateTime) {
val like = AudioContentLike(memberId = member.id!!)
like.audioContent = content
like.isActive = isActive
entityManager.persist(like)
entityManager.flush()
updateTimestamps("content_like", like.id!!, createdAt, createdAt)
}
private fun saveContentComment(
content: AudioContent,
member: Member,
parent: AudioContentComment? = null,
isActive: Boolean,
createdAt: LocalDateTime
): AudioContentComment {
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
comment.audioContent = content
comment.member = member
comment.parent = parent
entityManager.persist(comment)
entityManager.flush()
updateTimestamps("content_comment", comment.id!!, createdAt, createdAt)
return comment
}
private fun saveCreatorCheers(
creator: Member,
member: Member,
parent: CreatorCheers? = null,
isActive: Boolean,
createdAt: LocalDateTime
): CreatorCheers {
val cheers = CreatorCheers(cheers = "cheers", languageCode = "ko", isActive = isActive)
cheers.creator = creator
cheers.member = member
cheers.parent = parent
entityManager.persist(cheers)
entityManager.flush()
updateTimestamps("creator_cheers", cheers.id!!, createdAt, createdAt)
return cheers
}
private fun saveFollowing( private fun saveFollowing(
creator: Member, creator: Member,
member: Member, member: Member,
@@ -313,6 +269,87 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
updateTimestamps("creator_following", following.id!!, createdAt, updatedAt) updateTimestamps("creator_following", following.id!!, createdAt, updatedAt)
} }
private fun saveCharacter(creator: Member, name: String, isActive: Boolean = true): ChatCharacter {
val character = ChatCharacter(
characterUUID = "uuid-$name",
name = name,
description = "description",
systemPrompt = "prompt",
isActive = isActive
)
character.creatorMember = creator
entityManager.persist(character)
return character
}
private fun saveChatRoom(sessionId: String): ChatRoom {
val room = ChatRoom(sessionId = sessionId, title = sessionId)
entityManager.persist(room)
return room
}
private fun saveParticipant(
room: ChatRoom,
participantType: ParticipantType,
character: ChatCharacter?,
isActive: Boolean
): ChatParticipant {
val participant = ChatParticipant(
chatRoom = room,
participantType = participantType,
character = character,
isActive = isActive
)
entityManager.persist(participant)
return participant
}
private fun saveChatMessage(room: ChatRoom, participant: ChatParticipant, isActive: Boolean, createdAt: LocalDateTime) {
val message = ChatMessage(
message = "message",
chatRoom = room,
participant = participant,
isActive = isActive
)
entityManager.persist(message)
entityManager.flush()
updateTimestamps("chat_message", message.id!!, createdAt, createdAt)
}
private fun saveLiveRoom(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String? = "channel-${creator.nickname}"
): LiveRoom {
val room = LiveRoom(
title = "live-${creator.nickname}",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 10,
isAdult = false
)
room.member = creator
room.channelName = channelName
entityManager.persist(room)
return room
}
private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime): AudioContent {
val theme = AudioContentTheme(theme = "theme-${creator.nickname}", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(
title = "content-${creator.nickname}",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate
)
content.member = creator
content.theme = theme
content.isActive = true
entityManager.persist(content)
return content
}
private fun updateTimestamps(tableName: String, id: Long, createdAt: LocalDateTime, updatedAt: LocalDateTime) { private fun updateTimestamps(tableName: String, id: Long, createdAt: LocalDateTime, updatedAt: LocalDateTime) {
entityManager.createNativeQuery( entityManager.createNativeQuery(
"update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id" "update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id"
@@ -328,4 +365,10 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
entityManager.flush() entityManager.flush()
entityManager.clear() entityManager.clear()
} }
companion object {
private val START_AT = LocalDateTime.of(2026, 5, 31, 15, 0)
private val END_AT = LocalDateTime.of(2026, 6, 7, 15, 0)
private val IN_PERIOD = LocalDateTime.of(2026, 6, 1, 0, 0)
}
} }