feat(recommend): 추천 스냅샷 저장소를 추가한다
This commit is contained in:
@@ -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()
|
||||||
@@ -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<RecommendationSnapshotRecord> {
|
||||||
|
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<RecommendationSnapshotRecord>
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RecommendationSnapshot, Long> {
|
||||||
|
fun findTopBySectionTypeOrderBySnapshotAtDesc(sectionType: RecommendedSectionType): RecommendationSnapshot?
|
||||||
|
|
||||||
|
fun findAllBySectionTypeAndSnapshotAtOrderByScoreDescRandomTieBreakerAsc(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
snapshotAt: LocalDateTime
|
||||||
|
): List<RecommendationSnapshot>
|
||||||
|
|
||||||
|
fun deleteBySectionTypeAndSnapshotAt(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime)
|
||||||
|
}
|
||||||
@@ -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<RecommendationSnapshotRecord>
|
||||||
|
|
||||||
|
fun replaceSnapshots(
|
||||||
|
sectionType: RecommendedSectionType,
|
||||||
|
snapshotAt: LocalDateTime,
|
||||||
|
newSnapshots: List<RecommendationSnapshotRecord>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RecommendationSnapshotRecord(
|
||||||
|
val sectionType: RecommendedSectionType,
|
||||||
|
val targetId: Long,
|
||||||
|
val score: Double,
|
||||||
|
val snapshotAt: LocalDateTime,
|
||||||
|
val randomTieBreaker: Double
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user