From e8b8287968826cc6ed96a8a6abc21c64e09df30b Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 20 Jun 2026 04:35:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=ED=83=AD=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelSeriesQueryService.kt | 115 ++++++++ .../CreatorChannelSeriesQueryServiceTest.kt | 246 ++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt new file mode 100644 index 00000000..565b91f2 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt @@ -0,0 +1,115 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy +import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab +import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class CreatorChannelSeriesQueryService( + private val queryPortProvider: ObjectProvider, + private val queryPolicy: CreatorChannelSeriesQueryPolicy, + private val memberContentPreferenceService: MemberContentPreferenceService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getSeriesTab( + creatorId: Long, + viewer: Member, + sort: String?, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelSeriesTab { + val resolvedSort = queryPolicy.resolveSort(sort) + val seriesPage = queryPolicy.createPage(page, size) + val queryPort = queryPortProvider.getObject() + val viewerId = viewer.id!! + val creator = queryPort.findCreator(creatorId, viewerId) + ?: throw SodaException(messageKey = "member.validation.user_not_found") + + if (queryPort.existsBlockedBetween(viewerId, creatorId)) { + val messageTemplate = messageSource + .getMessage("explorer.creator.blocked_access", langContext.lang) + .orEmpty() + throw SodaException(message = String.format(messageTemplate, creator.nickname)) + } + + validateCreatorRole(creator) + + val preference = memberContentPreferenceService.getStoredPreference(viewer) + val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible) + val locale = langContext.lang.code + val fetchedSeries = queryPort.findSeries( + creatorId = creatorId, + viewerId = viewerId, + now = now, + canViewAdultContent = canViewAdultContent, + sort = resolvedSort, + locale = locale, + offset = seriesPage.offset, + limit = seriesPage.fetchLimit + ) + + return CreatorChannelSeriesTab( + seriesCount = queryPort.countSeries( + creatorId = creatorId, + now = now, + canViewAdultContent = canViewAdultContent + ), + series = queryPolicy.limitItems(fetchedSeries, seriesPage).map { it.toDomain(creatorId, viewerId, locale) }, + sort = resolvedSort, + page = seriesPage, + hasNext = queryPolicy.hasNext(fetchedSeries, seriesPage) + ) + } + + private fun validateCreatorRole(creator: CreatorChannelSeriesCreatorRecord) { + when (creator.role) { + MemberRole.CREATOR -> return + else -> throw SodaException(messageKey = "member.validation.creator_not_found") + } + } + + private fun CreatorChannelSeriesRecord.toDomain(creatorId: Long, viewerId: Long, locale: String): CreatorChannelSeries { + val isCreatorSelf = viewerId == creatorId + val domainPurchasedContentCount = if (isCreatorSelf) null else purchasedContentCount + val domainPaidContentCount = if (isCreatorSelf) null else paidContentCount + return CreatorChannelSeries( + seriesId = seriesId, + title = title, + coverImageUrl = coverImagePath.toCdnUrl(cloudFrontHost), + publishedDaysOfWeek = queryPolicy.publishedDaysOfWeekText(publishedDaysOfWeek, locale), + isOriginal = isOriginal, + isAdult = isAdult, + isProceeding = state == SeriesState.PROCEEDING, + contentCount = contentCount, + purchasedContentCount = domainPurchasedContentCount, + paidContentCount = domainPaidContentCount, + purchasedPaidContentRate = if (isCreatorSelf) { + null + } else { + queryPolicy.purchaseRate(domainPaidContentCount ?: 0, domainPurchasedContentCount ?: 0) + } + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt new file mode 100644 index 00000000..82accfe1 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt @@ -0,0 +1,246 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberProvider +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.ObjectProvider +import java.time.LocalDateTime + +class CreatorChannelSeriesQueryServiceTest { + @Test + @DisplayName("시리즈 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다") + fun shouldResolveRequestFallbacksAndAssembleSeriesTab() { + val port = FakeCreatorChannelSeriesQueryPort().apply { + series = (1L..51L).map { seriesRecord(it) } + } + val service = createService(port, canViewAdultContent = false) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 6, 20, 10, 0) + + val tab = service.getSeriesTab( + creatorId = 1L, + viewer = viewer, + sort = "UNKNOWN", + page = -1, + size = 100, + now = now + ) + + assertEquals(ContentSort.LATEST, tab.sort) + assertEquals(0, tab.page.page) + assertEquals(50, tab.page.size) + assertEquals(0L, port.listOffset) + assertEquals(51, port.listLimit) + assertEquals(ContentSort.LATEST, port.listSort) + assertEquals("en", port.listLocale) + assertEquals(false, port.listCanViewAdultContent) + assertEquals(60, tab.seriesCount) + assertEquals(50, tab.series.size) + assertTrue(tab.hasNext) + assertEquals("https://cdn.test/cover/1.png", tab.series.first().coverImageUrl) + assertEquals("Every Mon, Thu", tab.series.first().publishedDaysOfWeek) + assertEquals(75, tab.series.first().purchasedPaidContentRate) + } + + @Test + @DisplayName("조회자가 creator 본인이면 시리즈 구매 통계 필드는 null이다") + fun shouldHidePurchaseStatsForCreatorSelf() { + val port = FakeCreatorChannelSeriesQueryPort().apply { + series = listOf(seriesRecord(1L)) + } + val service = createService(port) + val viewer = createMember(id = 1L) + + val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + + assertNull(tab.series.first().purchasedContentCount) + assertNull(tab.series.first().paidContentCount) + assertNull(tab.series.first().purchasedPaidContentRate) + } + + @Test + @DisplayName("blank cover와 0개 유료 콘텐츠 구매율은 null cover와 0으로 조립한다") + fun shouldAssembleBlankCoverAndZeroPurchaseRate() { + val port = FakeCreatorChannelSeriesQueryPort().apply { + series = listOf(seriesRecord(1L, coverImagePath = " ", paidContentCount = 0, purchasedContentCount = 3)) + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val tab = service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + + assertNull(tab.series.first().coverImageUrl) + assertEquals(0, tab.series.first().purchasedPaidContentRate) + } + + @Test + @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") + fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { + val port = FakeCreatorChannelSeriesQueryPort().apply { creator = null } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + } + + assertEquals("member.validation.user_not_found", exception.messageKey) + } + + @Test + @DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다") + fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() { + val port = FakeCreatorChannelSeriesQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + } + + assertEquals("member.validation.creator_not_found", exception.messageKey) + } + + @Test + @DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다") + fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() { + val port = FakeCreatorChannelSeriesQueryPort().apply { blocked = true } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getSeriesTab(1L, viewer, null, null, null, LocalDateTime.of(2026, 6, 20, 10, 0)) + } + + assertNull(exception.messageKey) + assertEquals("Channel access is restricted at creator's request.", exception.message) + } + + private fun createService( + port: FakeCreatorChannelSeriesQueryPort, + canViewAdultContent: Boolean = true + ): CreatorChannelSeriesQueryService { + val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + Mockito.`when`( + preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L)) + ).thenReturn( + ViewerContentPreference( + countryCode = "US", + isAdultContentVisible = canViewAdultContent, + contentType = ContentType.ALL, + isAdult = canViewAdultContent + ) + ) + val langContext = LangContext() + langContext.setLang(Lang.EN) + return CreatorChannelSeriesQueryService( + queryPortProvider = FixedCreatorChannelSeriesQueryPortProvider(port), + queryPolicy = CreatorChannelSeriesQueryPolicy(), + memberContentPreferenceService = preferenceService, + messageSource = SodaMessageSource(), + langContext = langContext, + cloudFrontHost = "https://cdn.test" + ) + } + + private fun createMember(id: Long): Member { + return Member( + email = "member$id@test.com", + password = "password", + nickname = "member$id", + provider = MemberProvider.EMAIL + ).apply { this.id = id } + } +} + +private class FixedCreatorChannelSeriesQueryPortProvider( + private val port: CreatorChannelSeriesQueryPort +) : ObjectProvider { + override fun getObject(vararg args: Any?): CreatorChannelSeriesQueryPort = port + + override fun getIfAvailable(): CreatorChannelSeriesQueryPort = port + + override fun getIfUnique(): CreatorChannelSeriesQueryPort = port + + override fun getObject(): CreatorChannelSeriesQueryPort = port +} + +private class FakeCreatorChannelSeriesQueryPort : CreatorChannelSeriesQueryPort { + var creator: CreatorChannelSeriesCreatorRecord? = CreatorChannelSeriesCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator" + ) + var blocked = false + var seriesCount = 60 + var series = (1L..21L).map { seriesRecord(it) } + var listSort: ContentSort? = null + var listLocale: String? = null + var listOffset: Long? = null + var listLimit: Int? = null + var listCanViewAdultContent: Boolean? = null + + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? = creator + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked + override fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int = seriesCount + + override fun findSeries( + creatorId: Long, + viewerId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + sort: ContentSort, + locale: String, + offset: Long, + limit: Int + ): List { + listSort = sort + listLocale = locale + listOffset = offset + listLimit = limit + listCanViewAdultContent = canViewAdultContent + return series + } +} + +private fun seriesRecord( + seriesId: Long, + coverImagePath: String? = "cover/$seriesId.png", + paidContentCount: Int? = 4, + purchasedContentCount: Int? = 3 +): CreatorChannelSeriesRecord { + return CreatorChannelSeriesRecord( + seriesId = seriesId, + title = "series-$seriesId", + coverImagePath = coverImagePath, + publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU), + isOriginal = true, + isAdult = false, + state = SeriesState.PROCEEDING, + contentCount = 5, + purchasedContentCount = purchasedContentCount, + paidContentCount = paidContentCount + ) +}