feat(home): 응원 크리에이터 fallback을 홈 조회에 연결한다
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.recommendation.application
|
package kr.co.vividnext.sodalive.v2.recommendation.application
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
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.domain.RecommendedSectionType
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
|
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.RecentDebutCreatorRecord
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
|
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.RecommendationSnapshotPort
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.time.Clock
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -22,8 +25,12 @@ import java.time.LocalDateTime
|
|||||||
class HomeRecommendationQueryService(
|
class HomeRecommendationQueryService(
|
||||||
private val queryPort: HomeRecommendationQueryPort,
|
private val queryPort: HomeRecommendationQueryPort,
|
||||||
private val snapshotPort: RecommendationSnapshotPort,
|
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(
|
fun findLiveRecommendations(
|
||||||
offset: Long = 0,
|
offset: Long = 0,
|
||||||
limit: Int = DEFAULT_LIVE_LIMIT,
|
limit: Int = DEFAULT_LIVE_LIMIT,
|
||||||
@@ -95,13 +102,28 @@ class HomeRecommendationQueryService(
|
|||||||
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT,
|
limit: Int = DEFAULT_CHEER_CREATOR_LIMIT,
|
||||||
memberId: Long? = null
|
memberId: Long? = null
|
||||||
): List<HomeCheerCreatorRecommendationRecord> {
|
): 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)
|
val detailsById = queryPort.findCheerCreatorRecommendationDetails(snapshots.map { it.targetId }, memberId)
|
||||||
.associateBy { it.creatorId }
|
.associateBy { it.creatorId }
|
||||||
|
|
||||||
return snapshots.mapNotNull { detailsById[it.targetId] }.take(limit)
|
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(
|
fun findPopularCommunityRecommendations(
|
||||||
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
|
limit: Int = DEFAULT_POPULAR_COMMUNITY_LIMIT,
|
||||||
memberId: Long? = null,
|
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.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
class HomeRecommendationQueryServiceTest {
|
class HomeRecommendationQueryServiceTest {
|
||||||
private val port = FakeHomeRecommendationQueryPort()
|
private val port = FakeHomeRecommendationQueryPort()
|
||||||
@@ -280,7 +283,14 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
|
@DisplayName("최근 응원 크리에이터 추천은 최신 스냅샷 8명을 기준으로 닉네임과 프로필을 조립한다")
|
||||||
fun shouldFindCheerCreatorsFromLatestSnapshotsWithLimitAndDetails() {
|
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(
|
snapshotPort.replaceSnapshots(
|
||||||
RecommendedSectionType.CHEER_CREATOR,
|
RecommendedSectionType.CHEER_CREATOR,
|
||||||
snapshotAt,
|
snapshotAt,
|
||||||
@@ -303,12 +313,104 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
|
|
||||||
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
|
val creators = service.findCheerCreatorRecommendations(memberId = 104L)
|
||||||
|
|
||||||
|
assertEquals(null, fallback.offset)
|
||||||
|
assertEquals(null, fallback.limit)
|
||||||
assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds)
|
assertEquals((1L..9L).toList(), port.cheerCreatorDetailIds)
|
||||||
assertEquals(104L, port.cheerCreatorMemberId)
|
assertEquals(104L, port.cheerCreatorMemberId)
|
||||||
assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
|
assertEquals(listOf(1L, 2L), creators.map { it.creatorId })
|
||||||
assertEquals(listOf("creator-1", "creator-2"), creators.map { it.creatorNickname })
|
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
|
@Test
|
||||||
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
@DisplayName("인기 커뮤니티 추천은 최신 스냅샷 10개를 기준으로 크리에이터 중복을 제거하고 상세를 조립한다")
|
||||||
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
fun shouldFindPopularCommunitiesFromLatestSnapshotsWithLimitDetailsAndCreatorUniqueness() {
|
||||||
@@ -861,7 +963,7 @@ class HomeRecommendationQueryServiceTest {
|
|||||||
|
|
||||||
override fun findCheerCreatorSnapshots(
|
override fun findCheerCreatorSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
snapshotAt: LocalDateTime,
|
windowEndExclusive: LocalDateTime,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<RecommendationSnapshotRecord> = emptyList()
|
): List<RecommendationSnapshotRecord> = emptyList()
|
||||||
|
|
||||||
@@ -931,17 +1033,35 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
|||||||
return all.drop(offset.toInt()).take(limit)
|
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 {
|
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
|
||||||
return snapshots.any { it.sectionType == sectionType }
|
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(
|
override fun replaceSnapshots(
|
||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
newSnapshots: List<RecommendationSnapshotRecord>
|
newSnapshots: List<RecommendationSnapshotRecord>
|
||||||
) {
|
) {
|
||||||
if (newSnapshots.isEmpty() && sectionType == RecommendedSectionType.AI_CHARACTER) {
|
if (newSnapshots.isEmpty() && supportsEmptySnapshotMarker(sectionType)) {
|
||||||
snapshots.removeIf { it.sectionType == sectionType }
|
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt))
|
snapshots.add(snapshot(sectionType, targetId = 0L, score = 0.0, snapshotAt = snapshotAt))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -949,6 +1069,33 @@ private class FakeHomeRecommendationSnapshotPort : RecommendationSnapshotPort {
|
|||||||
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
snapshots.removeIf { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
|
||||||
snapshots.addAll(newSnapshots)
|
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(
|
private class FakeAiCharacterSnapshotFallbackPort(
|
||||||
|
|||||||
Reference in New Issue
Block a user