feat(content-ranking): 스냅샷 공개 조회 저장소를 추가한다

This commit is contained in:
2026-06-24 23:44:58 +09:00
parent 9489458b35
commit da1a63da23
5 changed files with 217 additions and 7 deletions

View File

@@ -1,20 +1,30 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import java.time.LocalDateTime import java.time.LocalDateTime
import javax.persistence.Column import javax.persistence.Column
import javax.persistence.Entity import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.Table import javax.persistence.Table
@Entity @Entity
@Table(name = "creator_ranking_snapshot") @Table(name = "creator_ranking_snapshot")
class CreatorRankingSnapshot( class CreatorRankingSnapshot(
@Enumerated(EnumType.STRING)
@Column(name = "ranking_type", nullable = false, updatable = false, length = 30)
val rankingType: CreatorRankingType,
@Column(name = "aggregation_start_at_utc", nullable = false, updatable = false) @Column(name = "aggregation_start_at_utc", nullable = false, updatable = false)
val aggregationStartAtUtc: LocalDateTime, val aggregationStartAtUtc: LocalDateTime,
@Column(name = "aggregation_end_at_utc", nullable = false, updatable = false) @Column(name = "aggregation_end_at_utc", nullable = false, updatable = false)
val aggregationEndAtUtc: LocalDateTime, val aggregationEndAtUtc: LocalDateTime,
@Column(name = "visible_from_at", nullable = false, updatable = false)
val visibleFromAtUtc: LocalDateTime,
@Column(name = "creator_id", nullable = false, updatable = false) @Column(name = "creator_id", nullable = false, updatable = false)
val creatorId: Long, val creatorId: Long,

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
@@ -43,7 +44,50 @@ interface CreatorRankingSnapshotRepository : JpaRepository<CreatorRankingSnapsho
) )
fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshot> fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshot>
fun deleteByAggregationStartAtUtcAndAggregationEndAtUtc( @Query(
value = """
select *
from creator_ranking_snapshot crs
where crs.ranking_type = :rankingType
and crs.visible_from_at = (
select max(latest.visible_from_at)
from creator_ranking_snapshot latest
where latest.ranking_type = :rankingType
and latest.visible_from_at <= :nowUtc
)
order by crs.final_score desc
""",
nativeQuery = true
)
fun findLatestVisibleSnapshots(
@Param("rankingType") rankingType: String,
@Param("nowUtc") nowUtc: LocalDateTime
): List<CreatorRankingSnapshot>
@Query(
value = """
select *
from creator_ranking_snapshot crs
where crs.ranking_type = :rankingType
and crs.aggregation_start_at_utc = (
select max(previous.aggregation_start_at_utc)
from creator_ranking_snapshot previous
where previous.ranking_type = :rankingType
and previous.aggregation_start_at_utc < :currentAggregationStartAtUtc
and previous.visible_from_at <= :nowUtc
)
order by crs.final_score desc
""",
nativeQuery = true
)
fun findPreviousVisibleSnapshots(
@Param("rankingType") rankingType: String,
@Param("currentAggregationStartAtUtc") currentAggregationStartAtUtc: LocalDateTime,
@Param("nowUtc") nowUtc: LocalDateTime
): List<CreatorRankingSnapshot>
fun deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc(
@Param("rankingType") rankingType: CreatorRankingType,
@Param("aggregationStartAtUtc") aggregationStartAtUtc: LocalDateTime, @Param("aggregationStartAtUtc") aggregationStartAtUtc: LocalDateTime,
@Param("aggregationEndAtUtc") aggregationEndAtUtc: LocalDateTime @Param("aggregationEndAtUtc") aggregationEndAtUtc: LocalDateTime
) )

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotPort
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.springframework.stereotype.Repository import org.springframework.stereotype.Repository
@@ -28,27 +29,51 @@ class DefaultCreatorRankingSnapshotRepository(
return repository.findPreviousCompletedSnapshots().map { it.toRecord() } return repository.findPreviousCompletedSnapshots().map { it.toRecord() }
} }
override fun findLatestVisibleSnapshots(
rankingType: CreatorRankingType,
nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> {
return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() }
}
override fun findPreviousVisibleSnapshots(
rankingType: CreatorRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord> {
return repository.findPreviousVisibleSnapshots(
rankingType = rankingType.name,
currentAggregationStartAtUtc = currentAggregationStartAtUtc,
nowUtc = nowUtc
).map { it.toRecord() }
}
override fun isSnapshotTableEmpty(): Boolean { override fun isSnapshotTableEmpty(): Boolean {
return repository.count() == 0L return repository.count() == 0L
} }
@Transactional @Transactional
override fun replaceSnapshots( override fun replaceSnapshots(
rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
newSnapshots: List<CreatorRankingSnapshotRecord> newSnapshots: List<CreatorRankingSnapshotRecord>
) { ) {
repository.deleteByAggregationStartAtUtcAndAggregationEndAtUtc( repository.deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc(
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc aggregationEndAtUtc = aggregationEndAtUtc
) )
repository.saveAll(newSnapshots.map { it.toEntity() }) repository.saveAll(newSnapshots.map { it.toEntity(rankingType, visibleFromAtUtc) })
} }
private fun CreatorRankingSnapshot.toRecord(): CreatorRankingSnapshotRecord { private fun CreatorRankingSnapshot.toRecord(): CreatorRankingSnapshotRecord {
return CreatorRankingSnapshotRecord( return CreatorRankingSnapshotRecord(
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc, aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creatorId, creatorId = creatorId,
nickname = nickname, nickname = nickname,
profileImageUrl = profileImageUrl, profileImageUrl = profileImageUrl,
@@ -69,10 +94,15 @@ class DefaultCreatorRankingSnapshotRepository(
) )
} }
private fun CreatorRankingSnapshotRecord.toEntity(): CreatorRankingSnapshot { private fun CreatorRankingSnapshotRecord.toEntity(
rankingType: CreatorRankingType,
visibleFromAtUtc: LocalDateTime
): CreatorRankingSnapshot {
return CreatorRankingSnapshot( return CreatorRankingSnapshot(
rankingType = rankingType,
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc, aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creatorId, creatorId = creatorId,
nickname = nickname, nickname = nickname,
profileImageUrl = profileImageUrl, profileImageUrl = profileImageUrl,

View File

@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.ranking.port.out package kr.co.vividnext.sodalive.v2.ranking.port.out
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import java.time.LocalDateTime import java.time.LocalDateTime
interface CreatorRankingSnapshotPort { interface CreatorRankingSnapshotPort {
@@ -12,18 +13,33 @@ interface CreatorRankingSnapshotPort {
fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord> fun findPreviousCompletedSnapshots(): List<CreatorRankingSnapshotRecord>
fun findLatestVisibleSnapshots(
rankingType: CreatorRankingType,
nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord>
fun findPreviousVisibleSnapshots(
rankingType: CreatorRankingType,
currentAggregationStartAtUtc: LocalDateTime,
nowUtc: LocalDateTime
): List<CreatorRankingSnapshotRecord>
fun isSnapshotTableEmpty(): Boolean fun isSnapshotTableEmpty(): Boolean
fun replaceSnapshots( fun replaceSnapshots(
rankingType: CreatorRankingType,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime,
newSnapshots: List<CreatorRankingSnapshotRecord> newSnapshots: List<CreatorRankingSnapshotRecord>
) )
} }
data class CreatorRankingSnapshotRecord( data class CreatorRankingSnapshotRecord(
val rankingType: CreatorRankingType,
val aggregationStartAtUtc: LocalDateTime, val aggregationStartAtUtc: LocalDateTime,
val aggregationEndAtUtc: LocalDateTime, val aggregationEndAtUtc: LocalDateTime,
val visibleFromAtUtc: LocalDateTime,
val creatorId: Long, val creatorId: Long,
val nickname: String, val nickname: String,
val profileImageUrl: String?, val profileImageUrl: String?,

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotRecord
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@@ -37,8 +38,10 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor(
) )
adapter.replaceSnapshots( adapter.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt, aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt, aggregationEndAtUtc = endAt,
visibleFromAtUtc = endAt.plusHours(9),
newSnapshots = listOf(snapshotRecord(creatorId = 3L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)) newSnapshots = listOf(snapshotRecord(creatorId = 3L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt))
) )
@@ -47,6 +50,28 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor(
assertEquals(listOf(3L), adapter.findLatestSnapshots().map { it.creatorId }) assertEquals(listOf(3L), adapter.findLatestSnapshots().map { it.creatorId })
} }
@Test
@DisplayName("스냅샷은 랭킹 타입과 공개 노출 시각을 저장하고 같은 타입/기간만 교체한다")
fun shouldPersistRankingTypeAndVisibleFromAtAndReplaceByTypeAndPeriod() {
val startAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val endAt = LocalDateTime.of(2026, 6, 7, 15, 0)
val visibleFromAt = LocalDateTime.of(2026, 6, 8, 0, 0)
repository.save(snapshot(creatorId = 1L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt))
adapter.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = visibleFromAt,
newSnapshots = listOf(snapshotRecord(creatorId = 2L, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt))
)
val saved = repository.findAll().single()
assertEquals(CreatorRankingType.WEEKLY, saved.rankingType)
assertEquals(visibleFromAt, saved.visibleFromAtUtc)
assertEquals(2L, saved.creatorId)
}
@Test @Test
@DisplayName("최신 완료 주차 스냅샷은 최신 종료 시각 기준으로 최종 점수 내림차순 조회한다") @DisplayName("최신 완료 주차 스냅샷은 최신 종료 시각 기준으로 최종 점수 내림차순 조회한다")
fun shouldFindLatestSnapshotsByLatestAggregationEndAndFinalScoreDescending() { fun shouldFindLatestSnapshotsByLatestAggregationEndAndFinalScoreDescending() {
@@ -90,6 +115,79 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor(
assertEquals(listOf(latestEndAt, latestEndAt, latestEndAt), latestSnapshots.map { it.aggregationEndAtUtc }) assertEquals(listOf(latestEndAt, latestEndAt, latestEndAt), latestSnapshots.map { it.aggregationEndAtUtc })
} }
@Test
@DisplayName("최신 공개 스냅샷은 visibleFromAt이 현재 시각 이하인 최신 노출 시각 기준으로 조회한다")
fun shouldFindLatestVisibleSnapshotsByVisibleFromAt() {
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val previousEndAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
val latestEndAt = LocalDateTime.of(2026, 6, 7, 15, 0)
repository.saveAll(
listOf(
snapshot(
creatorId = 1L,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousEndAt,
visibleFromAtUtc = LocalDateTime.of(2026, 6, 1, 0, 0),
finalScore = 100.0
),
snapshot(
creatorId = 2L,
aggregationStartAtUtc = latestStartAt,
aggregationEndAtUtc = latestEndAt,
visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0),
finalScore = 200.0
)
)
)
val beforeVisible = adapter.findLatestVisibleSnapshots(
rankingType = CreatorRankingType.WEEKLY,
nowUtc = LocalDateTime.of(2026, 6, 7, 23, 59, 59)
)
val afterVisible = adapter.findLatestVisibleSnapshots(
rankingType = CreatorRankingType.WEEKLY,
nowUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
)
assertEquals(listOf(1L), beforeVisible.map { it.creatorId })
assertEquals(listOf(2L), afterVisible.map { it.creatorId })
}
@Test
@DisplayName("직전 공개 스냅샷은 현재 공개 스냅샷보다 이전 집계 시작 시각 중 최신 공개 기준으로 조회한다")
fun shouldFindPreviousVisibleSnapshotsBeforeCurrentVisibleSnapshot() {
val oldestStartAt = LocalDateTime.of(2026, 5, 17, 15, 0)
val previousStartAt = LocalDateTime.of(2026, 5, 24, 15, 0)
val latestStartAt = LocalDateTime.of(2026, 5, 31, 15, 0)
repository.saveAll(
listOf(
snapshot(creatorId = 1L, aggregationStartAtUtc = oldestStartAt, aggregationEndAtUtc = oldestStartAt.plusWeeks(1)),
snapshot(
creatorId = 2L,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousStartAt.plusWeeks(1),
finalScore = 300.0
),
snapshot(
creatorId = 3L,
aggregationStartAtUtc = previousStartAt,
aggregationEndAtUtc = previousStartAt.plusWeeks(1),
finalScore = 200.0
),
snapshot(creatorId = 4L, aggregationStartAtUtc = latestStartAt, aggregationEndAtUtc = latestStartAt.plusWeeks(1))
)
)
val previous = adapter.findPreviousVisibleSnapshots(
rankingType = CreatorRankingType.WEEKLY,
currentAggregationStartAtUtc = latestStartAt,
nowUtc = LocalDateTime.of(2026, 6, 8, 0, 0)
)
assertEquals(listOf(2L, 3L), previous.map { it.creatorId })
}
@Test @Test
@DisplayName("직전 완료 주차 스냅샷은 최신 종료 시각보다 이전인 가장 큰 종료 시각 기준으로 조회한다") @DisplayName("직전 완료 주차 스냅샷은 최신 종료 시각보다 이전인 가장 큰 종료 시각 기준으로 조회한다")
fun shouldFindPreviousCompletedSnapshotsBeforeLatestPeriod() { fun shouldFindPreviousCompletedSnapshotsBeforeLatestPeriod() {
@@ -181,7 +279,13 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor(
snapshotRecord(creatorId = 22L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt) snapshotRecord(creatorId = 22L, finalScore = 500.0, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt)
) )
adapter.replaceSnapshots(startAt, endAt, candidates) adapter.replaceSnapshots(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = startAt,
aggregationEndAtUtc = endAt,
visibleFromAtUtc = endAt.plusHours(9),
newSnapshots = candidates
)
val latestSnapshots = adapter.findLatestSnapshots() val latestSnapshots = adapter.findLatestSnapshots()
assertEquals(22, latestSnapshots.size) assertEquals(22, latestSnapshots.size)
@@ -192,11 +296,14 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor(
creatorId: Long, creatorId: Long,
finalScore: Double = 100.0, finalScore: Double = 100.0,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9)
): CreatorRankingSnapshot { ): CreatorRankingSnapshot {
return CreatorRankingSnapshot( return CreatorRankingSnapshot(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc, aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creatorId, creatorId = creatorId,
nickname = "creator-$creatorId", nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png", profileImageUrl = "profile-$creatorId.png",
@@ -221,11 +328,14 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor(
creatorId: Long, creatorId: Long,
finalScore: Double = 100.0, finalScore: Double = 100.0,
aggregationStartAtUtc: LocalDateTime, aggregationStartAtUtc: LocalDateTime,
aggregationEndAtUtc: LocalDateTime aggregationEndAtUtc: LocalDateTime,
visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9)
): CreatorRankingSnapshotRecord { ): CreatorRankingSnapshotRecord {
return CreatorRankingSnapshotRecord( return CreatorRankingSnapshotRecord(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = aggregationStartAtUtc, aggregationStartAtUtc = aggregationStartAtUtc,
aggregationEndAtUtc = aggregationEndAtUtc, aggregationEndAtUtc = aggregationEndAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creatorId, creatorId = creatorId,
nickname = "creator-$creatorId", nickname = "creator-$creatorId",
profileImageUrl = "profile-$creatorId.png", profileImageUrl = "profile-$creatorId.png",