From da1a63da23d05f73903ec8f4bef0af8306305f23 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 24 Jun 2026 23:44:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=83=B7=20=EA=B3=B5=EA=B0=9C=20=EC=A1=B0=ED=9A=8C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/CreatorRankingSnapshot.kt | 10 ++ .../CreatorRankingSnapshotRepository.kt | 46 ++++++- ...DefaultCreatorRankingSnapshotRepository.kt | 36 +++++- .../port/out/CreatorRankingSnapshotPort.kt | 16 +++ ...ultCreatorRankingSnapshotRepositoryTest.kt | 116 +++++++++++++++++- 5 files changed, 217 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt index c94c370f..9968d035 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshot.kt @@ -1,20 +1,30 @@ package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType 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 = "creator_ranking_snapshot") 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) 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 = "creator_id", nullable = false, updatable = false) val creatorId: Long, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt index ac3f78a9..2cc1e9c0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/CreatorRankingSnapshotRepository.kt @@ -1,5 +1,6 @@ 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.Query import org.springframework.data.repository.query.Param @@ -43,7 +44,50 @@ interface CreatorRankingSnapshotRepository : JpaRepository - 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 + + @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 + + fun deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc( + @Param("rankingType") rankingType: CreatorRankingType, @Param("aggregationStartAtUtc") aggregationStartAtUtc: LocalDateTime, @Param("aggregationEndAtUtc") aggregationEndAtUtc: LocalDateTime ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt index 1c2ec6ed..ae1c9ce3 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepository.kt @@ -1,5 +1,6 @@ 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.CreatorRankingSnapshotRecord import org.springframework.stereotype.Repository @@ -28,27 +29,51 @@ class DefaultCreatorRankingSnapshotRepository( return repository.findPreviousCompletedSnapshots().map { it.toRecord() } } + override fun findLatestVisibleSnapshots( + rankingType: CreatorRankingType, + nowUtc: LocalDateTime + ): List { + return repository.findLatestVisibleSnapshots(rankingType.name, nowUtc).map { it.toRecord() } + } + + override fun findPreviousVisibleSnapshots( + rankingType: CreatorRankingType, + currentAggregationStartAtUtc: LocalDateTime, + nowUtc: LocalDateTime + ): List { + return repository.findPreviousVisibleSnapshots( + rankingType = rankingType.name, + currentAggregationStartAtUtc = currentAggregationStartAtUtc, + nowUtc = nowUtc + ).map { it.toRecord() } + } + override fun isSnapshotTableEmpty(): Boolean { return repository.count() == 0L } @Transactional override fun replaceSnapshots( + rankingType: CreatorRankingType, aggregationStartAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, newSnapshots: List ) { - repository.deleteByAggregationStartAtUtcAndAggregationEndAtUtc( + repository.deleteByRankingTypeAndAggregationStartAtUtcAndAggregationEndAtUtc( + rankingType = rankingType, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc ) - repository.saveAll(newSnapshots.map { it.toEntity() }) + repository.saveAll(newSnapshots.map { it.toEntity(rankingType, visibleFromAtUtc) }) } private fun CreatorRankingSnapshot.toRecord(): CreatorRankingSnapshotRecord { return CreatorRankingSnapshotRecord( + rankingType = rankingType, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, creatorId = creatorId, nickname = nickname, profileImageUrl = profileImageUrl, @@ -69,10 +94,15 @@ class DefaultCreatorRankingSnapshotRepository( ) } - private fun CreatorRankingSnapshotRecord.toEntity(): CreatorRankingSnapshot { + private fun CreatorRankingSnapshotRecord.toEntity( + rankingType: CreatorRankingType, + visibleFromAtUtc: LocalDateTime + ): CreatorRankingSnapshot { return CreatorRankingSnapshot( + rankingType = rankingType, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, creatorId = creatorId, nickname = nickname, profileImageUrl = profileImageUrl, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt index dd49e25c..33904ee8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/port/out/CreatorRankingSnapshotPort.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.ranking.port.out +import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import java.time.LocalDateTime interface CreatorRankingSnapshotPort { @@ -12,18 +13,33 @@ interface CreatorRankingSnapshotPort { fun findPreviousCompletedSnapshots(): List + fun findLatestVisibleSnapshots( + rankingType: CreatorRankingType, + nowUtc: LocalDateTime + ): List + + fun findPreviousVisibleSnapshots( + rankingType: CreatorRankingType, + currentAggregationStartAtUtc: LocalDateTime, + nowUtc: LocalDateTime + ): List + fun isSnapshotTableEmpty(): Boolean fun replaceSnapshots( + rankingType: CreatorRankingType, aggregationStartAtUtc: LocalDateTime, aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime, newSnapshots: List ) } data class CreatorRankingSnapshotRecord( + val rankingType: CreatorRankingType, val aggregationStartAtUtc: LocalDateTime, val aggregationEndAtUtc: LocalDateTime, + val visibleFromAtUtc: LocalDateTime, val creatorId: Long, val nickname: String, val profileImageUrl: String?, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt index 062db614..8118cff9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/adapter/out/persistence/DefaultCreatorRankingSnapshotRepositoryTest.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence 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 org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName @@ -37,8 +38,10 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( ) adapter.replaceSnapshots( + rankingType = CreatorRankingType.WEEKLY, aggregationStartAtUtc = startAt, aggregationEndAtUtc = endAt, + visibleFromAtUtc = endAt.plusHours(9), 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 }) } + @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 @DisplayName("최신 완료 주차 스냅샷은 최신 종료 시각 기준으로 최종 점수 내림차순 조회한다") fun shouldFindLatestSnapshotsByLatestAggregationEndAndFinalScoreDescending() { @@ -90,6 +115,79 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( 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 @DisplayName("직전 완료 주차 스냅샷은 최신 종료 시각보다 이전인 가장 큰 종료 시각 기준으로 조회한다") fun shouldFindPreviousCompletedSnapshotsBeforeLatestPeriod() { @@ -181,7 +279,13 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( 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() assertEquals(22, latestSnapshots.size) @@ -192,11 +296,14 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( creatorId: Long, finalScore: Double = 100.0, aggregationStartAtUtc: LocalDateTime, - aggregationEndAtUtc: LocalDateTime + aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9) ): CreatorRankingSnapshot { return CreatorRankingSnapshot( + rankingType = CreatorRankingType.WEEKLY, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, creatorId = creatorId, nickname = "creator-$creatorId", profileImageUrl = "profile-$creatorId.png", @@ -221,11 +328,14 @@ class DefaultCreatorRankingSnapshotRepositoryTest @Autowired constructor( creatorId: Long, finalScore: Double = 100.0, aggregationStartAtUtc: LocalDateTime, - aggregationEndAtUtc: LocalDateTime + aggregationEndAtUtc: LocalDateTime, + visibleFromAtUtc: LocalDateTime = aggregationEndAtUtc.plusHours(9) ): CreatorRankingSnapshotRecord { return CreatorRankingSnapshotRecord( + rankingType = CreatorRankingType.WEEKLY, aggregationStartAtUtc = aggregationStartAtUtc, aggregationEndAtUtc = aggregationEndAtUtc, + visibleFromAtUtc = visibleFromAtUtc, creatorId = creatorId, nickname = "creator-$creatorId", profileImageUrl = "profile-$creatorId.png",