test #433
@@ -1,6 +1,7 @@
|
||||
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
||||
@@ -13,8 +14,10 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQue
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.Clock
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
@@ -22,8 +25,12 @@ import java.time.LocalDateTime
|
||||
class HomeRecommendationQueryService(
|
||||
private val queryPort: HomeRecommendationQueryPort,
|
||||
private val snapshotPort: RecommendationSnapshotPort,
|
||||
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null
|
||||
private val aiCharacterSnapshotFallbackService: AiCharacterSnapshotFallbackPort? = null,
|
||||
private val cheerCreatorSnapshotFallbackService: CheerCreatorSnapshotFallbackPort? = null,
|
||||
private val clock: Clock = Clock.systemUTC()
|
||||
) {
|
||||
private val snapshotWindowPolicy = RecommendationSnapshotWindowPolicy()
|
||||
|
||||
fun findLiveRecommendations(
|
||||
offset: Long = 0,
|
||||
limit: Int = DEFAULT_LIVE_LIMIT,
|
||||
@@ -95,13 +102,28 @@ class HomeRecommendationQueryService(
|
||||
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT,
|
||||
memberId: Long? = null
|
||||
): List<HomeCheerCreatorRecommendationRecord> {
|
||||
val snapshots = snapshotPort.findLatestSnapshots(RecommendedSectionType.CHEER_CREATOR).take(CHEER_CREATOR_CANDIDATE_LIMIT)
|
||||
val snapshots = findCheerCreatorSnapshotsWithFallback()
|
||||
val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId)
|
||||
.associateBy { it.creatorId }
|
||||
|
||||
return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit)
|
||||
}
|
||||
|
||||
private fun findCheerCreatorSnapshotsWithFallback(): List<RecommendationSnapshotRecord> {
|
||||
val nowUtc = LocalDateTime.now(clock)
|
||||
val snapshotAt = snapshotWindowPolicy.previousKstDayUtcWindow(nowUtc).snapshotAt
|
||||
val snapshots = snapshotPort.findSnapshots(
|
||||
RecommendedSectionType.CHEER_CREATOR,
|
||||
snapshotAt,
|
||||
limit = CHEER_CREATOR_CANDIDATE_LIMIT
|
||||
)
|
||||
if (snapshots.isNotEmpty()) return snapshots
|
||||
if (snapshotPort.existsSnapshot(RecommendedSectionType.CHEER_CREATOR, snapshotAt)) return emptyList()
|
||||
return cheerCreatorSnapshotFallbackService
|
||||
?.refreshCheerCreatorIfMissing(offset = 0, limit = CHEER_CREATOR_CANDIDATE_LIMIT, nowUtc = nowUtc)
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
fun findPopularCommunityRecommendations(
|
||||
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
|
||||
memberId: Long? = null,
|
||||
|
||||
@@ -18,7 +18,10 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapsho
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
|
||||
class HomeRecommendationQueryServiceTest {
|
||||
private val port = FakeHomeRecommendationQueryPort()
|
||||
@@ -280,7 +283,14 @@ class HomeRecommendationQueryServiceTest {
|
||||
@Test
|
||||
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
|
||||
fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() {
|
||||
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
|
||||
val fallback = FakeCheerCreatorSnapshotFallbackPort(emptyList())
|
||||
val service = HomeRecommendationQueryService(
|
||||
port,
|
||||
snapshotPort,
|
||||
cheerCreatorSnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
)
|
||||
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.CHEER_CREATOR,
|
||||
snapshotAt,
|
||||
@@ -303,12 +313,104 @@ class HomeRecommendationQueryServiceTest {
|
||||
|
||||
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
|
||||
|
||||
assertEquals(null, fallback.offset)
|
||||
assertEquals(null, fallback.limit)
|
||||
assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds)
|
||||
assertEquals(104L, port.cheerCreatorMemberId)
|
||||
assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
|
||||
assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷이 없으면 fallback refresh 후 스냅샷 상세를 조립한다")
|
||||
fun shouldFindCheerCreatorsAfterFallbackWhenLatestSnapshotsDoNotExist() {
|
||||
val fallback = FakeCheerCreatorSnapshotFallbackPort(
|
||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||
)
|
||||
val service = HomeRecommendationQueryService(
|
||||
port,
|
||||
snapshotPort,
|
||||
cheerCreatorSnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
)
|
||||
port.cheerCreatorDetails = listOf(
|
||||
HomeCheerCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "fallback-creator",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
|
||||
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
|
||||
|
||||
assertEquals(0L, fallback.offset)
|
||||
assertEquals(16, fallback.limit)
|
||||
assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc)
|
||||
assertEquals(listOf(10L), port.cheerCreatorDetailIds)
|
||||
assertEquals(listOf(10L), creators.map { it.creatorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 응원 크리에이터 추천은 오래된 스냅샷만 있으면 fallback refresh를 호출한다")
|
||||
fun shouldFallbackWhenOnlyStaleCheerCreatorSnapshotsExist() {
|
||||
val fallback = FakeCheerCreatorSnapshotFallbackPort(
|
||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 10L, 10.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||
)
|
||||
val service = HomeRecommendationQueryService(
|
||||
port,
|
||||
snapshotPort,
|
||||
cheerCreatorSnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
)
|
||||
val staleSnapshotAt = LocalDateTime.of(2026, 7, 8, 14, 59, 59)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.CHEER_CREATOR,
|
||||
staleSnapshotAt,
|
||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, 99.0, staleSnapshotAt))
|
||||
)
|
||||
port.cheerCreatorDetails = listOf(
|
||||
HomeCheerCreatorRecommendationRecord(
|
||||
creatorId = 10L,
|
||||
creatorNickname = "fallback-creator",
|
||||
creatorProfileImage = null
|
||||
)
|
||||
)
|
||||
|
||||
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
|
||||
|
||||
assertEquals(0L, fallback.offset)
|
||||
assertEquals(16, fallback.limit)
|
||||
assertEquals(LocalDateTime.of(2026, 7, 9, 21, 0), fallback.nowUtc)
|
||||
assertEquals(listOf(10L), port.cheerCreatorDetailIds)
|
||||
assertEquals(listOf(10L), creators.map { it.creatorId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 응원 크리에이터 추천은 빈 스냅샷 refresh 완료 marker가 있으면 fallback refresh를 반복하지 않는다")
|
||||
fun shouldNotFallbackWhenEmptyCheerCreatorSnapshotCycleExists() {
|
||||
val fallback = FakeCheerCreatorSnapshotFallbackPort(
|
||||
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L, 99.0, LocalDateTime.of(2026, 7, 9, 14, 59, 59)))
|
||||
)
|
||||
val service = HomeRecommendationQueryService(
|
||||
port,
|
||||
snapshotPort,
|
||||
cheerCreatorSnapshotFallbackService = fallback,
|
||||
clock = fixedClock()
|
||||
)
|
||||
snapshotPort.replaceSnapshots(
|
||||
RecommendedSectionType.CHEER_CREATOR,
|
||||
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
|
||||
emptyList()
|
||||
)
|
||||
|
||||
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
|
||||
|
||||
assertEquals(null, fallback.offset)
|
||||
assertEquals(null, fallback.limit)
|
||||
assertEquals(emptyList<Long>(), port.cheerCreatorDetailIds)
|
||||
assertEquals(emptyList<HomeCheerCreatorRecommendationRecord>(), creators)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
||||
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
||||
@@ -861,7 +963,7 @@ class HomeRecommendationQueryServiceTest {
|
||||
|
||||
override fun findCheerCreatorSnapshots(
|
||||
windowStart: LocalDateTime,
|
||||
snapshotAt: LocalDateTime,
|
||||
windowEndExclusive: LocalDateTime,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> = emptyList()
|
||||
|
||||
@@ -931,17 +1033,35 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||
return all.drop(offset.toInt()).take(limit)
|
||||
}
|
||||
|
||||
override fun findSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
val all = snapshots
|
||||
.filter { it.sectionType == sectionType && it.snapshotAt == snapshotAt && it.targetId != 0L }
|
||||
.sortedWith(compareByDescending<RecommendationSnapshotRecord> { it.score }.thenBy { it.randomTieBreaker })
|
||||
|
||||
if (offset == 0L && limit == Int.MAX_VALUE) return all
|
||||
return all.drop(offset.toInt()).take(limit)
|
||||
}
|
||||
|
||||
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
|
||||
return snapshots.any { it.sectionType == sectionType }
|
||||
}
|
||||
|
||||
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
|
||||
return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
}
|
||||
|
||||
override fun replaceSnapshots(
|
||||
sectionType: RecommendedSectionType,
|
||||
snapshotAt: LocalDateTime,
|
||||
newSnapshots: List<RecommendationSnapshotRecord>
|
||||
) {
|
||||
if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) {
|
||||
snapshots.removeIf { it.sectionType == sectionType }
|
||||
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
|
||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt))
|
||||
return
|
||||
}
|
||||
@@ -949,6 +1069,33 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||
snapshots.addAll(newSnapshots)
|
||||
}
|
||||
|
||||
private fun supportsEmptySnapshotMarker(sectionType: RecommendedSectionType): Boolean {
|
||||
return sectionType == RecommendedSectionType.AI_CHARACTER || sectionType == RecommendedSectionType.CHEER_CREATOR
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixedClock(): Clock {
|
||||
return Clock.fixed(Instant.parse("2026-07-09T21:00:00Z"), ZoneOffset.UTC)
|
||||
}
|
||||
|
||||
private class FakeCheerCreatorSnapshotFallbackPort(
|
||||
private val snapshots: List<RecommendationSnapshotRecord>
|
||||
) : CheerCreatorSnapshotFallbackPort {
|
||||
var offset: Long? = null
|
||||
var limit: Int? = null
|
||||
var nowUtc: LocalDateTime? = null
|
||||
|
||||
override fun refreshCheerCreatorIfMissing(
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
nowUtc: LocalDateTime
|
||||
): List<RecommendationSnapshotRecord> {
|
||||
this.offset = offset
|
||||
this.limit = limit
|
||||
this.nowUtc = nowUtc
|
||||
return snapshots
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeAiCharacterSnapshotFallbackPort(
|
||||
|
||||
Reference in New Issue
Block a user