feat(content-ranking): 랭킹 후보 집계를 추가한다

This commit is contained in:
2026-06-24 16:21:43 +09:00
parent 453d914f44
commit ee32696c6c
4 changed files with 555 additions and 0 deletions
@@ -0,0 +1,252 @@
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
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.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.CreatorContentViewHistory
import org.junit.jupiter.api.Assertions.assertEquals
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 DefaultAudioRankingAggregationRepositoryTest @Autowired constructor(
private val entityManager: EntityManager
) {
private val adapter = DefaultAudioRankingAggregationRepository(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("주간 인기 후보는 매출, 판매량, 조회수, 좋아요, 댓글 수를 기간 기준으로 집계한다")
fun shouldAggregateWeeklyPopularMetricsByPeriod() {
val creator = saveCreator("creator")
val buyer = saveUser("buyer")
val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod)
saveOrder(content, buyer, creator, inPeriod)
saveOrder(content, buyer, creator, endAt)
saveView(content, buyer, inPeriod)
saveView(content, buyer, startAt.minusSeconds(1))
saveLike(content, buyer, isActive = true, createdAt = inPeriod)
saveLike(content, buyer, isActive = false, createdAt = inPeriod)
saveComment(content, buyer, isActive = true, createdAt = inPeriod)
saveComment(content, buyer, isActive = false, createdAt = inPeriod)
flushAndClear()
val candidate = adapter.aggregateWeeklyPopularCandidates(startAt, endAt).single()
assertEquals(content.id, candidate.contentId)
assertEquals(100, candidate.revenueCanAmount)
assertEquals(1, candidate.salesCount)
assertEquals(1, candidate.viewCount)
assertEquals(1, candidate.likeCount)
assertEquals(1, candidate.commentCount)
}
@Test
@DisplayName("비활성 콘텐츠, 공개 전 콘텐츠, 비활성 크리에이터 콘텐츠는 후보에서 제외한다")
fun shouldExcludeInactiveUnreleasedAndInactiveCreatorContent() {
val activeCreator = saveCreator("active")
val inactiveCreator = saveCreator("inactive", isActive = false)
val buyer = saveUser("buyer")
val validContent = saveAudioContent(activeCreator, price = 100, isActive = true, releaseDate = inPeriod)
val inactiveContent = saveAudioContent(activeCreator, price = 100, isActive = false, releaseDate = inPeriod)
val unreleasedContent = saveAudioContent(activeCreator, price = 100, isActive = true, releaseDate = endAt.plusDays(1))
val inactiveCreatorContent = saveAudioContent(inactiveCreator, price = 100, isActive = true, releaseDate = inPeriod)
listOf(validContent, inactiveContent, unreleasedContent, inactiveCreatorContent).forEach { content ->
saveOrder(content, buyer, content.member!!, inPeriod)
}
flushAndClear()
val candidates = adapter.aggregateWeeklyPopularCandidates(startAt, endAt)
assertEquals(listOf(validContent.id), candidates.map { it.contentId })
}
@Test
@DisplayName("한정판 콘텐츠는 랭킹 후보에서 제외한다")
fun shouldExcludeLimitedContent() {
val creator = saveCreator("limited")
val buyer = saveUser("buyer-limited")
val validContent = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod)
val limitedContent = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod, limited = 10)
listOf(validContent, limitedContent).forEach { content ->
saveOrder(content, buyer, creator, inPeriod)
}
flushAndClear()
val candidates = adapter.aggregateWeeklyPopularCandidates(startAt, endAt)
assertEquals(listOf(validContent.id), candidates.map { it.contentId })
}
@Test
@DisplayName("지금 뜨는 중 후보는 직전 비교 기간 지표를 함께 반환한다")
fun shouldAggregatePreviousMetricsForRisingCandidates() {
val creator = saveCreator("creator")
val viewer = saveUser("viewer")
val content = saveAudioContent(creator, price = 0, isActive = true, releaseDate = inPeriod)
saveView(content, viewer, startAt.minusDays(1))
saveView(content, viewer, inPeriod)
saveLike(content, viewer, isActive = true, createdAt = startAt.minusDays(1))
saveLike(content, viewer, isActive = true, createdAt = inPeriod)
saveComment(content, viewer, isActive = true, createdAt = startAt.minusDays(1))
saveComment(content, viewer, isActive = true, createdAt = inPeriod)
flushAndClear()
val candidate = adapter.aggregateRisingCandidates(startAt, endAt).single()
assertEquals(1, candidate.viewCount)
assertEquals(1, candidate.previousViewCount)
assertEquals(1, candidate.likeCount)
assertEquals(1, candidate.previousLikeCount)
assertEquals(1, candidate.commentCount)
assertEquals(1, candidate.previousCommentCount)
}
@Test
@DisplayName("매출, 판매량, 댓글 수, 좋아요 후보는 v2 집계 지표를 최종 점수로 사용한다")
fun shouldAggregateMetricRankingCandidatesWithRawScores() {
val creator = saveCreator("metric")
val buyer = saveUser("buyer-metric")
val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod)
saveOrder(content, buyer, creator, inPeriod)
saveOrder(content, buyer, creator, inPeriod.plusHours(1))
saveLike(content, buyer, isActive = true, createdAt = inPeriod)
saveComment(content, buyer, isActive = true, createdAt = inPeriod)
flushAndClear()
assertEquals(200.0, adapter.aggregateRevenueCandidates(startAt, endAt).single().finalScore)
assertEquals(2.0, adapter.aggregateSalesCountCandidates(startAt, endAt).single().finalScore)
assertEquals(1.0, adapter.aggregateLikeCountCandidates(startAt, endAt).single().finalScore)
assertEquals(1.0, adapter.aggregateCommentCountCandidates(startAt, endAt).single().finalScore)
}
@Test
@DisplayName("후보는 성인 콘텐츠 여부를 함께 반환한다")
fun shouldMapAdultFlagToCandidate() {
val creator = saveCreator("adult")
val buyer = saveUser("buyer-adult")
val content = saveAudioContent(creator, price = 100, isActive = true, releaseDate = inPeriod, isAdult = true)
saveOrder(content, buyer, creator, inPeriod)
flushAndClear()
val candidate = adapter.aggregateRevenueCandidates(startAt, endAt).single()
assertEquals(true, candidate.isAdult)
}
private fun saveCreator(nickname: String, isActive: Boolean = true): Member {
return saveMember(nickname, MemberRole.CREATOR, isActive)
}
private fun saveUser(nickname: String): Member {
return saveMember(nickname, MemberRole.USER, true)
}
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role,
isActive = isActive
)
entityManager.persist(member)
entityManager.flush()
return member
}
private fun saveAudioContent(
creator: Member,
price: Int,
isActive: Boolean,
releaseDate: LocalDateTime,
limited: Int? = null,
isAdult: Boolean = false
): AudioContent {
val theme = AudioContentTheme(theme = "theme-${creator.nickname}", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(
title = "content-${creator.nickname}-${releaseDate.nano}",
detail = "detail",
languageCode = "ko",
price = price,
releaseDate = releaseDate,
limited = limited
)
content.member = creator
content.theme = theme
content.isActive = isActive
content.isAdult = isAdult
content.duration = "00:01:00"
entityManager.persist(content)
entityManager.flush()
return content
}
private fun saveOrder(content: AudioContent, buyer: Member, creator: Member, createdAt: LocalDateTime) {
val order = Order(type = OrderType.KEEP, isActive = true)
order.member = buyer
order.creator = creator
order.audioContent = content
entityManager.persist(order)
entityManager.flush()
updateTimestamps("orders", order.id!!, createdAt, createdAt)
}
private fun saveView(content: AudioContent, viewer: Member, viewedAt: LocalDateTime) {
entityManager.persist(CreatorContentViewHistory(viewer.id!!, content.id!!, content.theme!!.id!!, viewedAt))
}
private fun saveLike(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 saveComment(content: AudioContent, member: Member, isActive: Boolean, createdAt: LocalDateTime) {
val comment = AudioContentComment(comment = "comment", languageCode = "ko", isActive = isActive)
comment.audioContent = content
comment.member = member
entityManager.persist(comment)
entityManager.flush()
updateTimestamps("content_comment", comment.id!!, createdAt, createdAt)
}
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()
}
}