feat(creator-channel): 오디오 탭 조회 서비스를 추가한다

This commit is contained in:
2026-06-19 16:06:45 +09:00
parent 80a06ad63d
commit 4fdb9bcb26
2 changed files with 379 additions and 99 deletions

View File

@@ -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<CreatorChannelAudioQueryPort>,
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"
}
}

View File

@@ -1,78 +1,202 @@
package kr.co.vividnext.sodalive.v2.creator.channel.audio.application 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.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.common.domain.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicy
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.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioCreatorRecord 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.CreatorChannelAudioQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out.CreatorChannelAudioThemeRecord 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.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.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime import java.time.LocalDateTime
class CreatorChannelAudioQueryServiceTest { class CreatorChannelAudioQueryServiceTest {
@Test @Test
@DisplayName("오디오 탭 domain model과 port 계약을 사용할 수 있") @DisplayName("오디오 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한")
fun shouldUseAudioTabDomainModelAndPortContract() { fun shouldResolveRequestFallbacksAndAssembleAudioTab() {
val page = CreatorChannelPage(page = 0, size = 20) val port = FakeCreatorChannelAudioQueryPort().apply {
val tab = CreatorChannelAudioTab( activeThemeId = null
audioContentCount = 1, paidAudioContentCount = 4
paidAudioContentCount = 1, purchasedAudioContentCount = 3
purchasedAudioContentCount = 1, audioContents = (1L..51L).map { audioContentRecord(it) }
purchasedAudioContentRate = 100.0, }
themes = listOf(CreatorChannelAudioTheme(themeId = 10L, themeName = "theme")), val service = createService(port, canViewAdultContent = false)
audioContents = listOf(audioContent()), val viewer = createMember(id = 10L)
sort = ContentSort.LATEST, val now = LocalDateTime.of(2026, 6, 19, 10, 0)
themeId = 10L,
page = page,
hasNext = false
)
val port = FakeCreatorChannelAudioQueryPort()
assertEquals(1, tab.audioContentCount) val tab = service.getAudioTab(
assertEquals(10L, tab.themes.first().themeId) creatorId = 1L,
assertEquals(100L, tab.audioContents.first().audioContentId) viewer = viewer,
assertEquals(MemberRole.CREATOR, port.findCreator(creatorId = 1L, viewerId = 2L)?.role) 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 { @Test
return CreatorChannelAudioContent( @DisplayName("유료 오디오 콘텐츠가 없으면 소장률은 0.0이다")
audioContentId = 100L, fun shouldReturnZeroPurchaseRateWhenPaidContentCountIsZero() {
title = "audio", val port = FakeCreatorChannelAudioQueryPort().apply {
duration = "00:01:00", paidAudioContentCount = 0
imageUrl = null, purchasedAudioContentCount = 3
price = 10, }
isAdult = false, val service = createService(port)
isPointAvailable = true, val viewer = createMember(id = 10L)
isFirstContent = true,
seriesName = null, val tab = service.getAudioTab(1L, viewer, null, null, null, null, LocalDateTime.of(2026, 6, 19, 10, 0))
isOriginalSeries = null,
isOwned = true, assertEquals(0.0, tab.purchasedAudioContentRate)
isRented = false }
@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"
)
}
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<CreatorChannelAudioQueryPort> {
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 { private class FakeCreatorChannelAudioQueryPort : CreatorChannelAudioQueryPort {
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? { var creator: CreatorChannelAudioCreatorRecord? = CreatorChannelAudioCreatorRecord(
return CreatorChannelAudioCreatorRecord( creatorId = 1L,
creatorId = creatorId,
role = MemberRole.CREATOR, role = MemberRole.CREATOR,
nickname = "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 existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean { override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? = creator
return false
}
override fun findActiveThemeId(themeId: Long): Long? { override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
return themeId
} override fun findActiveThemeId(themeId: Long): Long? = activeThemeId
override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> { override fun findAudioThemes(locale: String): List<CreatorChannelAudioThemeRecord> {
return listOf(CreatorChannelAudioThemeRecord(themeId = 10L, themeName = locale)) return listOf(CreatorChannelAudioThemeRecord(themeId = 10L, themeName = locale))
@@ -83,18 +207,14 @@ class CreatorChannelAudioQueryServiceTest {
themeId: Long?, themeId: Long?,
now: LocalDateTime, now: LocalDateTime,
canViewAdultContent: Boolean canViewAdultContent: Boolean
): Int { ): Int = audioContentCount
return 1
}
override fun countPaidAudioContents( override fun countPaidAudioContents(
creatorId: Long, creatorId: Long,
themeId: Long?, themeId: Long?,
now: LocalDateTime, now: LocalDateTime,
canViewAdultContent: Boolean canViewAdultContent: Boolean
): Int { ): Int = paidAudioContentCount
return 1
}
override fun countPurchasedAudioContents( override fun countPurchasedAudioContents(
creatorId: Long, creatorId: Long,
@@ -102,9 +222,7 @@ class CreatorChannelAudioQueryServiceTest {
themeId: Long?, themeId: Long?,
now: LocalDateTime, now: LocalDateTime,
canViewAdultContent: Boolean canViewAdultContent: Boolean
): Int { ): Int = purchasedAudioContentCount
return 1
}
override fun findAudioContents( override fun findAudioContents(
creatorId: Long, creatorId: Long,
@@ -117,7 +235,29 @@ class CreatorChannelAudioQueryServiceTest {
offset: Long, offset: Long,
limit: Int limit: Int
): List<CreatorChannelAudioContentRecord> { ): List<CreatorChannelAudioContentRecord> {
return emptyList() 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
)
} }