diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt new file mode 100644 index 00000000..8ac95583 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacade.kt @@ -0,0 +1,35 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.live.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto.CreatorChannelLiveTabResponse +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class CreatorChannelLiveFacade( + private val creatorChannelLiveQueryService: CreatorChannelLiveQueryService +) { + fun getLiveTab( + creatorId: Long, + viewer: Member, + sort: ContentSort, + page: Int, + size: Int, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelLiveTabResponse { + return CreatorChannelLiveTabResponse.from( + creatorChannelLiveQueryService.getLiveTab( + creatorId = creatorId, + viewer = viewer, + sort = sort, + page = page, + size = size, + now = now + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt new file mode 100644 index 00000000..acebc474 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt @@ -0,0 +1,101 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.live.dto + +import com.fasterxml.jackson.annotation.JsonProperty +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.CreatorChannelLiveTab +import java.time.LocalDateTime +import java.time.ZoneOffset + +data class CreatorChannelLiveTabResponse( + val liveReplayContentCount: Int, + val currentLive: CreatorChannelLiveResponse?, + val liveReplayContents: List, + val sort: ContentSort, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(tab: CreatorChannelLiveTab): CreatorChannelLiveTabResponse { + return CreatorChannelLiveTabResponse( + liveReplayContentCount = tab.liveReplayContentCount, + currentLive = tab.currentLive?.let(CreatorChannelLiveResponse::from), + liveReplayContents = tab.liveReplayContents.map(CreatorChannelAudioContentResponse::from), + sort = tab.sort, + page = tab.page.page, + size = tab.page.size, + hasNext = tab.hasNext + ) + } + } +} + +data class CreatorChannelAudioContentResponse( + val audioContentId: Long, + val title: String, + val duration: String?, + val imageUrl: String?, + val price: Int, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isPointAvailable") + val isPointAvailable: Boolean, + @JsonProperty("isFirstContent") + val isFirstContent: Boolean, + val seriesName: String?, + @JsonProperty("isOriginalSeries") + val isOriginalSeries: Boolean?, + @JsonProperty("isOwned") + val isOwned: Boolean, + @JsonProperty("isRented") + val isRented: Boolean +) { + companion object { + fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse { + return CreatorChannelAudioContentResponse( + audioContentId = content.audioContentId, + title = content.title, + duration = content.duration, + imageUrl = content.imageUrl, + price = content.price, + isAdult = content.isAdult, + isPointAvailable = content.isPointAvailable, + isFirstContent = content.isFirstContent, + seriesName = content.seriesName, + isOriginalSeries = content.isOriginalSeries, + isOwned = content.isOwned, + isRented = content.isRented + ) + } + } +} + +data class CreatorChannelLiveResponse( + val liveId: Long, + val title: String, + val coverImageUrl: String?, + val beginDateTimeUtc: String, + val price: Int, + @JsonProperty("isAdult") + val isAdult: Boolean +) { + companion object { + fun from(live: CreatorChannelLive): CreatorChannelLiveResponse { + return CreatorChannelLiveResponse( + liveId = live.liveId, + title = live.title, + coverImageUrl = live.coverImageUrl, + beginDateTimeUtc = live.beginDateTime.toUtcIso(), + price = live.price, + isAdult = live.isAdult + ) + } + } +} + +private fun LocalDateTime.toUtcIso(): String { + return atOffset(ZoneOffset.UTC).toInstant().toString() +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt new file mode 100644 index 00000000..337332e7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/application/CreatorChannelLiveFacadeTest.kt @@ -0,0 +1,101 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.live.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.live.application.CreatorChannelLiveQueryService +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.CreatorChannelLiveTab +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class CreatorChannelLiveFacadeTest { + @Test + @DisplayName("라이브 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다") + fun shouldMapLiveTabQueryResultToPublicResponse() { + val service = Mockito.mock(CreatorChannelLiveQueryService::class.java) + val facade = CreatorChannelLiveFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 6, 17, 10, 0) + Mockito.doReturn(createTab()).`when`(service).getLiveTab( + creatorId = 1L, + viewer = viewer, + sort = ContentSort.LATEST, + page = 0, + size = 20, + now = now + ) + + val response = facade.getLiveTab( + creatorId = 1L, + viewer = viewer, + sort = ContentSort.LATEST, + page = 0, + size = 20, + now = now + ) + + assertEquals(1, response.liveReplayContentCount) + assertEquals(101L, response.currentLive?.liveId) + assertEquals("2026-06-17T01:00:00Z", response.currentLive?.beginDateTimeUtc) + assertEquals(201L, response.liveReplayContents.first().audioContentId) + assertTrue(response.liveReplayContents.first().isOwned) + assertFalse(response.liveReplayContents.first().isRented) + assertEquals(ContentSort.LATEST, response.sort) + assertEquals(0, response.page) + assertEquals(20, response.size) + assertFalse(response.hasNext) + } + + private fun createMember(id: Long): Member { + return Member( + email = "viewer$id@test.com", + password = "password", + nickname = "viewer$id", + role = MemberRole.USER + ).apply { + this.id = id + } + } + + private fun createTab(): CreatorChannelLiveTab { + return CreatorChannelLiveTab( + liveReplayContentCount = 1, + currentLive = CreatorChannelLive( + liveId = 101L, + title = "live", + coverImageUrl = "live.png", + beginDateTime = LocalDateTime.of(2026, 6, 17, 1, 0), + price = 20, + isAdult = true + ), + liveReplayContents = listOf( + CreatorChannelAudioContent( + audioContentId = 201L, + title = "audio", + duration = "00:10:00", + imageUrl = "audio.png", + price = 30, + isAdult = false, + isPointAvailable = true, + isFirstContent = true, + publishedAt = LocalDateTime.of(2026, 6, 16, 1, 0), + seriesName = "series", + isOriginalSeries = true, + isOwned = true, + isRented = false + ) + ), + sort = ContentSort.LATEST, + page = CreatorChannelPage(page = 0, size = 20), + hasNext = false + ) + } +}