feat(content-ranking): 랭킹 스냅샷 저장소를 추가한다
This commit is contained in:
@@ -18,6 +18,7 @@ create table content_ranking_snapshot (
|
|||||||
creator_nickname varchar(100) not null comment '스냅샷 생성 시점 크리에이터 닉네임',
|
creator_nickname varchar(100) not null comment '스냅샷 생성 시점 크리에이터 닉네임',
|
||||||
cover_image_url varchar(500) null comment '스냅샷 생성 시점 콘텐츠 커버 이미지 URL',
|
cover_image_url varchar(500) null comment '스냅샷 생성 시점 콘텐츠 커버 이미지 URL',
|
||||||
release_date timestamp not null comment '콘텐츠 공개 시각',
|
release_date timestamp not null comment '콘텐츠 공개 시각',
|
||||||
|
is_adult boolean not null comment '스냅샷 생성 시점 성인 콘텐츠 여부',
|
||||||
rank_no int not null comment '스냅샷 생성 시점 순위',
|
rank_no int not null comment '스냅샷 생성 시점 순위',
|
||||||
final_score double not null comment '최종 랭킹 점수 또는 정렬 지표',
|
final_score double not null comment '최종 랭킹 점수 또는 정렬 지표',
|
||||||
normalized_score double null comment '유료/무료 그룹 정규화 점수',
|
normalized_score double null comment '유료/무료 그룹 정규화 점수',
|
||||||
@@ -51,6 +52,9 @@ create index idx_content_ranking_snapshot_period_rank
|
|||||||
create index idx_content_ranking_snapshot_visible_rank
|
create index idx_content_ranking_snapshot_visible_rank
|
||||||
on content_ranking_snapshot (ranking_type, visible_from_at desc, rank_no);
|
on content_ranking_snapshot (ranking_type, visible_from_at desc, rank_no);
|
||||||
|
|
||||||
|
create index idx_content_ranking_snapshot_visible_adult_rank
|
||||||
|
on content_ranking_snapshot (ranking_type, visible_from_at desc, is_adult, rank_no);
|
||||||
|
|
||||||
create index idx_content_ranking_snapshot_period_score
|
create index idx_content_ranking_snapshot_period_score
|
||||||
on content_ranking_snapshot (ranking_type, aggregation_end_at_utc, final_score desc, release_date desc, content_id desc);
|
on content_ranking_snapshot (ranking_type, aggregation_end_at_utc, final_score desc, release_date desc, content_id desc);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import javax.persistence.Column
|
||||||
|
import javax.persistence.Entity
|
||||||
|
import javax.persistence.EnumType
|
||||||
|
import javax.persistence.Enumerated
|
||||||
|
import javax.persistence.Table
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "content_ranking_snapshot")
|
||||||
|
class AudioRankingSnapshot(
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "ranking_type", nullable = false, updatable = false, length = 30)
|
||||||
|
val rankingType: AudioRankingType,
|
||||||
|
|
||||||
|
@Column(name = "aggregation_start_at_utc", nullable = false, updatable = false)
|
||||||
|
val aggregationStartAtUtc: LocalDateTime,
|
||||||
|
|
||||||
|
@Column(name = "aggregation_end_at_utc", nullable = false, updatable = false)
|
||||||
|
val aggregationEndAtUtc: LocalDateTime,
|
||||||
|
|
||||||
|
@Column(name = "visible_from_at", nullable = false, updatable = false)
|
||||||
|
val visibleFromAtUtc: LocalDateTime,
|
||||||
|
|
||||||
|
@Column(name = "content_id", nullable = false, updatable = false)
|
||||||
|
val contentId: Long,
|
||||||
|
|
||||||
|
@Column(name = "title", nullable = false, updatable = false, length = 255)
|
||||||
|
val title: String,
|
||||||
|
|
||||||
|
@Column(name = "creator_member_id", nullable = false, updatable = false)
|
||||||
|
val creatorMemberId: Long,
|
||||||
|
|
||||||
|
@Column(name = "creator_nickname", nullable = false, updatable = false, length = 100)
|
||||||
|
val creatorNickname: String,
|
||||||
|
|
||||||
|
@Column(name = "cover_image_url", updatable = false, length = 500)
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
|
||||||
|
@Column(name = "release_date", nullable = false, updatable = false)
|
||||||
|
val releaseDate: LocalDateTime,
|
||||||
|
|
||||||
|
@Column(name = "is_adult", nullable = false, updatable = false)
|
||||||
|
val isAdult: Boolean,
|
||||||
|
|
||||||
|
@Column(name = "rank_no", nullable = false, updatable = false)
|
||||||
|
val rank: Int,
|
||||||
|
|
||||||
|
@Column(name = "final_score", nullable = false, updatable = false)
|
||||||
|
val finalScore: Double,
|
||||||
|
|
||||||
|
@Column(name = "normalized_score", updatable = false)
|
||||||
|
val normalizedScore: Double? = null,
|
||||||
|
|
||||||
|
@Column(name = "raw_score", updatable = false)
|
||||||
|
val rawScore: Double? = null,
|
||||||
|
|
||||||
|
@Column(name = "revenue_can_amount", updatable = false)
|
||||||
|
val revenueCanAmount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "sales_count", updatable = false)
|
||||||
|
val salesCount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "view_count", updatable = false)
|
||||||
|
val viewCount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "like_count", updatable = false)
|
||||||
|
val likeCount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "comment_count", updatable = false)
|
||||||
|
val commentCount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "previous_sales_count", updatable = false)
|
||||||
|
val previousSalesCount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "previous_view_count", updatable = false)
|
||||||
|
val previousViewCount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "previous_like_count", updatable = false)
|
||||||
|
val previousLikeCount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "previous_comment_count", updatable = false)
|
||||||
|
val previousCommentCount: Long? = null,
|
||||||
|
|
||||||
|
@Column(name = "sales_growth_rate", updatable = false)
|
||||||
|
val salesGrowthRate: Double? = null,
|
||||||
|
|
||||||
|
@Column(name = "view_growth_rate", updatable = false)
|
||||||
|
val viewGrowthRate: Double? = null,
|
||||||
|
|
||||||
|
@Column(name = "like_growth_rate", updatable = false)
|
||||||
|
val likeGrowthRate: Double? = null,
|
||||||
|
|
||||||
|
@Column(name = "comment_growth_rate", updatable = false)
|
||||||
|
val commentGrowthRate: Double? = null,
|
||||||
|
|
||||||
|
@Column(name = "content_growth_score", updatable = false)
|
||||||
|
val contentGrowthScore: Double? = null,
|
||||||
|
|
||||||
|
@Column(name = "boost_multiplier", updatable = false)
|
||||||
|
val boostMultiplier: Double? = null
|
||||||
|
) : BaseEntity()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import org.springframework.data.jpa.repository.Query
|
||||||
|
import org.springframework.data.repository.query.Param
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface AudioRankingSnapshotRepository : JpaRepository<AudioRankingSnapshot, Long> {
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
select *
|
||||||
|
from content_ranking_snapshot crs
|
||||||
|
where crs.ranking_type = :rankingType
|
||||||
|
and crs.visible_from_at = (
|
||||||
|
select max(latest.visible_from_at)
|
||||||
|
from content_ranking_snapshot latest
|
||||||
|
where latest.ranking_type = :rankingType
|
||||||
|
and latest.visible_from_at <= :nowUtc
|
||||||
|
)
|
||||||
|
order by crs.rank_no asc
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
fun findLatestVisibleSnapshots(
|
||||||
|
@Param("rankingType") rankingType: String,
|
||||||
|
@Param("nowUtc") nowUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshot>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
value = """
|
||||||
|
select *
|
||||||
|
from content_ranking_snapshot crs
|
||||||
|
where crs.ranking_type = :rankingType
|
||||||
|
and crs.aggregation_start_at_utc = (
|
||||||
|
select max(previous.aggregation_start_at_utc)
|
||||||
|
from content_ranking_snapshot previous
|
||||||
|
where previous.ranking_type = :rankingType
|
||||||
|
and previous.aggregation_start_at_utc < :currentAggregationStartAtUtc
|
||||||
|
and previous.visible_from_at <= :nowUtc
|
||||||
|
)
|
||||||
|
order by crs.rank_no asc
|
||||||
|
""",
|
||||||
|
nativeQuery = true
|
||||||
|
)
|
||||||
|
fun findPreviousVisibleSnapshots(
|
||||||
|
@Param("rankingType") rankingType: String,
|
||||||
|
@Param("currentAggregationStartAtUtc") currentAggregationStartAtUtc: LocalDateTime,
|
||||||
|
@Param("nowUtc") nowUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshot>
|
||||||
|
|
||||||
|
fun deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
|
aggregationEndAtUtc: LocalDateTime
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class DefaultAudioRankingSnapshotPersistenceAdapter(
|
||||||
|
private val repository: AudioRankingSnapshotRepository
|
||||||
|
) : AudioRankingSnapshotPort {
|
||||||
|
override fun findLatestVisibleSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotRecord> {
|
||||||
|
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findPreviousVisibleSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotRecord> {
|
||||||
|
return repository.findPreviousVisibleSnapshots(
|
||||||
|
rankingType = rankingType.name,
|
||||||
|
currentAggregationStartAtUtc = currentAggregationStartAtUtc,
|
||||||
|
nowUtc = nowUtc
|
||||||
|
).map { it.toRecord() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
override fun replaceSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
|
aggregationEndAtUtc: LocalDateTime,
|
||||||
|
visibleFromAtUtc: LocalDateTime,
|
||||||
|
newSnapshots: List<AudioRankingSnapshotRecord>
|
||||||
|
) {
|
||||||
|
repository.deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc(
|
||||||
|
rankingType = rankingType,
|
||||||
|
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||||
|
aggregationEndAtUtc = aggregationEndAtUtc
|
||||||
|
)
|
||||||
|
repository.saveAll(newSnapshots.map { it.toEntity(visibleFromAtUtc) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioRankingSnapshot.toRecord(): AudioRankingSnapshotRecord {
|
||||||
|
return AudioRankingSnapshotRecord(
|
||||||
|
rankingType = rankingType,
|
||||||
|
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||||
|
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||||
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
|
contentId = contentId,
|
||||||
|
title = title,
|
||||||
|
creatorMemberId = creatorMemberId,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
coverImageUrl = coverImageUrl,
|
||||||
|
releaseDate = releaseDate,
|
||||||
|
isAdult = isAdult,
|
||||||
|
rank = rank,
|
||||||
|
finalScore = finalScore,
|
||||||
|
normalizedScore = normalizedScore,
|
||||||
|
rawScore = rawScore,
|
||||||
|
revenueCanAmount = revenueCanAmount,
|
||||||
|
salesCount = salesCount,
|
||||||
|
viewCount = viewCount,
|
||||||
|
likeCount = likeCount,
|
||||||
|
commentCount = commentCount,
|
||||||
|
previousSalesCount = previousSalesCount,
|
||||||
|
previousViewCount = previousViewCount,
|
||||||
|
previousLikeCount = previousLikeCount,
|
||||||
|
previousCommentCount = previousCommentCount,
|
||||||
|
salesGrowthRate = salesGrowthRate,
|
||||||
|
viewGrowthRate = viewGrowthRate,
|
||||||
|
likeGrowthRate = likeGrowthRate,
|
||||||
|
commentGrowthRate = commentGrowthRate,
|
||||||
|
contentGrowthScore = contentGrowthScore,
|
||||||
|
boostMultiplier = boostMultiplier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AudioRankingSnapshotRecord.toEntity(visibleFromAtUtc: LocalDateTime): AudioRankingSnapshot {
|
||||||
|
return AudioRankingSnapshot(
|
||||||
|
rankingType = rankingType,
|
||||||
|
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||||
|
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||||
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
|
contentId = contentId,
|
||||||
|
title = title,
|
||||||
|
creatorMemberId = creatorMemberId,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
coverImageUrl = coverImageUrl,
|
||||||
|
releaseDate = releaseDate,
|
||||||
|
isAdult = isAdult,
|
||||||
|
rank = rank,
|
||||||
|
finalScore = finalScore,
|
||||||
|
normalizedScore = normalizedScore,
|
||||||
|
rawScore = rawScore,
|
||||||
|
revenueCanAmount = revenueCanAmount,
|
||||||
|
salesCount = salesCount,
|
||||||
|
viewCount = viewCount,
|
||||||
|
likeCount = likeCount,
|
||||||
|
commentCount = commentCount,
|
||||||
|
previousSalesCount = previousSalesCount,
|
||||||
|
previousViewCount = previousViewCount,
|
||||||
|
previousLikeCount = previousLikeCount,
|
||||||
|
previousCommentCount = previousCommentCount,
|
||||||
|
salesGrowthRate = salesGrowthRate,
|
||||||
|
viewGrowthRate = viewGrowthRate,
|
||||||
|
likeGrowthRate = likeGrowthRate,
|
||||||
|
commentGrowthRate = commentGrowthRate,
|
||||||
|
contentGrowthScore = contentGrowthScore,
|
||||||
|
boostMultiplier = boostMultiplier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.port.out
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
interface AudioRankingSnapshotPort {
|
||||||
|
fun findLatestVisibleSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotRecord>
|
||||||
|
|
||||||
|
fun findPreviousVisibleSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
currentAggregationStartAtUtc: LocalDateTime,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<AudioRankingSnapshotRecord>
|
||||||
|
|
||||||
|
fun replaceSnapshots(
|
||||||
|
rankingType: AudioRankingType,
|
||||||
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
|
aggregationEndAtUtc: LocalDateTime,
|
||||||
|
visibleFromAtUtc: LocalDateTime,
|
||||||
|
newSnapshots: List<AudioRankingSnapshotRecord>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AudioRankingSnapshotRecord(
|
||||||
|
val rankingType: AudioRankingType,
|
||||||
|
val aggregationStartAtUtc: LocalDateTime,
|
||||||
|
val aggregationEndAtUtc: LocalDateTime,
|
||||||
|
val visibleFromAtUtc: LocalDateTime,
|
||||||
|
val contentId: Long,
|
||||||
|
val title: String,
|
||||||
|
val creatorMemberId: Long,
|
||||||
|
val creatorNickname: String,
|
||||||
|
val coverImageUrl: String?,
|
||||||
|
val releaseDate: LocalDateTime,
|
||||||
|
val isAdult: Boolean,
|
||||||
|
val rank: Int,
|
||||||
|
val finalScore: Double,
|
||||||
|
val normalizedScore: Double? = null,
|
||||||
|
val rawScore: Double? = null,
|
||||||
|
val revenueCanAmount: Long? = null,
|
||||||
|
val salesCount: Long? = null,
|
||||||
|
val viewCount: Long? = null,
|
||||||
|
val likeCount: Long? = null,
|
||||||
|
val commentCount: Long? = null,
|
||||||
|
val previousSalesCount: Long? = null,
|
||||||
|
val previousViewCount: Long? = null,
|
||||||
|
val previousLikeCount: Long? = null,
|
||||||
|
val previousCommentCount: Long? = null,
|
||||||
|
val salesGrowthRate: Double? = null,
|
||||||
|
val viewGrowthRate: Double? = null,
|
||||||
|
val likeGrowthRate: Double? = null,
|
||||||
|
val commentGrowthRate: Double? = null,
|
||||||
|
val contentGrowthScore: Double? = null,
|
||||||
|
val boostMultiplier: Double? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package kr.co.vividnext.sodalive.v2.content.ranking.adapter.out.persistence
|
||||||
|
|
||||||
|
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.domain.AudioRankingType
|
||||||
|
import kr.co.vividnext.sodalive.v2.content.ranking.port.out.AudioRankingSnapshotRecord
|
||||||
|
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
|
||||||
|
|
||||||
|
@DataJpaTest(
|
||||||
|
properties = [
|
||||||
|
"spring.cache.type=none",
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(QueryDslConfig::class)
|
||||||
|
class DefaultAudioRankingSnapshotPersistenceAdapterTest @Autowired constructor(
|
||||||
|
private val repository: AudioRankingSnapshotRepository
|
||||||
|
) {
|
||||||
|
private val adapter = DefaultAudioRankingSnapshotPersistenceAdapter(repository)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최신 visible 스냅샷만 랭킹 타입별 rank 순서로 조회한다")
|
||||||
|
fun shouldFindLatestVisibleSnapshotsByRankingTypeAndVisibleFromAt() {
|
||||||
|
val previousVisibleAt = LocalDateTime.of(2026, 6, 1, 0, 0)
|
||||||
|
val latestVisibleAt = LocalDateTime.of(2026, 6, 8, 0, 0)
|
||||||
|
val hiddenVisibleAt = LocalDateTime.of(2026, 6, 15, 0, 0)
|
||||||
|
repository.saveAll(
|
||||||
|
listOf(
|
||||||
|
snapshot(
|
||||||
|
contentId = 1L,
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
visibleFromAtUtc = previousVisibleAt
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
contentId = 2L,
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
visibleFromAtUtc = latestVisibleAt,
|
||||||
|
rank = 2
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
contentId = 3L,
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
visibleFromAtUtc = latestVisibleAt,
|
||||||
|
rank = 1
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
contentId = 4L,
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
visibleFromAtUtc = hiddenVisibleAt
|
||||||
|
),
|
||||||
|
snapshot(contentId = 5L, rankingType = AudioRankingType.RISING, visibleFromAtUtc = latestVisibleAt)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshots = adapter.findLatestVisibleSnapshots(
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 6, 8, 23, 59)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(3L, 2L), snapshots.map { it.contentId })
|
||||||
|
assertEquals(listOf(latestVisibleAt, latestVisibleAt), snapshots.map { it.visibleFromAtUtc })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("09시 전 생성된 신규 스냅샷은 visible 전까지 이전 visible 스냅샷을 반환한다")
|
||||||
|
fun shouldReturnPreviousVisibleSnapshotsBeforeNewVisibleFromAt() {
|
||||||
|
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
|
||||||
|
val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||||
|
val currentStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||||
|
val currentEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
||||||
|
repository.saveAll(
|
||||||
|
listOf(
|
||||||
|
snapshot(
|
||||||
|
contentId = 1L,
|
||||||
|
aggregationStartAtUtc = previousStartAt,
|
||||||
|
aggregationEndAtUtc = previousEndAt,
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 1, 0, 0)
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
contentId = 2L,
|
||||||
|
aggregationStartAtUtc = currentStartAt,
|
||||||
|
aggregationEndAtUtc = currentEndAt,
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val snapshots = adapter.findLatestVisibleSnapshots(
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(1L), snapshots.map { it.contentId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("스냅샷 교체는 같은 랭킹 타입과 집계 기간 row만 삭제한다")
|
||||||
|
fun shouldReplaceSnapshotsOnlyForSameRankingTypeAndPeriod() {
|
||||||
|
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||||
|
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
|
||||||
|
val visibleAt = LocalDateTime.of(2026, 6, 8, 0, 0)
|
||||||
|
repository.saveAll(
|
||||||
|
listOf(
|
||||||
|
snapshot(
|
||||||
|
contentId = 1L,
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
aggregationStartAtUtc = startAt,
|
||||||
|
aggregationEndAtUtc = endAt
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
contentId = 2L,
|
||||||
|
rankingType = AudioRankingType.RISING,
|
||||||
|
aggregationStartAtUtc = startAt,
|
||||||
|
aggregationEndAtUtc = endAt
|
||||||
|
),
|
||||||
|
snapshot(
|
||||||
|
contentId = 3L,
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
aggregationStartAtUtc = startAt.minusWeeks(1),
|
||||||
|
aggregationEndAtUtc = endAt.minusWeeks(1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.replaceSnapshots(
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
aggregationStartAtUtc = startAt,
|
||||||
|
aggregationEndAtUtc = endAt,
|
||||||
|
visibleFromAtUtc = visibleAt,
|
||||||
|
newSnapshots = listOf(
|
||||||
|
snapshotRecord(
|
||||||
|
contentId = 4L,
|
||||||
|
aggregationStartAtUtc = startAt,
|
||||||
|
aggregationEndAtUtc = endAt,
|
||||||
|
visibleFromAtUtc = visibleAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val all = repository.findAll().map { it.contentId }.sorted()
|
||||||
|
assertEquals(listOf(2L, 3L, 4L), all)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshot(
|
||||||
|
contentId: Long,
|
||||||
|
rankingType: AudioRankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
aggregationStartAtUtc: LocalDateTime = LocalDateTime.of(2026, 5, 31, 15, 0),
|
||||||
|
aggregationEndAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 7, 15, 0),
|
||||||
|
visibleFromAtUtc: LocalDateTime = LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||||
|
rank: Int = 1,
|
||||||
|
isAdult: Boolean = false
|
||||||
|
): AudioRankingSnapshot {
|
||||||
|
return AudioRankingSnapshot(
|
||||||
|
rankingType = rankingType,
|
||||||
|
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||||
|
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||||
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
|
contentId = contentId,
|
||||||
|
title = "audio-$contentId",
|
||||||
|
creatorMemberId = 100L + contentId,
|
||||||
|
creatorNickname = "creator-$contentId",
|
||||||
|
coverImageUrl = "cover-$contentId.png",
|
||||||
|
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||||
|
isAdult = isAdult,
|
||||||
|
rank = rank,
|
||||||
|
finalScore = 100.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshotRecord(
|
||||||
|
contentId: Long,
|
||||||
|
aggregationStartAtUtc: LocalDateTime,
|
||||||
|
aggregationEndAtUtc: LocalDateTime,
|
||||||
|
visibleFromAtUtc: LocalDateTime,
|
||||||
|
isAdult: Boolean = false
|
||||||
|
): AudioRankingSnapshotRecord {
|
||||||
|
return AudioRankingSnapshotRecord(
|
||||||
|
rankingType = AudioRankingType.WEEKLY_POPULAR,
|
||||||
|
aggregationStartAtUtc = aggregationStartAtUtc,
|
||||||
|
aggregationEndAtUtc = aggregationEndAtUtc,
|
||||||
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
|
contentId = contentId,
|
||||||
|
title = "audio-$contentId",
|
||||||
|
creatorMemberId = 100L + contentId,
|
||||||
|
creatorNickname = "creator-$contentId",
|
||||||
|
coverImageUrl = "cover-$contentId.png",
|
||||||
|
releaseDate = LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||||
|
isAdult = isAdult,
|
||||||
|
rank = 1,
|
||||||
|
finalScore = 100.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user