From 4fdb9bcb262cd3b99e7a06751cfe03acb07a8e6a Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Jun 2026 16:06:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=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 --- .../CreatorChannelAudioQueryService.kt | 140 ++++++++ .../CreatorChannelAudioQueryServiceTest.kt | 338 +++++++++++++----- 2 files changed, 379 insertions(+), 99 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt new file mode 100644 index 00000000..2807fead --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt @@ -0,0 +1,140 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.audio.application + +import kr.co.vividnext.sodalive.common.SodaException +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.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab +import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme +import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord +import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord +import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent +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 CreatorChannelAudioQueryService( + private val queryPortProvider: ObjectProvider, + private val queryPolicy: CreatorChannelAudioQueryPolicy, + private val memberContentPreferenceService: MemberContentPreferenceService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getAudioTab( + creatorId: Long, + viewer: Member, + sort: String?, + themeId: Long?, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelAudioTab { + val resolvedSort = queryPolicy.resolveSort(sort) + val audioPage = 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 resolvedThemeId = themeId?.let(queryPort::findActiveThemeId) + val locale = langContext.lang.code + val fetchedContents = queryPort.findAudioContents( + creatorId = creatorId, + viewerId = viewerId, + themeId = resolvedThemeId, + now = now, + canViewAdultContent = canViewAdultContent, + sort = resolvedSort, + locale = locale, + offset = audioPage.offset, + limit = audioPage.fetchLimit + ) + val paidAudioContentCount = queryPort.countPaidAudioContents( + creatorId = creatorId, + themeId = resolvedThemeId, + now = now, + canViewAdultContent = canViewAdultContent + ) + val purchasedAudioContentCount = queryPort.countPurchasedAudioContents( + creatorId = creatorId, + viewerId = viewerId, + themeId = resolvedThemeId, + now = now, + canViewAdultContent = canViewAdultContent + ) + + return CreatorChannelAudioTab( + audioContentCount = queryPort.countAudioContents( + creatorId = creatorId, + themeId = resolvedThemeId, + now = now, + canViewAdultContent = canViewAdultContent + ), + paidAudioContentCount = paidAudioContentCount, + purchasedAudioContentCount = purchasedAudioContentCount, + purchasedAudioContentRate = queryPolicy.purchaseRate(paidAudioContentCount, purchasedAudioContentCount), + themes = queryPort.findAudioThemes(locale).map { it.toDomain() }, + audioContents = queryPolicy.limitItems(fetchedContents, audioPage).map { it.toDomain() }, + sort = resolvedSort, + themeId = resolvedThemeId, + page = audioPage, + hasNext = queryPolicy.hasNext(fetchedContents, audioPage) + ) + } + + private fun validateCreatorRole(creator: CreatorChannelAudioCreatorRecord) { + when (creator.role) { + MemberRole.CREATOR -> return + else -> throw SodaException(messageKey = "member.validation.creator_not_found") + } + } + + private fun CreatorChannelAudioThemeRecord.toDomain() = CreatorChannelAudioTheme( + themeId = themeId, + themeName = themeName + ) + + private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent( + audioContentId = audioContentId, + title = title, + duration = duration, + imageUrl = imagePath.toCdnUrl(), + price = price, + isAdult = isAdult, + isPointAvailable = isPointAvailable, + isFirstContent = isFirstContent, + seriesName = seriesName, + isOriginalSeries = isOriginalSeries, + isOwned = isOwned, + isRented = isRented + ) + + private fun String?.toCdnUrl(): String? { + if (isNullOrBlank()) return null + if (startsWith("https://") || startsWith("http://")) return this + return "$cloudFrontHost/$this" + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt index dcd54d26..ee70bced 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt @@ -1,123 +1,263 @@ package kr.co.vividnext.sodalive.v2.creator.channel.audio.application +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.ContentType +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.audio.domain.CreatorChannelAudioTab -import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme +import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioContentRecord import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioQueryPort import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord -import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent -import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage 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 CreatorChannelAudioQueryServiceTest { @Test - @DisplayName("오디오 탭 domain model과 port 계약을 사용할 수 있다") - fun shouldUseAudioTabDomainModelAndPortContract() { - val page = CreatorChannelPage(page = 0, size = 20) - val tab = CreatorChannelAudioTab( - audioContentCount = 1, - paidAudioContentCount = 1, - purchasedAudioContentCount = 1, - purchasedAudioContentRate = 100.0, - themes = listOf(CreatorChannelAudioTheme(themeId = 10L, themeName = "theme")), - audioContents = listOf(audioContent()), - sort = ContentSort.LATEST, - themeId = 10L, - page = page, - hasNext = false - ) - val port = FakeCreatorChannelAudioQueryPort() + @DisplayName("오디오 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다") + fun shouldResolveRequestFallbacksAndAssembleAudioTab() { + val port = FakeCreatorChannelAudioQueryPort().apply { + activeThemeId = null + paidAudioContentCount = 4 + purchasedAudioContentCount = 3 + audioContents = (1L..51L).map { audioContentRecord(it) } + } + val service = createService(port, canViewAdultContent = false) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 6, 19, 10, 0) - assertEquals(1, tab.audioContentCount) - assertEquals(10L, tab.themes.first().themeId) - assertEquals(100L, tab.audioContents.first().audioContentId) - assertEquals(MemberRole.CREATOR, port.findCreator(creatorId = 1L, viewerId = 2L)?.role) + val tab = service.getAudioTab( + creatorId = 1L, + viewer = viewer, + sort = "UNKNOWN", + themeId = 999L, + page = -1, + size = 100, + now = now + ) + + assertEquals(ContentSort.LATEST, tab.sort) + assertNull(tab.themeId) + assertEquals(0, tab.page.page) + assertEquals(50, tab.page.size) + assertEquals(0L, port.listOffset) + assertEquals(51, port.listLimit) + assertEquals(ContentSort.LATEST, port.listSort) + assertNull(port.listThemeId) + assertEquals("en", port.listLocale) + assertEquals(false, port.listCanViewAdultContent) + assertEquals(75.0, tab.purchasedAudioContentRate) + assertEquals(50, tab.audioContents.size) + assertTrue(tab.hasNext) + assertEquals("https://cdn.test/audio/1.png", tab.audioContents.first().imageUrl) } - private fun audioContent(): CreatorChannelAudioContent { - return CreatorChannelAudioContent( - audioContentId = 100L, - title = "audio", - duration = "00:01:00", - imageUrl = null, - price = 10, - isAdult = false, - isPointAvailable = true, - isFirstContent = true, - seriesName = null, - isOriginalSeries = null, - isOwned = true, - isRented = false - ) + @Test + @DisplayName("유료 오디오 콘텐츠가 없으면 소장률은 0.0이다") + fun shouldReturnZeroPurchaseRateWhenPaidContentCountIsZero() { + val port = FakeCreatorChannelAudioQueryPort().apply { + paidAudioContentCount = 0 + purchasedAudioContentCount = 3 + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val tab = service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0)) + + assertEquals(0.0, tab.purchasedAudioContentRate) } - private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort { - override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? { - return CreatorChannelAudioCreatorRecord( - creatorId = creatorId, - role = MemberRole.CREATOR, - nickname = "creator" + @Test + @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") + fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { + val port = FakeCreatorChannelAudioQueryPort().apply { creator = null } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0)) + } + + assertEquals("member.validation.user_not_found", exception.messageKey) + } + + @Test + @DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다") + fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() { + val port = FakeCreatorChannelAudioQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0)) + } + + assertEquals("member.validation.creator_not_found", exception.messageKey) + } + + @Test + @DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다") + fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() { + val port = FakeCreatorChannelAudioQueryPort().apply { blocked = true } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0)) + } + + assertNull(exception.messageKey) + assertEquals("Channel access is restricted at creator's request.", exception.message) + } + + private fun createService( + port: FakeCreatorChannelAudioQueryPort, + canViewAdultContent: Boolean = true + ): CreatorChannelAudioQueryService { + 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 CreatorChannelAudioQueryService( + queryPortProvider = FixedCreatorChannelAudioQueryPortProvider(port), + queryPolicy = CreatorChannelAudioQueryPolicy(), + memberContentPreferenceService = preferenceService, + messageSource = SodaMessageSource(), + langContext = langContext, + cloudFrontHost = "https://cdn.test" + ) + } - override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean { - return false - } - - override fun findActiveThemeId(themeId: Long): Long? { - return themeId - } - - override fun findAudioThemes(locale: String): List { - return listOf(CreatorChannelAudioThemeRecord(themeId = 10L, themeName = locale)) - } - - override fun countAudioContents( - creatorId: Long, - themeId: Long?, - now: LocalDateTime, - canViewAdultContent: Boolean - ): Int { - return 1 - } - - override fun countPaidAudioContents( - creatorId: Long, - themeId: Long?, - now: LocalDateTime, - canViewAdultContent: Boolean - ): Int { - return 1 - } - - override fun countPurchasedAudioContents( - creatorId: Long, - viewerId: Long, - themeId: Long?, - now: LocalDateTime, - canViewAdultContent: Boolean - ): Int { - return 1 - } - - override fun findAudioContents( - creatorId: Long, - viewerId: Long, - themeId: Long?, - now: LocalDateTime, - canViewAdultContent: Boolean, - sort: ContentSort, - locale: String, - offset: Long, - limit: Int - ): List { - return emptyList() - } + 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 FixedCreatorChannelAudioQueryPortProvider( + private val port: CreatorChannelAudioQueryPort +) : ObjectProvider { + override fun getObject(vararg args: Any?): CreatorChannelAudioQueryPort = port + + override fun getIfAvailable(): CreatorChannelAudioQueryPort = port + + override fun getIfUnique(): CreatorChannelAudioQueryPort = port + + override fun getObject(): CreatorChannelAudioQueryPort = port +} + +private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort { + var creator: CreatorChannelAudioCreatorRecord? = CreatorChannelAudioCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator" + ) + var blocked = false + var activeThemeId: Long? = 10L + var audioContentCount = 60 + var paidAudioContentCount = 4 + var purchasedAudioContentCount = 3 + var audioContents = (1L..21L).map { audioContentRecord(it) } + var listThemeId: Long? = null + 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?): CreatorChannelAudioCreatorRecord? = creator + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked + + override fun findActiveThemeId(themeId: Long): Long? = activeThemeId + + override fun findAudioThemes(locale: String): List { + return listOf(CreatorChannelAudioThemeRecord(themeId = 10L, themeName = locale)) + } + + override fun countAudioContents( + creatorId: Long, + themeId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Int = audioContentCount + + override fun countPaidAudioContents( + creatorId: Long, + themeId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Int = paidAudioContentCount + + override fun countPurchasedAudioContents( + creatorId: Long, + viewerId: Long, + themeId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Int = purchasedAudioContentCount + + override fun findAudioContents( + creatorId: Long, + viewerId: Long, + themeId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean, + sort: ContentSort, + locale: String, + offset: Long, + limit: Int + ): List { + listThemeId = themeId + listSort = sort + listLocale = locale + listOffset = offset + listLimit = limit + listCanViewAdultContent = canViewAdultContent + return audioContents + } +} + +private fun audioContentRecord(audioContentId: Long): CreatorChannelAudioContentRecord { + return CreatorChannelAudioContentRecord( + audioContentId = audioContentId, + title = "audio-$audioContentId", + duration = "00:10:00", + imagePath = "audio/$audioContentId.png", + price = 10, + isAdult = false, + isPointAvailable = true, + isFirstContent = audioContentId == 1L, + seriesName = "series", + isOriginalSeries = true, + isOwned = audioContentId == 1L, + isRented = audioContentId == 2L + ) +}