From 2edd48652488619b1ccf07f6328edf2c26f8a9d2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 31 May 2026 00:57:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(recommend):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7=20=EC=A0=80=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/RecommendationSnapshot.kt | 30 +++++ ...ecommendationSnapshotPersistenceAdapter.kt | 47 ++++++++ .../RecommendationSnapshotRepository.kt | 16 +++ .../port/out/RecommendationSnapshotPort.kt | 22 ++++ ...mendationSnapshotPersistenceAdapterTest.kt | 109 ++++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshot.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/RecommendationSnapshotPort.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshot.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshot.kt new file mode 100644 index 00000000..b5ae3448 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshot.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence + +import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +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 = "recommendation_snapshot") +class RecommendationSnapshot( + @Enumerated(EnumType.STRING) + @Column(name = "section_type", nullable = false, updatable = false, length = 50) + val sectionType: RecommendedSectionType, + + @Column(name = "target_id", nullable = false, updatable = false) + val targetId: Long, + + @Column(name = "score", nullable = false, updatable = false) + val score: Double, + + @Column(name = "snapshot_at", nullable = false, updatable = false) + val snapshotAt: LocalDateTime, + + @Column(name = "random_tie_breaker", nullable = false, updatable = false) + val randomTieBreaker: Double +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt new file mode 100644 index 00000000..a469fcff --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt @@ -0,0 +1,47 @@ +package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +class RecommendationSnapshotPersistenceAdapter( + private val repository: RecommendationSnapshotRepository +) : RecommendationSnapshotPort { + override fun findLatestSnapshots(sectionType: RecommendedSectionType): List { + val snapshotAt = repository.findTopBySectionTypeOrderBySnapshotAtDesc(sectionType)?.snapshotAt ?: return emptyList() + return repository.findAllBySectionTypeAndSnapshotAtOrderByScoreDescRandomTieBreakerAsc(sectionType, snapshotAt) + .map { it.toRecord() } + } + + override fun replaceSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + newSnapshots: List + ) { + repository.deleteBySectionTypeAndSnapshotAt(sectionType, snapshotAt) + repository.saveAll(newSnapshots.map { it.toEntity() }) + } + + private fun RecommendationSnapshot.toRecord(): RecommendationSnapshotRecord { + return RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = targetId, + score = score, + snapshotAt = snapshotAt, + randomTieBreaker = randomTieBreaker + ) + } + + private fun RecommendationSnapshotRecord.toEntity(): RecommendationSnapshot { + return RecommendationSnapshot( + sectionType = sectionType, + targetId = targetId, + score = score, + snapshotAt = snapshotAt, + randomTieBreaker = randomTieBreaker + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt new file mode 100644 index 00000000..361058fb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotRepository.kt @@ -0,0 +1,16 @@ +package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence + +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime + +interface RecommendationSnapshotRepository : JpaRepository { + fun findTopBySectionTypeOrderBySnapshotAtDesc(sectionType: RecommendedSectionType): RecommendationSnapshot? + + fun findAllBySectionTypeAndSnapshotAtOrderByScoreDescRandomTieBreakerAsc( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime + ): List + + fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime) +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/RecommendationSnapshotPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/RecommendationSnapshotPort.kt new file mode 100644 index 00000000..f62eaab4 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/port/out/RecommendationSnapshotPort.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.v2.recommend.port.out + +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import java.time.LocalDateTime + +interface RecommendationSnapshotPort { + fun findLatestSnapshots(sectionType: RecommendedSectionType): List + + fun replaceSnapshots( + sectionType: RecommendedSectionType, + snapshotAt: LocalDateTime, + newSnapshots: List + ) +} + +data class RecommendationSnapshotRecord( + val sectionType: RecommendedSectionType, + val targetId: Long, + val score: Double, + val snapshotAt: LocalDateTime, + val randomTieBreaker: Double +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt new file mode 100644 index 00000000..a84d470a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt @@ -0,0 +1,109 @@ +package kr.co.vividnext.sodalive.v2.recommend.adapter.out.persistence + +import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.v2.recommend.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommend.port.out.RecommendationSnapshotRecord +import org.junit.jupiter.api.Assertions.assertEquals +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 RecommendationSnapshotPersistenceAdapterTest @Autowired constructor( + private val repository: RecommendationSnapshotRepository +) { + private val adapter = RecommendationSnapshotPersistenceAdapter(repository) + + @Test + fun shouldFindLatestSnapshotsByLatestSnapshotAtAndScoreDescendingTieBreakerAscending() { + val oldSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) + val latestSnapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59) + repository.saveAll( + listOf( + snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 1L, score = 999.0, snapshotAt = oldSnapshotAt), + snapshot( + RecommendedSectionType.AI_CHARACTER, + targetId = 2L, + score = 100.0, + snapshotAt = latestSnapshotAt, + randomTieBreaker = 0.9 + ), + snapshot( + RecommendedSectionType.AI_CHARACTER, + targetId = 3L, + score = 200.0, + snapshotAt = latestSnapshotAt, + randomTieBreaker = 0.8 + ), + snapshot( + RecommendedSectionType.AI_CHARACTER, + targetId = 4L, + score = 100.0, + snapshotAt = latestSnapshotAt, + randomTieBreaker = 0.1 + ), + snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 5L, score = 300.0, snapshotAt = latestSnapshotAt) + ) + ) + + val latestSnapshots = adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER) + + assertEquals(listOf(3L, 4L, 2L), latestSnapshots.map { it.targetId }) + assertEquals(listOf(latestSnapshotAt, latestSnapshotAt, latestSnapshotAt), latestSnapshots.map { it.snapshotAt }) + } + + @Test + fun shouldReplaceSnapshotsByDeletingSameSectionSnapshotAtOnly() { + 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.AI_CHARACTER, targetId = 1L, score = 100.0, snapshotAt = oldSnapshotAt), + snapshot(RecommendedSectionType.AI_CHARACTER, targetId = 2L, score = 200.0, snapshotAt = snapshotAt), + snapshot(RecommendedSectionType.CHEER_CREATOR, targetId = 3L, score = 300.0, snapshotAt = snapshotAt) + ) + ) + + adapter.replaceSnapshots( + RecommendedSectionType.AI_CHARACTER, + snapshotAt, + listOf( + RecommendationSnapshotRecord( + sectionType = RecommendedSectionType.AI_CHARACTER, + targetId = 4L, + score = 400.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.4 + ) + ) + ) + + assertEquals(listOf(4L), adapter.findLatestSnapshots(RecommendedSectionType.AI_CHARACTER).map { it.targetId }) + assertEquals(listOf(3L), adapter.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).map { it.targetId }) + assertEquals(3, repository.findAll().size) + } + + private fun snapshot( + sectionType: RecommendedSectionType, + targetId: Long, + score: Double, + snapshotAt: LocalDateTime, + randomTieBreaker: Double = 0.1 + ): RecommendationSnapshot { + return RecommendationSnapshot( + sectionType = sectionType, + targetId = targetId, + score = score, + snapshotAt = snapshotAt, + randomTieBreaker = randomTieBreaker + ) + } +}