diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt new file mode 100644 index 00000000..b4e04dd0 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt @@ -0,0 +1,52 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.domain + +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import org.springframework.stereotype.Component + +@Component +class CreatorChannelCommunityQueryPolicy { + 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 maskPaidContent( + content: String, + price: Int, + isCreatorSelf: Boolean, + existOrdered: Boolean + ): String { + if (price <= 0 || isCreatorSelf || existOrdered) { + return content + } + + val codePointCount = content.codePointCount(0, content.length) + val visibleCodePointCount = if (codePointCount > PAID_CONTENT_PREVIEW_CODE_POINTS) { + PAID_CONTENT_PREVIEW_CODE_POINTS + } else { + codePointCount / 2 + } + val endIndex = content.offsetByCodePoints(0, visibleCodePointCount) + return content.substring(0, endIndex) + PAID_CONTENT_MASK_SUFFIX + } + + 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 const val PAID_CONTENT_PREVIEW_CODE_POINTS = 15 + private const val PAID_CONTENT_MASK_SUFFIX = "..." + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt new file mode 100644 index 00000000..3790e03a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt @@ -0,0 +1,28 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.domain + +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import java.time.LocalDateTime + +data class CreatorChannelCommunityTab( + val communityPostCount: Int, + val communityPosts: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelCommunityPost( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val imageUrl: String?, + val audioUrl: String?, + val content: String, + val price: Int, + val createdAt: LocalDateTime, + val existOrdered: Boolean, + val isCommentAvailable: Boolean, + val likeCount: Int, + val commentCount: Int, + val isPinned: Boolean +) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt new file mode 100644 index 00000000..2eaa4764 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt @@ -0,0 +1,55 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out + +import kr.co.vividnext.sodalive.member.MemberRole +import java.time.LocalDateTime + +interface CreatorChannelCommunityQueryPort { + fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? + + fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean + + fun countCommunityPosts( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): Int + + fun findCommunityPosts( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean, + offset: Long, + limit: Int + ): List + + fun findHomeCommunityPosts( + creatorId: Long, + viewerId: Long, + isPinned: Boolean, + canViewAdultContent: Boolean, + limit: Int + ): List +} + +data class CreatorChannelCommunityCreatorRecord( + val creatorId: Long, + val role: MemberRole, + val nickname: String +) + +data class CreatorChannelCommunityPostRecord( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfilePath: String?, + val imagePath: String?, + val audioPath: String?, + val content: String, + val price: Int, + val createdAt: LocalDateTime, + val existOrdered: Boolean, + val isCommentAvailable: Boolean, + val likeCount: Int, + val commentCount: Int, + val isPinned: Boolean +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt new file mode 100644 index 00000000..307a33aa --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt @@ -0,0 +1,164 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.domain + +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord +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 CreatorChannelCommunityQueryPolicyTest { + private val policy = CreatorChannelCommunityQueryPolicy() + + @Test + @DisplayName("커뮤니티 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다") + fun shouldFallbackNullPageAndSizeForCommunityTab() { + 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 shouldFallbackPageAndSizeForCommunityTab() { + 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("유료 커뮤니티 본문은 접근 권한이 없으면 code point 기준으로 마스킹한다") + fun shouldMaskPaidContentWhenViewerCannotAccess() { + assertEquals( + "123456789012345...", + policy.maskPaidContent( + content = "1234567890123456", + price = 100, + isCreatorSelf = false, + existOrdered = false + ) + ) + assertEquals( + "1234567...", + policy.maskPaidContent( + content = "123456789012345", + price = 100, + isCreatorSelf = false, + existOrdered = false + ) + ) + assertEquals( + "가나다라마바사아자차카타파하🙂...", + policy.maskPaidContent( + content = "가나다라마바사아자차카타파하🙂끝", + price = 100, + isCreatorSelf = false, + existOrdered = false + ) + ) + } + + @Test + @DisplayName("무료 게시글, 작성자 본인, 구매자는 유료 본문 원문을 볼 수 있다") + fun shouldReturnOriginalContentWhenViewerCanAccess() { + assertEquals( + "free content", + policy.maskPaidContent("free content", price = 0, isCreatorSelf = false, existOrdered = false) + ) + assertEquals( + "creator content", + policy.maskPaidContent("creator content", price = 100, isCreatorSelf = true, existOrdered = false) + ) + assertEquals( + "ordered content", + policy.maskPaidContent("ordered content", price = 100, isCreatorSelf = false, existOrdered = true) + ) + } + + @Test + @DisplayName("커뮤니티 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다") + fun shouldKeepDomainAndPortContract() { + val createdAt = LocalDateTime.of(2026, 6, 21, 10, 0) + val page = policy.createPage(page = 0, size = 20) + val tab = CreatorChannelCommunityTab( + communityPostCount = 1, + communityPosts = listOf( + CreatorChannelCommunityPost( + postId = 10L, + creatorId = 1L, + creatorNickname = "creator", + creatorProfileUrl = "https://cdn.test/profile.png", + imageUrl = null, + audioUrl = null, + content = "content", + price = 100, + createdAt = createdAt, + existOrdered = false, + isCommentAvailable = true, + likeCount = 2, + commentCount = 3, + isPinned = true + ) + ), + page = page, + hasNext = false + ) + val creatorRecord = CreatorChannelCommunityCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator" + ) + val postRecord = CreatorChannelCommunityPostRecord( + postId = 10L, + creatorId = 1L, + creatorNickname = "creator", + creatorProfilePath = null, + imagePath = null, + audioPath = null, + content = "content", + price = 100, + createdAt = createdAt, + existOrdered = false, + isCommentAvailable = true, + likeCount = 2, + commentCount = 3, + isPinned = true + ) + + assertEquals(1, tab.communityPostCount) + assertEquals("creator", tab.communityPosts.first().creatorNickname) + assertEquals(page, tab.page) + assertFalse(tab.hasNext) + assertEquals(MemberRole.CREATOR, creatorRecord.role) + assertNull(postRecord.imagePath) + assertTrue(postRecord.isPinned) + } +}