From 64b05deee78ff16ed0e7f8b6850bddec2b68ea68 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 06:06:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=9D=91=EC=9B=90=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20marker=20=EC=A0=80=EC=9E=A5=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ecommendationSnapshotPersistenceAdapter.kt | 21 +++- .../RecommendationSnapshotRepository.kt | 23 +++- .../port/out/RecommendationSnapshotPort.kt | 9 ++ ...mendationSnapshotPersistenceAdapterTest.kt | 107 +++++++++++++++++- 4 files changed, 154 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt index f91f9386..9ea998f6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt @@ -18,17 +18,30 @@ class RecommendationSnapshotPersistenceAdapter( return repository.findLatestSnapshots(sectionType.name, offset, limit).map { it.toRecord() } } + override fun findSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + offset: Long, + limit: Int + ): List { + return repository.findSnapshots(sectionType.name, snapshotAt, offset, limit).map { it.toRecord() } + } + override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean { return repository.existsBySectionType(sectionType) } + override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { + return repository.existsBySectionTypeAndSnapshotAt(sectionType, snapshotAt) + } + override fun replaceSnapshots( sectionType: RecommendedSectionType, snapshotAt: LocalDateTime, newSnapshots: List ) { - if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) { - repository.deleteBySectionType(sectionType) + if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) { + repository.deleteBySectionTypeAndSnapshotAt(sectionType, snapshotAt) repository.save(emptySnapshotMarker(sectionType, snapshotAt).toEntity()) return } @@ -70,6 +83,10 @@ class RecommendationSnapshotPersistenceAdapter( ) } + private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean { + return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR + } + companion object { private const val EMPTY_SNAPSHOT_MARKER_TARGET_ID = 0L } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotRepository.kt index 1a231480..c850d9b0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotRepository.kt @@ -29,9 +29,28 @@ interface RecommendationSnapshotRepository : JpaRepository + @Query( + value = """ + select * + from recommendation_snapshot rs + where rs.section_type = :sectionType + and rs.snapshot_at = :snapshotAt + and rs.target_id <> 0 + order by rs.score desc, rs.random_tie_breaker asc + limit :limit offset :offset + """, + nativeQuery = true + ) + fun findSnapshots( + @Param("sectionType") sectionType: String, + @Param("snapshotAt") snapshotAt: LocalDateTime, + @Param("offset") offset: Long, + @Param("limit") limit: Int + ): List + fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime) - fun deleteBySectionType(sectionType: RecommendedSectionType) - fun existsBySectionType(sectionType: RecommendedSectionType): Boolean + + fun existsBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt index 38d37691..d5b7812c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/RecommendationSnapshotPort.kt @@ -10,8 +10,17 @@ interface RecommendationSnapshotPort { limit: Int = Int.MAX_VALUE ): List + fun findSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + offset: Long = 0, + limit: Int = Int.MAX_VALUE + ): List + fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean + fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean + fun replaceSnapshots( sectionType: RecommendedSectionType, snapshotAt: LocalDateTime, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt index bbc08264..2fcceb97 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt @@ -77,6 +77,61 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor( assertEquals(listOf(2L, 3L), snapshots.map { it.targetId }) } + @Test + fun shouldFindSnapshotsByExactSnapshotAtAndExcludeEmptyMarker() { + val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + repository.saveAll( + listOf( + snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 999.0, snapshotAt = oldSnapshotAt), + snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 0L, score = 0.0, snapshotAt = snapshotAt), + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 2L, + score = 100.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.9 + ), + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 3L, + score = 200.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.8 + ) + ) + ) + + val snapshots = adapter.findSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, offset = 0, limit = 10) + + assertEquals(listOf(3L, 2L), snapshots.map { it.targetId }) + assertEquals(listOf(snapshotAt, snapshotAt), snapshots.map { it.snapshotAt }) + } + + @Test + fun shouldCheckSnapshotExistenceByExactSnapshotAtIncludingEmptyMarker() { + val staleSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) + val expectedSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + repository.save( + snapshot( + RecommendedSectionType.CHEER_CREATOR, + targetId = 1L, + score = 100.0, + snapshotAt = staleSnapshotAt + ) + ) + + assertEquals(false, adapter.existsSnapshot(RecommendedSectionType.CHEER_CREATOR, expectedSnapshotAt)) + + adapter.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, expectedSnapshotAt, emptyList()) + + assertEquals(true, adapter.existsSnapshot(RecommendedSectionType.CHEER_CREATOR, expectedSnapshotAt)) + assertEquals( + emptyList(), + adapter.findSnapshots(RecommendedSectionType.CHEER_CREATOR, expectedSnapshotAt) + ) + } + @Test fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() { val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) @@ -109,7 +164,7 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor( } @Test - fun shouldClearAiCharacterSectionSnapshotsWhenReplacingWithEmptySnapshots() { + fun shouldSaveAiCharacterEmptySnapshotMarkerWhenReplacingWithEmptySnapshots() { val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) repository.saveAll( @@ -125,9 +180,10 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor( assertEquals(listOf(2L), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) assertEquals(true, adapter.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER)) assertEquals( - listOf(0L), + listOf(1L, 0L), repository.findAll() .filter { it.sectionType == RecommendedSectionType.AI_CHARACTER } + .sortedBy { it.snapshotAt } .map { it.targetId } ) } @@ -142,6 +198,53 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor( assertEquals(true, adapter.existsLatestSnapshot(RecommendedSectionType.AI_CHARACTER)) } + @Test + fun shouldSaveCheerCreatorEmptySnapshotMarkerWhenReplacingWithEmptySnapshots() { + val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + repository.save(snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 1L, score = 100.0, snapshotAt = oldSnapshotAt)) + + adapter.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, emptyList()) + + assertEquals(emptyList(), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR)) + assertEquals(true, adapter.existsLatestSnapshot(RecommendedSectionType.CHEER_CREATOR)) + assertEquals( + listOf(1L, 0L), + repository.findAll() + .filter { it.sectionType == RecommendedSectionType.CHEER_CREATOR } + .sortedBy { it.snapshotAt } + .map { it.targetId } + ) + } + + @Test + fun shouldReplaceCheerCreatorEmptyMarkerWithRealSnapshots() { + val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + adapter.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, emptyList()) + + adapter.replaceSnapshots( + RecommendedSectionType.CHEER_CREATOR, + snapshotAt, + listOf( + RecommendationSnapshotRecord( + sectionType = RecommendedSectionType.CHEER_CREATOR, + targetId = 9L, + score = 90.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.9 + ) + ) + ) + + assertEquals(listOf(9L), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) + assertEquals( + listOf(9L), + repository.findAll() + .filter { it.sectionType == RecommendedSectionType.CHEER_CREATOR } + .map { it.targetId } + ) + } + private fun snapshot( sectionType: RecommendedSectionType, targetId: Long,