From e516a7406f94344ca1cafafbcbddd115f3bb2987 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 17:59:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=ED=9B=84=EC=9B=90=20?= =?UTF-8?q?=ED=83=AD=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B3=84=EC=95=BD?= =?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 --- .../CreatorChannelDonationQueryPolicy.kt | 54 ++++++++ .../domain/CreatorChannelDonationTab.kt | 27 ++++ .../out/CreatorChannelDonationQueryPort.kt | 41 ++++++ .../out/CreatorChannelDonationRankingPort.kt | 18 +++ .../CreatorChannelDonationQueryPolicyTest.kt | 125 ++++++++++++++++++ 5 files changed, 265 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt new file mode 100644 index 00000000..7504f296 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicy.kt @@ -0,0 +1,54 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain + +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class CreatorChannelDonationQueryPolicy { + 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 currentKstMonthRange(now: LocalDateTime): CreatorChannelDonationMonthRange { + val nowKst = now.atZone(UTC_ZONE_ID).withZoneSameInstant(KST_ZONE_ID) + val start = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(KST_ZONE_ID) + .withZoneSameInstant(UTC_ZONE_ID) + .toLocalDateTime() + val end = nowKst.toLocalDate().withDayOfMonth(1).atStartOfDay(KST_ZONE_ID).plusMonths(1) + .withZoneSameInstant(UTC_ZONE_ID) + .toLocalDateTime() + + return CreatorChannelDonationMonthRange( + startInclusiveUtc = start, + endExclusiveUtc = end + ) + } + + 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 KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul") + private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC") + } +} + +data class CreatorChannelDonationMonthRange( + val startInclusiveUtc: LocalDateTime, + val endExclusiveUtc: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt new file mode 100644 index 00000000..ad91d33f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain + +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import java.time.LocalDateTime + +data class CreatorChannelDonationTab( + val donationCount: Int, + val rankings: List, + val donations: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelDonationRanking( + val userId: Long, + val nickname: String, + val profileImage: String, + val donationCan: Int +) + +data class CreatorChannelDonation( + val nickname: String, + val profileImageUrl: String, + val can: Int, + val message: String, + val createdAt: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt new file mode 100644 index 00000000..48cba338 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out + +import kr.co.vividnext.sodalive.member.DonationRankingPeriod +import kr.co.vividnext.sodalive.member.MemberRole +import java.time.LocalDateTime + +interface CreatorChannelDonationQueryPort { + fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? + + fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean + + fun countChannelDonations( + creatorId: Long, + viewerId: Long, + now: LocalDateTime + ): Int + + fun findChannelDonations( + creatorId: Long, + viewerId: Long, + now: LocalDateTime, + offset: Long, + limit: Int + ): List +} + +data class CreatorChannelDonationCreatorRecord( + val creatorId: Long, + val role: MemberRole, + val nickname: String, + val isVisibleDonationRank: Boolean, + val donationRankingPeriod: DonationRankingPeriod? +) + +data class CreatorChannelDonationRecord( + val nickname: String, + val profileImagePath: String?, + val can: Int, + val message: String?, + val createdAt: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt new file mode 100644 index 00000000..46aae7dd --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationRankingPort.kt @@ -0,0 +1,18 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out + +import kr.co.vividnext.sodalive.member.DonationRankingPeriod + +interface CreatorChannelDonationRankingPort { + fun findTopRankings( + creatorId: Long, + period: DonationRankingPeriod, + withDonationCan: Boolean + ): List +} + +data class CreatorChannelDonationRankingRecord( + val userId: Long, + val nickname: String, + val profileImage: String, + val donationCan: Int +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt new file mode 100644 index 00000000..d4020312 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt @@ -0,0 +1,125 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.domain + +import kr.co.vividnext.sodalive.member.DonationRankingPeriod +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord +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 +import java.time.LocalDateTime + +class CreatorChannelDonationQueryPolicyTest { + private val policy = CreatorChannelDonationQueryPolicy() + + @Test + @DisplayName("후원 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다") + fun shouldFallbackNullPageAndSizeForDonationTab() { + val page = policy.createPage(page = null, size = null) + + assertEquals(0, page.page) + assertEquals(20, page.size) + assertEquals(0L, page.offset) + assertEquals(21, page.fetchLimit) + } + + @Test + @DisplayName("후원 탭 page 정책은 최소/최대 범위로 fallback하고 fetch limit을 계산한다") + fun shouldFallbackPageAndSizeForDonationTab() { + 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)) + assertFalse(policy.hasNext(emptyList(), page)) + } + + @Test + @DisplayName("후원 탭 월 범위 정책은 현재 UTC 시각 기준 KST 월 시작과 다음 월 시작을 UTC로 계산한다") + fun shouldCalculateCurrentKstMonthRangeAsUtc() { + val range = policy.currentKstMonthRange(LocalDateTime.of(2026, 6, 22, 3, 0)) + + assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), range.startInclusiveUtc) + assertEquals(LocalDateTime.of(2026, 6, 30, 15, 0), range.endExclusiveUtc) + } + + @Test + @DisplayName("후원 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다") + fun shouldKeepDomainAndPortContract() { + val createdAt = LocalDateTime.of(2026, 6, 22, 10, 0) + val page = policy.createPage(page = 0, size = 20) + val ranking = CreatorChannelDonationRanking( + userId = 10L, + nickname = "fan", + profileImage = "https://cdn.test/fan.png", + donationCan = 100 + ) + val donation = CreatorChannelDonation( + nickname = "donor", + profileImageUrl = "https://cdn.test/donor.png", + can = 50, + message = "thanks", + createdAt = createdAt + ) + val tab = CreatorChannelDonationTab( + donationCount = 1, + rankings = listOf(ranking), + donations = listOf(donation), + page = page, + hasNext = false + ) + val creatorRecord = CreatorChannelDonationCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator", + isVisibleDonationRank = true, + donationRankingPeriod = DonationRankingPeriod.CUMULATIVE + ) + val donationRecord = CreatorChannelDonationRecord( + nickname = "donor", + profileImagePath = null, + can = 50, + message = "thanks", + createdAt = createdAt + ) + val rankingRecord = CreatorChannelDonationRankingRecord( + userId = 10L, + nickname = "fan", + profileImage = "https://cdn.test/fan.png", + donationCan = 100 + ) + + assertEquals(1, tab.donationCount) + assertEquals(ranking, tab.rankings.first()) + assertEquals(donation, tab.donations.first()) + assertEquals(page, tab.page) + assertFalse(tab.hasNext) + assertEquals(MemberRole.CREATOR, creatorRecord.role) + assertEquals(DonationRankingPeriod.CUMULATIVE, creatorRecord.donationRankingPeriod) + assertNull(donationRecord.profileImagePath) + assertEquals(100, rankingRecord.donationCan) + } +}