diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryService.kt index 04f3d8cf..1e46e617 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryService.kt @@ -2,20 +2,36 @@ package kr.co.vividnext.sodalive.v2.recommend.application import kr.co.vividnext.sodalive.v2.recommend.port.out.CreatorContentViewHistoryPort import kr.co.vividnext.sodalive.v2.recommend.port.out.CreatorContentViewHistoryRecord +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Propagation import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.support.TransactionSynchronization +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime @Service class CreatorContentViewHistoryService( private val port: CreatorContentViewHistoryPort ) { + private val log = LoggerFactory.getLogger(javaClass) + @Transactional(propagation = Propagation.REQUIRES_NEW) fun recordView(memberId: Long?, contentId: Long, viewedAt: LocalDateTime = LocalDateTime.now()) { - if (memberId == null) return + if (memberId == null) { + log.info("event=creator_content_view_history_record_skipped reason=anonymous contentId={}", contentId) + return + } - val genreId = port.findGenreIdByContentId(contentId) ?: return + val genreId = port.findGenreIdByContentId(contentId) + if (genreId == null) { + log.info( + "event=creator_content_view_history_record_skipped reason=genre_not_found memberId={} contentId={}", + memberId, + contentId + ) + return + } port.save( CreatorContentViewHistoryRecord( memberId = memberId, @@ -24,5 +40,25 @@ class CreatorContentViewHistoryService( viewedAt = viewedAt ) ) + afterCommit { + log.info( + "event=creator_content_view_history_record_success memberId={} contentId={} genreId={}", + memberId, + contentId, + genreId + ) + } + } + + private fun afterCommit(action: () -> Unit) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + action() + return + } + TransactionSynchronizationManager.registerSynchronization( + object : TransactionSynchronization { + override fun afterCommit() = action() + } + ) } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryServiceTest.kt index 8508a413..0a56fe0f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/recommend/application/CreatorContentViewHistoryServiceTest.kt @@ -6,15 +6,20 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension +import org.springframework.transaction.support.TransactionSynchronizationManager import java.time.LocalDateTime +@ExtendWith(OutputCaptureExtension::class) class CreatorContentViewHistoryServiceTest { private val port = FakeCreatorContentViewHistoryPort() private val service = CreatorContentViewHistoryService(port) @Test @DisplayName("인증 회원의 콘텐츠 상세 진입 시 memberId/contentId/genreId/viewedAt을 저장한다") - fun shouldRecordAuthenticatedMemberContentViewWithGenreAndViewedAt() { + fun shouldRecordAuthenticatedMemberContentViewWithGenreAndViewedAt(output: CapturedOutput) { val viewedAt = LocalDateTime.of(2026, 5, 31, 10, 0) port.genreIdByContentId[20L] = 30L @@ -31,22 +36,44 @@ class CreatorContentViewHistoryServiceTest { ), port.savedRecords ) + assertTrue(output.out.contains("event=creator_content_view_history_record_success")) + } + + @Test + @DisplayName("조회 이력 저장 성공 로그는 트랜잭션 커밋 후 기록한다") + fun shouldLogRecordSuccessAfterTransactionCommit(output: CapturedOutput) { + port.genreIdByContentId[20L] = 30L + TransactionSynchronizationManager.initSynchronization() + try { + service.recordView(memberId = 10L, contentId = 20L) + + assertEquals(false, output.out.contains("event=creator_content_view_history_record_success")) + TransactionSynchronizationManager.getSynchronizations().forEach { it.afterCommit() } + } finally { + TransactionSynchronizationManager.clearSynchronization() + } + + assertTrue(output.out.contains("event=creator_content_view_history_record_success")) } @Test @DisplayName("비회원 콘텐츠 상세 진입은 조회 이력을 저장하지 않는다") - fun shouldNotRecordAnonymousContentView() { + fun shouldNotRecordAnonymousContentView(output: CapturedOutput) { service.recordView(memberId = null, contentId = 20L) assertTrue(port.savedRecords.isEmpty()) + assertTrue(output.out.contains("event=creator_content_view_history_record_skipped")) + assertTrue(output.out.contains("reason=anonymous")) } @Test @DisplayName("콘텐츠의 활성 장르를 찾지 못하면 조회 이력을 저장하지 않는다") - fun shouldNotRecordWhenContentGenreDoesNotExist() { + fun shouldNotRecordWhenContentGenreDoesNotExist(output: CapturedOutput) { service.recordView(memberId = 10L, contentId = 20L) assertTrue(port.savedRecords.isEmpty()) + assertTrue(output.out.contains("event=creator_content_view_history_record_skipped")) + assertTrue(output.out.contains("reason=genre_not_found")) } private class FakeCreatorContentViewHistoryPort : CreatorContentViewHistoryPort {