From e596cb0826e18df9e90c9ebe2e0fee3ac40ba49b Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 14:42:34 +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=ED=9B=84=EB=B3=B4=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=EB=A5=BC=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 --- ...aultCreatorRankingAggregationRepository.kt | 180 +++----- .../domain/CreatorRankingSnapshotCandidate.kt | 17 +- ...CreatorRankingAggregationRepositoryTest.kt | 435 ++++++++++-------- 3 files changed, 317 insertions(+), 315 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt index 2b6720fb..758c1498 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepository.kt @@ -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 { 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() } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt index 4bd073d3..d0ea8ca9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/domain/CreatorRankingSnapshotCandidate.kt @@ -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 ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt index 59235dec..fb36d842 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingAggregationRepositoryTest.kt @@ -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.UseCanCalculate 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.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.explorer.profile.CreatorCheers +import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.following.CreatorFollowing import org.junit.jupiter.api.Assertions.assertEquals 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.Test import org.springframework.beans.factory.annotation.Autowired @@ -28,25 +30,41 @@ import javax.persistence.EntityManager @DataJpaTest( properties = [ "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( + private val adapter: DefaultCreatorRankingAggregationRepository, private val entityManager: EntityManager ) { - private val adapter = DefaultCreatorRankingAggregationRepository(entityManager) - private val startAt = LocalDateTime.of(2026, 5, 31, 15, 0) - private val endAt = LocalDateTime.of(2026, 6, 7, 15, 0) - private val inPeriod = LocalDateTime.of(2026, 6, 1, 0, 0) + @Test + @DisplayName("활성 크리에이터만 V2 raw metric 후보로 집계한다") + fun shouldAggregateOnlyActiveCreators() { + 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 - @DisplayName("콘텐츠/라이브 캔은 사용 구분, 정산 상태, 환불 여부, UTC 기간으로 집계한다") - fun shouldAggregateLiveAndContentCanAmountsByUsageStatusRefundAndUtcPeriod() { + @DisplayName("캔 지표는 사용 구분, 정산 상태, 환불 여부, half-open UTC 기간으로 집계한다") + fun shouldAggregateCanMetricsByUsageStatusRefundAndHalfOpenPeriod() { val creator = saveCreator("can-creator") val user = saveUser("can-user") - saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, startAt) - saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, inPeriod) + saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, START_AT) + saveUseCanCalculate(user, creator, CanUsage.LIVE, 20, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD) saveUseCanCalculate( user, creator, @@ -54,11 +72,11 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( 30, UseCanCalculateStatus.RECEIVED, false, - endAt.minusSeconds(1) + END_AT.minusSeconds(1) ) - saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, inPeriod) - saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, inPeriod) - saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, inPeriod) + saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 40, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD) + saveUseCanCalculate(user, creator, CanUsage.DONATION, 100, UseCanCalculateStatus.RECEIVED, true, IN_PERIOD) + saveUseCanCalculate(user, creator, CanUsage.LIVE, 200, UseCanCalculateStatus.CALCULATE_COMPLETE, false, IN_PERIOD) saveUseCanCalculate( user, creator, @@ -66,9 +84,9 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( 300, UseCanCalculateStatus.RECEIVED, 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() val candidate = aggregate().single() @@ -78,139 +96,136 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( } @Test - @DisplayName("활성 콘텐츠의 활성 좋아요와 작성자 본인이 아닌 활성 댓글/대댓글만 집계한다") - fun shouldAggregateActiveContentLikesAndCommentsExcludingCreatorSelfResponses() { - val creator = saveCreator("engagement-creator") - val otherCreator = saveCreator("inactive-content-creator") - val user = saveUser("engagement-user") - val content = saveAudioContent(creator, isActive = true) - val inactiveContent = saveAudioContent(otherCreator, isActive = false) - saveUseCanCalculate(user, creator, CanUsage.DONATION, 10, UseCanCalculateStatus.RECEIVED, false, inPeriod) - saveContentLike(content, user, isActive = true, createdAt = inPeriod) - saveContentLike(content, user, isActive = false, createdAt = inPeriod) - saveContentLike(content, user, isActive = true, createdAt = startAt.minusSeconds(1)) - 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)) + @DisplayName("팔로우 증가는 음수를 0으로 보정하고 양수 신규 팔로우는 후보를 만든다") + fun shouldClampNegativeFollowIncreaseAndIncludePositiveFollowCandidate() { + val negativeCreator = saveCreator("negative-follow-creator") + val positiveCreator = saveCreator("positive-follow-creator") + val user = saveUser("follow-user") + val firstUnfollower = saveUser("first-unfollower") + val secondUnfollower = saveUser("second-unfollower") + saveUseCanCalculate(user, negativeCreator, CanUsage.DONATION, 1, UseCanCalculateStatus.RECEIVED, false, IN_PERIOD) + saveFollowing(negativeCreator, firstUnfollower, isActive = false, START_AT.minusDays(3), IN_PERIOD) + saveFollowing(negativeCreator, secondUnfollower, isActive = false, START_AT.minusDays(2), IN_PERIOD) + saveFollowing(positiveCreator, user, isActive = true, IN_PERIOD, IN_PERIOD) flushAndClear() val candidates = aggregate() - assertEquals(listOf(creator.id), candidates.map { it.creatorId }) - val candidate = candidates.single() - 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 }) + assertEquals(0, candidates.single { it.creatorId == negativeCreator.id }.followIncrease) + assertEquals(1, candidates.single { it.creatorId == positiveCreator.id }.followIncrease) } - 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 { - return saveMember(nickname, MemberRole.CREATOR, profileImage, isActive) + val candidate = aggregate().single() + + 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 { - 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( email = "$nickname@test.com", password = "password", nickname = nickname, - profileImage = profileImage, + profileImage = "$nickname.png", role = role, isActive = isActive ) @@ -219,22 +234,6 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( 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( member: Member, creator: Member, @@ -255,49 +254,6 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( 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( creator: Member, member: Member, @@ -313,6 +269,87 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( 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) { entityManager.createNativeQuery( "update $tableName set created_at = :createdAt, updated_at = :updatedAt where id = :id" @@ -328,4 +365,10 @@ class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor( entityManager.flush() 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) + } }