feat(creator): 채널 라이브 탭 조회 서비스를 추가한다

This commit is contained in:
2026-06-17 18:20:52 +09:00
parent 6a3ca5f44f
commit 3e3642bb7f
3 changed files with 602 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.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.Gender
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.ContentSort
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord
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 CreatorChannelLiveQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelLiveQueryPort>,
private val queryPolicy: CreatorChannelLiveReplayQueryPolicy,
private val memberContentPreferenceService: MemberContentPreferenceService,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getLiveTab(
creatorId: Long,
viewer: Member,
sort: ContentSort,
page: Int,
size: Int,
now: LocalDateTime = LocalDateTime.now()
): CreatorChannelLiveTab {
val livePage = 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 isViewerCreator = viewerId == creatorId
val effectiveViewerGender = viewer.effectiveGender()
val fetchedContents = queryPort.findLiveReplayAudioContents(
creatorId = creatorId,
viewerId = viewerId,
now = now,
canViewAdultContent = canViewAdultContent,
sort = sort,
offset = livePage.offset,
limit = livePage.fetchLimit
)
return CreatorChannelLiveTab(
liveReplayContentCount = queryPort.countLiveReplayAudioContents(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent
),
currentLive = queryPort.findCurrentLive(
creatorId = creatorId,
now = now,
canViewAdultContent = canViewAdultContent,
viewerId = viewerId,
isViewerCreator = isViewerCreator,
effectiveViewerGender = effectiveViewerGender
)?.toDomain(),
liveReplayContents = queryPolicy.limitItems(fetchedContents, livePage).map { it.toDomain() },
sort = sort,
page = livePage,
hasNext = queryPolicy.hasNext(fetchedContents, livePage)
)
}
private fun validateCreatorRole(creator: CreatorChannelCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun Member.effectiveGender(): Gender {
auth?.let { return if (it.gender == 1) Gender.MALE else Gender.FEMALE }
return gender
}
private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive(
liveId = liveId,
title = title,
coverImageUrl = coverImagePath.toCdnUrl(),
beginDateTime = beginDateTime,
price = price,
isAdult = isAdult
)
private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent(
audioContentId = audioContentId,
title = title,
duration = duration,
imageUrl = imagePath.toCdnUrl(),
price = price,
isAdult = isAdult,
isPointAvailable = isPointAvailable,
isFirstContent = isFirstContent,
publishedAt = publishedAt,
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

@@ -0,0 +1,68 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.port.out
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
import java.time.LocalDateTime
interface CreatorChannelLiveQueryPort {
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord?
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
fun findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord?
fun countLiveReplayAudioContents(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int
fun findLiveReplayAudioContents(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord>
}
data class CreatorChannelCreatorRecord(
val creatorId: Long,
val role: MemberRole,
val nickname: String
)
data class CreatorChannelLiveRecord(
val liveId: Long,
val title: String,
val coverImagePath: String?,
val beginDateTime: LocalDateTime,
val price: Int,
val isAdult: Boolean
)
data class CreatorChannelAudioContentRecord(
val audioContentId: Long,
val title: String,
val duration: String?,
val imagePath: String?,
val price: Int,
val isAdult: Boolean,
val isPointAvailable: Boolean,
val isFirstContent: Boolean,
val publishedAt: LocalDateTime,
val seriesName: String?,
val isOriginalSeries: Boolean?,
val isOwned: Boolean,
val isRented: Boolean
)

View File

@@ -0,0 +1,397 @@
package kr.co.vividnext.sodalive.v2.creator.channel.live.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.Gender
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.auth.Auth
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.live.domain.CreatorChannelLiveReplayQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelAudioContentRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
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 CreatorChannelLiveQueryServiceTest {
@Test
@DisplayName("라이브 탭 서비스는 검증 후 현재 라이브와 다시듣기 조회에 필요한 정책 컨텍스트를 전달한다")
fun shouldPassLiveTabQueryContextToPort() {
val port = FakeCreatorChannelLiveQueryPort()
val service = createService(port, canViewAdultContent = false)
val viewer = createMember(id = 10L, gender = Gender.FEMALE, authGender = 1)
val now = LocalDateTime.of(2026, 6, 17, 10, 0)
val tab = service.getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.LATEST,
page = 0,
size = 20,
now = now
)
assertEquals(1L, port.findCreatorCreatorId)
assertEquals(10L, port.findCreatorViewerId)
assertEquals(10L, port.existsBlockedViewerId)
assertEquals(1L, port.existsBlockedCreatorId)
assertEquals(10L, port.currentLiveViewerId)
assertFalse(port.currentLiveIsViewerCreator == true)
assertEquals(Gender.MALE, port.currentLiveEffectiveViewerGender)
assertEquals(false, port.currentLiveCanViewAdultContent)
assertEquals(false, port.countCanViewAdultContent)
assertEquals(false, port.listCanViewAdultContent)
assertEquals(ContentSort.LATEST, port.listSort)
assertEquals(0L, port.listOffset)
assertEquals(21, port.listLimit)
assertEquals("https://cdn.test/live.png", tab.currentLive?.coverImageUrl)
assertEquals("https://cdn.test/audio/1.png", tab.liveReplayContents.first().imageUrl)
}
@Test
@DisplayName("라이브 탭 서비스는 size + 1개 조회 결과를 응답 size로 제한하고 hasNext를 true로 반환한다")
fun shouldAssembleLiveTabWithHasNextWhenFetchedMoreThanRequestedSize() {
val port = FakeCreatorChannelLiveQueryPort().apply {
liveReplayContents = (1L..21L).map { audioContentRecord(it) }
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.LATEST,
page = 0,
size = 20,
now = LocalDateTime.of(2026, 6, 17, 10, 0)
)
assertEquals(30, tab.liveReplayContentCount)
assertEquals(20, tab.liveReplayContents.size)
assertEquals((1L..20L).toList(), tab.liveReplayContents.map { it.audioContentId })
assertTrue(tab.hasNext)
assertEquals(ContentSort.LATEST, tab.sort)
assertEquals(0, tab.page.page)
assertEquals(20, tab.page.size)
assertTrue(tab.liveReplayContents.first().isOwned)
assertFalse(tab.liveReplayContents.first().isRented)
assertTrue(tab.liveReplayContents[1].isRented)
}
@Test
@DisplayName("라이브 탭 서비스는 빈 page에서도 count와 요청 page 정보를 유지한다")
fun shouldKeepCountAndPageWhenReplayContentsAreEmpty() {
val port = FakeCreatorChannelLiveQueryPort().apply {
liveReplayContents = emptyList()
currentLive = null
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getLiveTab(
creatorId = 1L,
viewer = viewer,
sort = ContentSort.PRICE_LOW,
page = 2,
size = 20,
now = LocalDateTime.of(2026, 6, 17, 10, 0)
)
assertEquals(30, tab.liveReplayContentCount)
assertNull(tab.currentLive)
assertEquals(emptyList<Any>(), tab.liveReplayContents)
assertFalse(tab.hasNext)
assertEquals(ContentSort.PRICE_LOW, tab.sort)
assertEquals(2, tab.page.page)
assertEquals(20, tab.page.size)
assertEquals(40L, port.listOffset)
assertEquals(21, port.listLimit)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelLiveQueryPort().apply {
creator = null
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val port = FakeCreatorChannelLiveQueryPort().apply {
creator = creator?.copy(role = MemberRole.USER)
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("차단 관계가 있으면 대상 회원이 크리에이터가 아니어도 접근 차단 예외를 먼저 던진다")
fun shouldThrowBlockedAccessBeforeCreatorNotFoundWhenViewerAndTargetAreBlocked() {
val port = FakeCreatorChannelLiveQueryPort().apply {
creator = creator?.copy(role = MemberRole.USER)
blocked = true
}
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message)
}
@Test
@DisplayName("잘못된 page 요청이면 invalid request 예외를 던진다")
fun shouldThrowInvalidRequestWhenPageIsNegative() {
val service = createServiceWithMissingPort()
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, -1, 20, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
@Test
@DisplayName("잘못된 size 요청이면 port 조회 전에 invalid request 예외를 던진다")
fun shouldThrowInvalidRequestBeforePortLookupWhenSizeIsOutOfRange() {
val service = createServiceWithMissingPort()
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 51, LocalDateTime.of(2026, 6, 17, 10, 0))
}
assertEquals("common.error.invalid_request", exception.messageKey)
}
private fun createService(
port: FakeCreatorChannelLiveQueryPort,
canViewAdultContent: Boolean = true
): CreatorChannelLiveQueryService {
return createService(
portProvider = FixedCreatorChannelLiveQueryPortProvider(port),
canViewAdultContent = canViewAdultContent
)
}
private fun createServiceWithMissingPort(): CreatorChannelLiveQueryService {
return createService(portProvider = MissingCreatorChannelLiveQueryPortProvider())
}
private fun createService(
portProvider: ObjectProvider<CreatorChannelLiveQueryPort>,
canViewAdultContent: Boolean = true
): CreatorChannelLiveQueryService {
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 messageSource = SodaMessageSource()
val langContext = LangContext()
langContext.setLang(Lang.KO)
return CreatorChannelLiveQueryService(
queryPortProvider = portProvider,
queryPolicy = CreatorChannelLiveReplayQueryPolicy(),
memberContentPreferenceService = preferenceService,
messageSource = messageSource,
langContext = langContext,
cloudFrontHost = "https://cdn.test"
)
}
private fun createMember(
id: Long,
gender: Gender = Gender.NONE,
authGender: Int? = null
): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
provider = MemberProvider.EMAIL,
gender = gender
)
member.id = id
authGender?.let {
Auth(
name = "name",
birth = "19900101",
uniqueCi = "ci$id",
di = "di$id",
gender = it
).member = member
}
return member
}
}
private class FixedCreatorChannelLiveQueryPortProvider(
private val port: CreatorChannelLiveQueryPort
) : ObjectProvider<CreatorChannelLiveQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelLiveQueryPort = port
override fun getIfAvailable(): CreatorChannelLiveQueryPort = port
override fun getIfUnique(): CreatorChannelLiveQueryPort = port
override fun getObject(): CreatorChannelLiveQueryPort = port
}
private class MissingCreatorChannelLiveQueryPortProvider : ObjectProvider<CreatorChannelLiveQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelLiveQueryPort {
throw IllegalStateException("port should not be resolved before page validation")
}
override fun getIfAvailable(): CreatorChannelLiveQueryPort? = null
override fun getIfUnique(): CreatorChannelLiveQueryPort? = null
override fun getObject(): CreatorChannelLiveQueryPort {
throw IllegalStateException("port should not be resolved before page validation")
}
}
private class FakeCreatorChannelLiveQueryPort : CreatorChannelLiveQueryPort {
var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
var blocked = false
var currentLive: CreatorChannelLiveRecord? = CreatorChannelLiveRecord(
liveId = 101L,
title = "live",
coverImagePath = "live.png",
beginDateTime = LocalDateTime.of(2026, 6, 17, 9, 0),
price = 10,
isAdult = false
)
var liveReplayContentCount = 30
var liveReplayContents = (1L..21L).map { audioContentRecord(it) }
var findCreatorCreatorId: Long? = null
var findCreatorViewerId: Long? = null
var existsBlockedViewerId: Long? = null
var existsBlockedCreatorId: Long? = null
var currentLiveViewerId: Long? = null
var currentLiveIsViewerCreator: Boolean? = null
var currentLiveEffectiveViewerGender: Gender? = null
var currentLiveCanViewAdultContent: Boolean? = null
var countCanViewAdultContent: Boolean? = null
var listCanViewAdultContent: Boolean? = null
var listSort: ContentSort? = null
var listOffset: Long? = null
var listLimit: Int? = null
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? {
findCreatorCreatorId = creatorId
findCreatorViewerId = viewerId
return creator
}
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
existsBlockedViewerId = viewerId
existsBlockedCreatorId = creatorId
return blocked
}
override fun findCurrentLive(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean,
viewerId: Long?,
isViewerCreator: Boolean,
effectiveViewerGender: Gender?
): CreatorChannelLiveRecord? {
currentLiveViewerId = viewerId
currentLiveIsViewerCreator = isViewerCreator
currentLiveEffectiveViewerGender = effectiveViewerGender
currentLiveCanViewAdultContent = canViewAdultContent
return currentLive
}
override fun countLiveReplayAudioContents(
creatorId: Long,
now: LocalDateTime,
canViewAdultContent: Boolean
): Int {
countCanViewAdultContent = canViewAdultContent
return liveReplayContentCount
}
override fun findLiveReplayAudioContents(
creatorId: Long,
viewerId: Long?,
now: LocalDateTime,
canViewAdultContent: Boolean,
sort: ContentSort,
offset: Long,
limit: Int
): List<CreatorChannelAudioContentRecord> {
listCanViewAdultContent = canViewAdultContent
listSort = sort
listOffset = offset
listLimit = limit
return liveReplayContents
}
}
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,
publishedAt = LocalDateTime.of(2026, 6, 16, 10, 0),
seriesName = "series",
isOriginalSeries = true,
isOwned = audioContentId == 1L,
isRented = audioContentId == 2L
)
}