From 59439df33ed25ced96134a35752a960668350fb7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 26 Jun 2026 02:50:51 +0900 Subject: [PATCH] =?UTF-8?q?feat(content-ranking):=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EC=B5=9C=EA=B7=BC=20=EC=86=8C=EC=8B=9D?= =?UTF-8?q?=EC=9D=84=20=EB=B0=9C=ED=96=89=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorRankingSnapshotRefreshService.kt | 36 +++++- ...reatorRankingSnapshotRefreshServiceTest.kt | 117 +++++++++++++++++- 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt index b96bbdf4..f4536c3a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshService.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.ranking.application +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingPeriodPolicy import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingScorePolicy import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate @@ -19,7 +20,8 @@ import java.time.ZonedDateTime @Service class CreatorRankingSnapshotRefreshService( private val aggregationPort: CreatorRankingAggregationPort, - private val snapshotPort: CreatorRankingSnapshotPort + private val snapshotPort: CreatorRankingSnapshotPort, + private val homeFollowingNewsPublishService: HomeFollowingNewsPublishService ) { private val log = LoggerFactory.getLogger(javaClass) private val periodPolicy = CreatorRankingPeriodPolicy() @@ -47,6 +49,28 @@ class CreatorRankingSnapshotRefreshService( visibleFromAtUtc = visibleFromAtUtc, newSnapshots = snapshots ) + afterCommit { + snapshots.forEachIndexed { index, snapshot -> + runCatching { + homeFollowingNewsPublishService.publishCreatorRankingVisible( + creatorId = snapshot.creatorId, + creatorNickname = snapshot.nickname, + creatorProfileImagePath = snapshot.profileImageUrl, + aggregationStartAtUtc = utcRange.startInclusiveUtc, + visibleFromAtUtc = visibleFromAtUtc, + rank = index + 1 + ) + }.onFailure { ex -> + log.warn( + "event=home_following_creator_ranking_news_publish_failure creatorId={} rank={} error={}", + snapshot.creatorId, + index + 1, + ex.message, + ex + ) + } + } + } aggregationResult.toLogCounts(storedCount = snapshots.size) }.onSuccess { counts -> afterCommit { @@ -92,12 +116,18 @@ class CreatorRankingSnapshotRefreshService( private fun afterCommit(action: () -> Unit) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { - action() + runCatching(action).onFailure { ex -> + log.warn("event=creator_ranking_after_commit_failure error={}", ex.message, ex) + } return } TransactionSynchronizationManager.registerSynchronization( object : TransactionSynchronization { - override fun afterCommit() = action() + override fun afterCommit() { + runCatching(action).onFailure { ex -> + log.warn("event=creator_ranking_after_commit_failure error={}", ex.message, ex) + } + } } ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt index 8a7fa528..9b15a03a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/ranking/application/CreatorRankingSnapshotRefreshServiceTest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.ranking.application +import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingSnapshotCandidate import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingAggregationPort @@ -11,6 +12,7 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito import org.springframework.boot.test.system.CapturedOutput import org.springframework.boot.test.system.OutputCaptureExtension import org.springframework.transaction.support.TransactionSynchronizationManager @@ -165,16 +167,125 @@ class CreatorRankingSnapshotRefreshServiceTest { assertEquals(true, output.out.contains("error=aggregate failed")) } + @Test + @DisplayName("주간 스냅샷 저장 성공 후 크리에이터 랭킹 최근 소식을 순위와 함께 발행한다") + fun shouldPublishCreatorRankingNewsAfterSnapshotsAreReplaced() { + val aggregationPort = FakeCreatorRankingAggregationPort() + val snapshotPort = FakeCreatorRankingSnapshotPort() + val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) + val service = service( + aggregationPort = aggregationPort, + snapshotPort = snapshotPort, + publishService = publishService + ) + aggregationPort.candidates = listOf( + candidate(creatorId = 1L, liveCanAmount = 200), + candidate(creatorId = 2L, liveCanAmount = 100) + ) + + service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))) + + Mockito.verify(publishService).publishCreatorRankingVisible( + creatorId = 1L, + creatorNickname = "creator-1", + creatorProfileImagePath = "profile-1.png", + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), + visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), + rank = 1 + ) + Mockito.verify(publishService).publishCreatorRankingVisible( + creatorId = 2L, + creatorNickname = "creator-2", + creatorProfileImagePath = "profile-2.png", + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), + visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), + rank = 2 + ) + } + + @Test + @DisplayName("주간 스냅샷 저장 실패 시 크리에이터 랭킹 최근 소식을 발행하지 않는다") + fun shouldNotPublishCreatorRankingNewsWhenReplaceSnapshotsFails() { + val aggregationPort = FakeCreatorRankingAggregationPort() + val snapshotPort = FakeCreatorRankingSnapshotPort() + val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) + val service = service( + aggregationPort = aggregationPort, + snapshotPort = snapshotPort, + publishService = publishService + ) + aggregationPort.candidates = listOf(candidate(creatorId = 1L, liveCanAmount = 100)) + snapshotPort.failure = IllegalStateException("replace failed") + + assertThrows(IllegalStateException::class.java) { + service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))) + } + + Mockito.verifyNoInteractions(publishService) + } + + @Test + @DisplayName("일부 크리에이터 랭킹 최근 소식 발행 실패는 스냅샷 갱신을 실패시키지 않는다") + fun shouldNotFailSnapshotRefreshWhenCreatorRankingNewsPublishFails() { + val aggregationPort = FakeCreatorRankingAggregationPort() + val snapshotPort = FakeCreatorRankingSnapshotPort() + val publishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) + val service = service( + aggregationPort = aggregationPort, + snapshotPort = snapshotPort, + publishService = publishService + ) + aggregationPort.candidates = listOf( + candidate(creatorId = 1L, liveCanAmount = 200), + candidate(creatorId = 2L, liveCanAmount = 100) + ) + Mockito.doAnswer { invocation -> + if (invocation.getArgument(0) == 1L) { + throw IllegalStateException("publish failed") + } + 0 + }.`when`(publishService) + .publishCreatorRankingVisible( + creatorId = Mockito.anyLong(), + creatorNickname = anyStringValue(), + creatorProfileImagePath = Mockito.anyString(), + aggregationStartAtUtc = anyLocalDateTime(), + visibleFromAtUtc = anyLocalDateTime(), + rank = Mockito.anyInt() + ) + + service.refreshLastCompletedWeek(ZonedDateTime.of(2026, 6, 8, 6, 0, 0, 0, ZoneId.of("Asia/Seoul"))) + + Mockito.verify(publishService).publishCreatorRankingVisible( + creatorId = 2L, + creatorNickname = "creator-2", + creatorProfileImagePath = "profile-2.png", + aggregationStartAtUtc = LocalDateTime.of(2026, 5, 31, 15, 0), + visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0), + rank = 2 + ) + } + private fun service( aggregationPort: CreatorRankingAggregationPort = FakeCreatorRankingAggregationPort(), - snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort() + snapshotPort: CreatorRankingSnapshotPort = FakeCreatorRankingSnapshotPort(), + publishService: HomeFollowingNewsPublishService = Mockito.mock(HomeFollowingNewsPublishService::class.java) ): CreatorRankingSnapshotRefreshService { return CreatorRankingSnapshotRefreshService( aggregationPort = aggregationPort, - snapshotPort = snapshotPort + snapshotPort = snapshotPort, + homeFollowingNewsPublishService = publishService ) } + private fun anyStringValue(): String { + return Mockito.anyString() ?: "" + } + + private fun anyLocalDateTime(): LocalDateTime { + return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.MIN + } + private fun candidate( creatorId: Long, finalScore: Double = 0.0, @@ -247,6 +358,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort { var aggregationStartAtUtc: LocalDateTime? = null var aggregationEndAtUtc: LocalDateTime? = null var visibleFromAtUtc: LocalDateTime? = null + var failure: RuntimeException? = null override fun findSnapshotsByAggregationPeriod( aggregationStartAtUtc: LocalDateTime, @@ -281,6 +393,7 @@ private class FakeCreatorRankingSnapshotPort : CreatorRankingSnapshotPort { visibleFromAtUtc: LocalDateTime, newSnapshots: List ) { + failure?.let { throw it } this.rankingType = rankingType this.aggregationStartAtUtc = aggregationStartAtUtc this.aggregationEndAtUtc = aggregationEndAtUtc