feat(content-ranking): 랭킹 후보 집계를 추가한다
This commit is contained in:
@@ -0,0 +1,243 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingAggregationPort
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.sql.Timestamp
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class DefaultAudioRankingAggregationRepository(
|
||||||
|
private val entityManager: EntityManager
|
||||||
|
) : AudioRankingAggregationPort {
|
||||||
|
override fun aggregateWeeklyPopularCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate> {
|
||||||
|
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun aggregateRisingCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate> {
|
||||||
|
val previousStartInclusiveUtc = startInclusiveUtc.minusWeeks(1)
|
||||||
|
val previousEndExclusiveUtc = startInclusiveUtc
|
||||||
|
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, previousStartInclusiveUtc, previousEndExclusiveUtc)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun aggregateRevenueCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate> {
|
||||||
|
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
|
||||||
|
.filter { it.revenueCanAmount > 0 }
|
||||||
|
.map { it.copy(finalScore = it.revenueCanAmount.toDouble()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun aggregateSalesCountCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate> {
|
||||||
|
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
|
||||||
|
.filter { it.salesCount > 0 }
|
||||||
|
.map { it.copy(finalScore = it.salesCount.toDouble()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun aggregateCommentCountCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate> {
|
||||||
|
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
|
||||||
|
.filter { it.commentCount > 0 }
|
||||||
|
.map { it.copy(finalScore = it.commentCount.toDouble()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun aggregateLikeCountCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate> {
|
||||||
|
return aggregateCandidates(startInclusiveUtc, endExclusiveUtc, null, null)
|
||||||
|
.filter { it.likeCount > 0 }
|
||||||
|
.map { it.copy(finalScore = it.likeCount.toDouble()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aggregateCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime,
|
||||||
|
previousStartInclusiveUtc: LocalDateTime?,
|
||||||
|
previousEndExclusiveUtc: LocalDateTime?
|
||||||
|
): List<AudioRankingSnapshotCandidate> {
|
||||||
|
val rows = entityManager.createNativeQuery(AGGREGATION_SQL)
|
||||||
|
.setParameter("startInclusiveUtc", startInclusiveUtc)
|
||||||
|
.setParameter("endExclusiveUtc", endExclusiveUtc)
|
||||||
|
.setParameter("previousStartInclusiveUtc", previousStartInclusiveUtc ?: startInclusiveUtc)
|
||||||
|
.setParameter("previousEndExclusiveUtc", previousEndExclusiveUtc ?: startInclusiveUtc)
|
||||||
|
.resultList
|
||||||
|
|
||||||
|
return rows.map { row -> (row as Array<*>).toCandidate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Array<*>.toCandidate(): AudioRankingSnapshotCandidate {
|
||||||
|
return AudioRankingSnapshotCandidate(
|
||||||
|
contentId = this[0].toLong(),
|
||||||
|
title = this[1] as String,
|
||||||
|
creatorMemberId = this[2].toLong(),
|
||||||
|
creatorNickname = this[3] as String,
|
||||||
|
coverImageUrl = this[4] as String?,
|
||||||
|
releaseDate = this[5].toLocalDateTime(),
|
||||||
|
isAdult = this[6].toBoolean(),
|
||||||
|
isPaid = this[7].toLong() > 0,
|
||||||
|
revenueCanAmount = this[8].toLong(),
|
||||||
|
salesCount = this[9].toLong(),
|
||||||
|
viewCount = this[10].toLong(),
|
||||||
|
likeCount = this[11].toLong(),
|
||||||
|
commentCount = this[12].toLong(),
|
||||||
|
previousSalesCount = this[13].toLong(),
|
||||||
|
previousViewCount = this[14].toLong(),
|
||||||
|
previousLikeCount = this[15].toLong(),
|
||||||
|
previousCommentCount = this[16].toLong()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Any?.toLong(): Long {
|
||||||
|
return (this as Number?)?.toLong() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Any?.toBoolean(): Boolean {
|
||||||
|
return when (this) {
|
||||||
|
is Boolean -> this
|
||||||
|
is Number -> toInt() != 0
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Any?.toLocalDateTime(): LocalDateTime {
|
||||||
|
return when (this) {
|
||||||
|
is LocalDateTime -> this
|
||||||
|
is Timestamp -> toLocalDateTime()
|
||||||
|
else -> error("Unsupported datetime value: $this")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val AGGREGATION_SQL = """
|
||||||
|
with eligible_content as (
|
||||||
|
select c.id as content_id,
|
||||||
|
c.title as title,
|
||||||
|
c.member_id as creator_member_id,
|
||||||
|
m.nickname as creator_nickname,
|
||||||
|
c.cover_image as cover_image_url,
|
||||||
|
c.release_date as release_date,
|
||||||
|
c.is_adult as is_adult,
|
||||||
|
c.price as price
|
||||||
|
from content c
|
||||||
|
join member m on m.id = c.member_id
|
||||||
|
join content_theme ct on ct.id = c.theme_id
|
||||||
|
where c.is_active = true
|
||||||
|
and c.release_date is not null
|
||||||
|
and c.release_date < :endExclusiveUtc
|
||||||
|
and c.duration is not null
|
||||||
|
and c.limited is null
|
||||||
|
and ct.is_active = true
|
||||||
|
and m.role = 'CREATOR'
|
||||||
|
and m.is_active = true
|
||||||
|
), order_metrics as (
|
||||||
|
select o.content_id,
|
||||||
|
sum(o.can) as revenue_can_amount,
|
||||||
|
count(o.id) as sales_count
|
||||||
|
from orders o
|
||||||
|
where o.is_active = true
|
||||||
|
and o.created_at >= :startInclusiveUtc
|
||||||
|
and o.created_at < :endExclusiveUtc
|
||||||
|
group by o.content_id
|
||||||
|
), view_metrics as (
|
||||||
|
select ccvh.content_id,
|
||||||
|
count(ccvh.id) as view_count
|
||||||
|
from creator_content_view_history ccvh
|
||||||
|
where ccvh.viewed_at >= :startInclusiveUtc
|
||||||
|
and ccvh.viewed_at < :endExclusiveUtc
|
||||||
|
group by ccvh.content_id
|
||||||
|
), like_metrics as (
|
||||||
|
select cl.content_id,
|
||||||
|
count(cl.id) as like_count
|
||||||
|
from content_like cl
|
||||||
|
where cl.is_active = true
|
||||||
|
and cl.created_at >= :startInclusiveUtc
|
||||||
|
and cl.created_at < :endExclusiveUtc
|
||||||
|
group by cl.content_id
|
||||||
|
), comment_metrics as (
|
||||||
|
select cc.content_id,
|
||||||
|
count(cc.id) as comment_count
|
||||||
|
from content_comment cc
|
||||||
|
where cc.is_active = true
|
||||||
|
and cc.created_at >= :startInclusiveUtc
|
||||||
|
and cc.created_at < :endExclusiveUtc
|
||||||
|
group by cc.content_id
|
||||||
|
), previous_order_metrics as (
|
||||||
|
select o.content_id,
|
||||||
|
count(o.id) as previous_sales_count
|
||||||
|
from orders o
|
||||||
|
where o.is_active = true
|
||||||
|
and o.created_at >= :previousStartInclusiveUtc
|
||||||
|
and o.created_at < :previousEndExclusiveUtc
|
||||||
|
group by o.content_id
|
||||||
|
), previous_view_metrics as (
|
||||||
|
select ccvh.content_id,
|
||||||
|
count(ccvh.id) as previous_view_count
|
||||||
|
from creator_content_view_history ccvh
|
||||||
|
where ccvh.viewed_at >= :previousStartInclusiveUtc
|
||||||
|
and ccvh.viewed_at < :previousEndExclusiveUtc
|
||||||
|
group by ccvh.content_id
|
||||||
|
), previous_like_metrics as (
|
||||||
|
select cl.content_id,
|
||||||
|
count(cl.id) as previous_like_count
|
||||||
|
from content_like cl
|
||||||
|
where cl.is_active = true
|
||||||
|
and cl.created_at >= :previousStartInclusiveUtc
|
||||||
|
and cl.created_at < :previousEndExclusiveUtc
|
||||||
|
group by cl.content_id
|
||||||
|
), previous_comment_metrics as (
|
||||||
|
select cc.content_id,
|
||||||
|
count(cc.id) as previous_comment_count
|
||||||
|
from content_comment cc
|
||||||
|
where cc.is_active = true
|
||||||
|
and cc.created_at >= :previousStartInclusiveUtc
|
||||||
|
and cc.created_at < :previousEndExclusiveUtc
|
||||||
|
group by cc.content_id
|
||||||
|
)
|
||||||
|
select ec.content_id,
|
||||||
|
ec.title,
|
||||||
|
ec.creator_member_id,
|
||||||
|
ec.creator_nickname,
|
||||||
|
ec.cover_image_url,
|
||||||
|
ec.release_date,
|
||||||
|
ec.is_adult,
|
||||||
|
ec.price,
|
||||||
|
coalesce(om.revenue_can_amount, 0) as revenue_can_amount,
|
||||||
|
coalesce(om.sales_count, 0) as sales_count,
|
||||||
|
coalesce(vm.view_count, 0) as view_count,
|
||||||
|
coalesce(lm.like_count, 0) as like_count,
|
||||||
|
coalesce(cm.comment_count, 0) as comment_count,
|
||||||
|
coalesce(pom.previous_sales_count, 0) as previous_sales_count,
|
||||||
|
coalesce(pvm.previous_view_count, 0) as previous_view_count,
|
||||||
|
coalesce(plm.previous_like_count, 0) as previous_like_count,
|
||||||
|
coalesce(pcm.previous_comment_count, 0) as previous_comment_count
|
||||||
|
from eligible_content ec
|
||||||
|
left join order_metrics om on om.content_id = ec.content_id
|
||||||
|
left join view_metrics vm on vm.content_id = ec.content_id
|
||||||
|
left join like_metrics lm on lm.content_id = ec.content_id
|
||||||
|
left join comment_metrics cm on cm.content_id = ec.content_id
|
||||||
|
left join previous_order_metrics pom on pom.content_id = ec.content_id
|
||||||
|
left join previous_view_metrics pvm on pvm.content_id = ec.content_id
|
||||||
|
left join previous_like_metrics plm on plm.content_id = ec.content_id
|
||||||
|
left join previous_comment_metrics pcm on pcm.content_id = ec.content_id
|
||||||
|
where coalesce(om.revenue_can_amount, 0) <> 0
|
||||||
|
or coalesce(om.sales_count, 0) <> 0
|
||||||
|
or coalesce(vm.view_count, 0) <> 0
|
||||||
|
or coalesce(lm.like_count, 0) <> 0
|
||||||
|
or coalesce(cm.comment_count, 0) <> 0
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.domain
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class AudioRankingSnapshotCandidate(
|
||||||
|
val contentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val creatorMemberId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val releaseDate: LocalDateTime,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val isPaid: Boolean,
|
||||||
|
val finalScore: Double = 0.0,
|
||||||
|
val revenueCanAmount: Long = 0,
|
||||||
|
val salesCount: Long = 0,
|
||||||
|
val viewCount: Long = 0,
|
||||||
|
val likeCount: Long = 0,
|
||||||
|
val commentCount: Long = 0,
|
||||||
|
val previousSalesCount: Long = 0,
|
||||||
|
val previousViewCount: Long = 0,
|
||||||
|
val previousLikeCount: Long = 0,
|
||||||
|
val previousCommentCount: Long = 0
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingSnapshotCandidate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface AudioRankingAggregationPort {
|
||||||
|
fun aggregateWeeklyPopularCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate>
|
||||||
|
|
||||||
|
fun aggregateRisingCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate>
|
||||||
|
|
||||||
|
fun aggregateRevenueCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate>
|
||||||
|
|
||||||
|
fun aggregateSalesCountCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate>
|
||||||
|
|
||||||
|
fun aggregateCommentCountCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate>
|
||||||
|
|
||||||
|
fun aggregateLikeCountCandidates(
|
||||||
|
startInclusiveUtc: LocalDateTime,
|
||||||
|
endExclusiveUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotCandidate>
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user