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,331 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
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.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.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
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultCreatorRankingAggregationRepositoryTest @Autowired constructor(
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("콘텐츠/라이브 캔은 사용 구분, 정산 상태, 환불 여부, UTC 기간으로 집계한다")
fun shouldAggregateLiveAndContentCanAmountsByUsageStatusRefundAndUtcPeriod() {
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.SPIN_ROULETTE,
30,
UseCanCalculateStatus.RECEIVED,
false,
endAt.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,
300,
UseCanCalculateStatus.RECEIVED,
false,
startAt.minusSeconds(1)
)
saveUseCanCalculate(user, creator, CanUsage.ORDER_CONTENT, 400, UseCanCalculateStatus.RECEIVED, false, endAt)
flushAndClear()
val candidate = aggregate().single()
assertEquals(60, candidate.liveCanAmount)
assertEquals(40, candidate.contentPurchaseCanAmount)
}
@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))
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 })
}
private fun aggregate() = adapter.aggregateCandidates(startAt, endAt)
private fun saveCreator(nickname: String, profileImage: String? = null, isActive: Boolean = true): Member {
return saveMember(nickname, MemberRole.CREATOR, profileImage, isActive)
}
private fun saveUser(nickname: String): Member {
return saveMember(nickname, MemberRole.USER, null, true)
}
private fun saveMember(nickname: String, role: MemberRole, profileImage: String?, isActive: Boolean): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = profileImage,
role = role,
isActive = isActive
)
entityManager.persist(member)
entityManager.flush()
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,
usage: CanUsage,
can: Int,
status: UseCanCalculateStatus,
isRefund: Boolean,
createdAt: LocalDateTime
) {
val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0, isRefund = isRefund)
useCan.member = member
entityManager.persist(useCan)
val calculate = UseCanCalculate(can = can, paymentGateway = PaymentGateway.PG, status = status)
calculate.useCan = useCan
calculate.recipientCreatorId = creator.id
entityManager.persist(calculate)
entityManager.flush()
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,
isActive: Boolean,
createdAt: LocalDateTime,
updatedAt: LocalDateTime
) {
val following = CreatorFollowing(isActive = isActive)
following.creator = creator
following.member = member
entityManager.persist(following)
entityManager.flush()
updateTimestamps("creator_following", following.id!!, createdAt, updatedAt)
}
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"
)
.setParameter("createdAt", createdAt)
.setParameter("updatedAt", updatedAt)
.setParameter("id", id)
.executeUpdate()
entityManager.clear()
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}