From 41937c7cce937ba21a88799b8e393246977036db Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 14:26:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20FanTalk=20=ED=83=AD=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B3=84=EC=95=BD=EC=9D=84=20?= =?UTF-8?q?=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 --- .../CreatorChannelFanTalkQueryPolicy.kt | 30 +++++ .../domain/CreatorChannelFanTalkTab.kt | 30 +++++ .../out/CreatorChannelFanTalkQueryPort.kt | 49 +++++++ .../CreatorChannelFanTalkQueryPolicyTest.kt | 120 ++++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt new file mode 100644 index 00000000..630d1ed8 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicy.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain + +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import org.springframework.stereotype.Component + +@Component +class CreatorChannelFanTalkQueryPolicy { + 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 + } + + 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 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt new file mode 100644 index 00000000..578a604f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkTab.kt @@ -0,0 +1,30 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain + +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import java.time.LocalDateTime + +data class CreatorChannelFanTalkTab( + val fanTalkCount: Int, + val fanTalks: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelFanTalk( + val fanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImageUrl: String, + val content: String, + val createdAt: LocalDateTime, + val creatorReplies: List +) + +data class CreatorChannelFanTalkReply( + val fanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImageUrl: String, + val content: String, + val createdAt: LocalDateTime +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt new file mode 100644 index 00000000..957408cb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/port/out/CreatorChannelFanTalkQueryPort.kt @@ -0,0 +1,49 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out + +import kr.co.vividnext.sodalive.member.MemberRole +import java.time.LocalDateTime + +interface CreatorChannelFanTalkQueryPort { + fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? + + fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean + + fun countFanTalks(creatorId: Long, viewerId: Long): Int + + fun findFanTalks( + creatorId: Long, + viewerId: Long, + offset: Long, + limit: Int + ): List + + fun findCreatorReplies( + creatorId: Long, + parentFanTalkIds: List + ): List +} + +data class CreatorChannelFanTalkCreatorRecord( + val creatorId: Long, + val role: MemberRole, + val nickname: String +) + +data class CreatorChannelFanTalkRecord( + val fanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImagePath: String?, + val content: String, + val createdAt: LocalDateTime +) + +data class CreatorChannelFanTalkReplyRecord( + val fanTalkId: Long, + val parentFanTalkId: Long, + val writerId: Long, + val writerNickname: String, + val writerProfileImagePath: String?, + val content: String, + val createdAt: LocalDateTime +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt new file mode 100644 index 00000000..ccac9838 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/fantalk/domain/CreatorChannelFanTalkQueryPolicyTest.kt @@ -0,0 +1,120 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain + +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord +import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord +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 CreatorChannelFanTalkQueryPolicyTest { + private val policy = CreatorChannelFanTalkQueryPolicy() + + @Test + @DisplayName("FanTalk 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다") + fun shouldFallbackNullPageAndSizeForFanTalkTab() { + 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("FanTalk 탭 page 정책은 최소/최대 범위로 fallback하고 fetch limit을 계산한다") + fun shouldFallbackPageAndSizeForFanTalkTab() { + 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("FanTalk 탭 목록 정책은 요청 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("FanTalk 탭 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 reply = CreatorChannelFanTalkReply( + fanTalkId = 11L, + writerId = 1L, + writerNickname = "creator", + writerProfileImageUrl = "https://cdn.test/creator.png", + content = "reply", + createdAt = createdAt.plusMinutes(1) + ) + val tab = CreatorChannelFanTalkTab( + fanTalkCount = 1, + fanTalks = listOf( + CreatorChannelFanTalk( + fanTalkId = 10L, + writerId = 2L, + writerNickname = "fan", + writerProfileImageUrl = "https://cdn.test/fan.png", + content = "fan talk", + createdAt = createdAt, + creatorReplies = listOf(reply) + ) + ), + page = page, + hasNext = false + ) + val creatorRecord = CreatorChannelFanTalkCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator" + ) + val fanTalkRecord = CreatorChannelFanTalkRecord( + fanTalkId = 10L, + writerId = 2L, + writerNickname = "fan", + writerProfileImagePath = null, + content = "fan talk", + createdAt = createdAt + ) + val replyRecord = CreatorChannelFanTalkReplyRecord( + fanTalkId = 11L, + parentFanTalkId = 10L, + writerId = 1L, + writerNickname = "creator", + writerProfileImagePath = null, + content = "reply", + createdAt = createdAt.plusMinutes(1) + ) + + assertEquals(1, tab.fanTalkCount) + assertEquals("fan", tab.fanTalks.first().writerNickname) + assertEquals(reply, tab.fanTalks.first().creatorReplies.first()) + assertEquals(page, tab.page) + assertFalse(tab.hasNext) + assertEquals(MemberRole.CREATOR, creatorRecord.role) + assertNull(fanTalkRecord.writerProfileImagePath) + assertEquals(10L, replyRecord.parentFanTalkId) + } +}