feat(content-ranking): 랭킹 스냅샷 저장소를 추가한다

This commit is contained in:
2026-06-24 16:19:50 +09:00
parent 25c48a7606
commit f1e03706c7
6 changed files with 540 additions and 0 deletions

View File

@@ -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()

View File

@@ -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
)
}

View File

@@ -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
)
}
}

View File

@@ -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
)