From 2ebc7286561ee13852fa0643f6493416333195c1 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 20 Jun 2026 03:19:41 +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=A0=95=EC=B1=85?= =?UTF-8?q?=EC=9D=84=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 --- .../domain/CreatorChannelSeriesQueryPolicy.kt | 128 +++++++++++++++ .../CreatorChannelSeriesQueryPolicyTest.kt | 150 ++++++++++++++++++ 2 files changed, 278 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt new file mode 100644 index 00000000..97f9a954 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt @@ -0,0 +1,128 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series.domain + +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import org.springframework.stereotype.Component + +@Component +class CreatorChannelSeriesQueryPolicy { + fun resolveSort(sort: String?): ContentSort { + return runCatching { ContentSort.valueOf(sort ?: ContentSort.LATEST.name) } + .getOrDefault(ContentSort.LATEST) + } + + fun createPage(page: Int?, size: Int?): CreatorChannelPage { + return CreatorChannelPage( + page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE, + size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE + ) + } + + fun limitItems(fetched: List, page: CreatorChannelPage): List { + return fetched.take(page.size) + } + + fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean { + return fetched.size > page.size + } + + fun purchaseRate(paidContentCount: Int, purchasedContentCount: Int): Int { + if (paidContentCount == 0) { + return 0 + } + return purchasedContentCount * 100 / paidContentCount + } + + fun publishedDaysOfWeekText(days: Set, locale: String): String { + if (days.contains(SeriesPublishedDaysOfWeek.RANDOM)) { + return randomText(locale) + } + if (days.containsAll(WEEKDAYS)) { + return everyDayText(locale) + } + + val dayText = WEEKDAYS + .filter(days::contains) + .joinToString(", ") { dayText(it, locale) } + + return weeklyText(dayText, locale) + } + + private fun randomText(locale: String): String { + return when (locale) { + "en" -> "Random" + "ja" -> "ランダム" + else -> "랜덤" + } + } + + private fun everyDayText(locale: String): String { + return when (locale) { + "en" -> "Every day" + "ja" -> "毎日" + else -> "매일" + } + } + + private fun weeklyText(dayText: String, locale: String): String { + return when (locale) { + "en" -> "Every $dayText" + "ja" -> "毎週 $dayText" + else -> "매주 $dayText" + } + } + + private fun dayText(day: SeriesPublishedDaysOfWeek, locale: String): String { + return when (locale) { + "en" -> EN_DAY_TEXTS.getValue(day) + "ja" -> JA_DAY_TEXTS.getValue(day) + else -> KO_DAY_TEXTS.getValue(day) + } + } + + companion object { + private const val DEFAULT_PAGE = 0 + private const val DEFAULT_PAGE_SIZE = 20 + private const val MIN_PAGE = 0 + private const val MIN_PAGE_SIZE = 20 + private const val MAX_PAGE_SIZE = 50 + + private val WEEKDAYS = listOf( + SeriesPublishedDaysOfWeek.SUN, + SeriesPublishedDaysOfWeek.MON, + SeriesPublishedDaysOfWeek.TUE, + SeriesPublishedDaysOfWeek.WED, + SeriesPublishedDaysOfWeek.THU, + SeriesPublishedDaysOfWeek.FRI, + SeriesPublishedDaysOfWeek.SAT + ) + private val KO_DAY_TEXTS = mapOf( + SeriesPublishedDaysOfWeek.SUN to "일", + SeriesPublishedDaysOfWeek.MON to "월", + SeriesPublishedDaysOfWeek.TUE to "화", + SeriesPublishedDaysOfWeek.WED to "수", + SeriesPublishedDaysOfWeek.THU to "목", + SeriesPublishedDaysOfWeek.FRI to "금", + SeriesPublishedDaysOfWeek.SAT to "토" + ) + private val EN_DAY_TEXTS = mapOf( + SeriesPublishedDaysOfWeek.SUN to "Sun", + SeriesPublishedDaysOfWeek.MON to "Mon", + SeriesPublishedDaysOfWeek.TUE to "Tue", + SeriesPublishedDaysOfWeek.WED to "Wed", + SeriesPublishedDaysOfWeek.THU to "Thu", + SeriesPublishedDaysOfWeek.FRI to "Fri", + SeriesPublishedDaysOfWeek.SAT to "Sat" + ) + private val JA_DAY_TEXTS = mapOf( + SeriesPublishedDaysOfWeek.SUN to "日", + SeriesPublishedDaysOfWeek.MON to "月", + SeriesPublishedDaysOfWeek.TUE to "火", + SeriesPublishedDaysOfWeek.WED to "水", + SeriesPublishedDaysOfWeek.THU to "木", + SeriesPublishedDaysOfWeek.FRI to "金", + SeriesPublishedDaysOfWeek.SAT to "土" + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt new file mode 100644 index 00000000..1ac55127 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt @@ -0,0 +1,150 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.series.domain + +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.member.MemberRole +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.series.port.out.CreatorChannelSeriesCreatorRecord +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.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +class CreatorChannelSeriesQueryPolicyTest { + private val policy = CreatorChannelSeriesQueryPolicy() + + @Test + @DisplayName("시리즈 탭 sort 정책은 null과 알 수 없는 값을 LATEST로 fallback한다") + fun shouldFallbackInvalidSortToLatest() { + assertEquals(ContentSort.LATEST, policy.resolveSort(null)) + assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN")) + assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR")) + } + + @Test + @DisplayName("시리즈 탭 page 정책은 page와 size를 fallback하고 fetch limit을 계산한다") + fun shouldFallbackPageAndSizeForSeriesTab() { + val minimumPage = policy.createPage(page = -1, size = 10) + val maximumPage = policy.createPage(page = 2, size = 100) + + assertEquals(0, minimumPage.page) + assertEquals(20, minimumPage.size) + assertEquals(0L, minimumPage.offset) + assertEquals(21, minimumPage.fetchLimit) + assertEquals(2, maximumPage.page) + assertEquals(50, maximumPage.size) + assertEquals(100L, maximumPage.offset) + assertEquals(51, maximumPage.fetchLimit) + } + + @Test + @DisplayName("시리즈 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다") + fun shouldLimitItemsAndCalculateHasNext() { + val page = policy.createPage(page = 0, size = 20) + val fetched = (1..21).toList() + + val items = policy.limitItems(fetched, page) + + assertEquals((1..20).toList(), items) + assertTrue(policy.hasNext(fetched, page)) + assertFalse(policy.hasNext((1..20).toList(), page)) + } + + @Test + @DisplayName("시리즈 탭 구매율은 유료 콘텐츠가 없으면 0이고 있으면 정수 백분율로 계산한다") + fun shouldCalculatePurchaseRateAsInteger() { + assertEquals(0, policy.purchaseRate(paidContentCount = 0, purchasedContentCount = 3)) + assertEquals(75, policy.purchaseRate(paidContentCount = 4, purchasedContentCount = 3)) + assertEquals(66, policy.purchaseRate(paidContentCount = 3, purchasedContentCount = 2)) + } + + @Test + @DisplayName("시리즈 탭 연재 요일은 RANDOM 포함 시 다른 요일을 무시하고 locale별 랜덤 문구를 반환한다") + fun shouldReturnRandomTextWhenDaysContainRandom() { + val days = setOf(SeriesPublishedDaysOfWeek.RANDOM, SeriesPublishedDaysOfWeek.MON) + + assertEquals("랜덤", policy.publishedDaysOfWeekText(days, "ko")) + assertEquals("Random", policy.publishedDaysOfWeekText(days, "en")) + assertEquals("ランダム", policy.publishedDaysOfWeekText(days, "ja")) + } + + @Test + @DisplayName("시리즈 탭 연재 요일은 7개 요일이면 locale별 매일 문구를 반환한다") + fun shouldReturnEveryDayTextWhenDaysContainAllWeekdays() { + val days = setOf( + SeriesPublishedDaysOfWeek.SUN, + SeriesPublishedDaysOfWeek.MON, + SeriesPublishedDaysOfWeek.TUE, + SeriesPublishedDaysOfWeek.WED, + SeriesPublishedDaysOfWeek.THU, + SeriesPublishedDaysOfWeek.FRI, + SeriesPublishedDaysOfWeek.SAT + ) + + assertEquals("매일", policy.publishedDaysOfWeekText(days, "ko")) + assertEquals("Every day", policy.publishedDaysOfWeekText(days, "en")) + assertEquals("毎日", policy.publishedDaysOfWeekText(days, "ja")) + } + + @Test + @DisplayName("시리즈 탭 연재 요일은 SUN부터 SAT 순서로 locale별 매주 문구를 반환한다") + fun shouldReturnWeeklyTextOrderedFromSundayToSaturday() { + val days = setOf(SeriesPublishedDaysOfWeek.SAT, SeriesPublishedDaysOfWeek.MON, SeriesPublishedDaysOfWeek.THU) + + assertEquals("매주 월, 목, 토", policy.publishedDaysOfWeekText(days, "ko")) + assertEquals("Every Mon, Thu, Sat", policy.publishedDaysOfWeekText(days, "en")) + assertEquals("毎週 月, 木, 土", policy.publishedDaysOfWeekText(days, "ja")) + } + + @Test + @DisplayName("시리즈 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다") + fun shouldKeepDomainAndPortContract() { + val tab = CreatorChannelSeriesTab( + seriesCount = 1, + series = listOf( + CreatorChannelSeries( + seriesId = 10L, + title = "title", + coverImageUrl = null, + publishedDaysOfWeek = "매일", + isOriginal = true, + isAdult = false, + isProceeding = true, + contentCount = 3, + purchasedContentCount = null, + paidContentCount = null, + purchasedPaidContentRate = null + ) + ), + sort = ContentSort.LATEST, + page = policy.createPage(page = 0, size = 20), + hasNext = false + ) + val creatorRecord = CreatorChannelSeriesCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator" + ) + val seriesRecord = CreatorChannelSeriesRecord( + seriesId = 10L, + title = "title", + coverImagePath = null, + publishedDaysOfWeek = setOf(SeriesPublishedDaysOfWeek.MON), + isOriginal = true, + isAdult = false, + state = SeriesState.PROCEEDING, + contentCount = 3, + purchasedContentCount = null, + paidContentCount = null + ) + + assertEquals(1, tab.seriesCount) + assertTrue(tab.series.first().isProceeding) + assertNull(tab.series.first().purchasedPaidContentRate) + assertEquals(MemberRole.CREATOR, creatorRecord.role) + assertEquals(setOf(SeriesPublishedDaysOfWeek.MON), seriesRecord.publishedDaysOfWeek) + } +}