diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt index ea3893ab..551c7350 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt @@ -2,15 +2,22 @@ package kr.co.vividnext.sodalive.v2.creator.channel.community.application import kr.co.vividnext.sodalive.aws.cloudfront.AudioContentCloudFront import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComment +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityComments import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPostDetail import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord 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 kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort @@ -93,6 +100,115 @@ class CreatorChannelCommunityQueryService( .map { it.toDomain(viewerId) } } + fun getCommunityPostDetail( + postId: Long, + viewer: Member, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityPostDetail { + val queryPort = queryPortProvider.getObject() + val viewerId = viewer.id!! + val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer) + val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent) + ?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY) + + validateNotBlocked(queryPort, viewerId, postRecord.creatorId) + + return CreatorChannelCommunityPostDetail( + post = postRecord.toDomain(viewerId), + comments = getCommunityComments(postId, viewer, page = 0, size = 20, now = now) + ) + } + + fun getCommunityComments( + postId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityComments { + val communityPage = queryPolicy.createPage(page, size) + val queryPort = queryPortProvider.getObject() + val viewerId = viewer.id!! + val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer) + val postRecord = queryPort.findCommunityPost(postId, viewerId, canViewAdultContent) + ?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY) + + validateNotBlocked(queryPort, viewerId, postRecord.creatorId) + + if (!postRecord.isCommentAvailable) { + return CreatorChannelCommunityComments( + commentCount = 0, + comments = emptyList(), + page = communityPage, + hasNext = false + ) + } + + val fetchedComments = queryPort.findCommunityComments( + postId = postId, + viewerId = viewerId, + creatorId = postRecord.creatorId, + offset = communityPage.offset, + limit = communityPage.fetchLimit + ) + val limitedComments = queryPolicy.limitItems(fetchedComments, communityPage) + val latestReplies = queryPort.findLatestRepliesByCommentIds( + commentIds = limitedComments.map { it.commentId }, + viewerId = viewerId + ).associateBy { it.parentCommentId } + + return CreatorChannelCommunityComments( + commentCount = queryPort.countCommunityComments( + postId = postId, + viewerId = viewerId, + creatorId = postRecord.creatorId + ), + comments = limitedComments.map { it.toCommentDomain(latestReplies[it.commentId]?.toReplyDomain()) }, + page = communityPage, + hasNext = queryPolicy.hasNext(fetchedComments, communityPage) + ) + } + + fun getCommunityReplies( + commentId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityReplies { + val communityPage = queryPolicy.createPage(page, size) + val queryPort = queryPortProvider.getObject() + val viewerId = viewer.id!! + val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer) + val context = queryPort.findRootCommentContext(commentId, viewerId, canViewAdultContent) + ?: throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY) + + validateNotBlocked(queryPort, viewerId, context.creatorId) + + if (!context.isCommentAvailable) { + return CreatorChannelCommunityReplies( + replyCount = 0, + replies = emptyList(), + page = communityPage, + hasNext = false + ) + } + + val fetchedReplies = queryPort.findCommunityReplies( + commentId = commentId, + viewerId = viewerId, + offset = communityPage.offset, + limit = communityPage.fetchLimit + ) + + return CreatorChannelCommunityReplies( + replyCount = queryPort.countCommunityReplies(commentId, viewerId), + replies = queryPolicy.limitItems(fetchedReplies, communityPage).map { it.toReplyDomain() }, + page = communityPage, + hasNext = queryPolicy.hasNext(fetchedReplies, communityPage) + ) + } + private fun validateCreatorRole(creator: CreatorChannelCommunityCreatorRecord) { when (creator.role) { MemberRole.CREATOR -> return @@ -100,6 +216,12 @@ class CreatorChannelCommunityQueryService( } } + private fun validateNotBlocked(queryPort: CreatorChannelCommunityQueryPort, viewerId: Long, creatorId: Long) { + if (queryPort.existsBlockedBetween(viewerId, creatorId)) { + throw SodaException(messageKey = INVALID_COMMUNITY_REQUEST_MESSAGE_KEY) + } + } + private fun CreatorChannelCommunityPostRecord.toDomain(viewerId: Long): CreatorChannelCommunityPost { val canAccessPaidContent = price <= 0 || viewerId == creatorId || existOrdered return CreatorChannelCommunityPost( @@ -126,6 +248,29 @@ class CreatorChannelCommunityQueryService( ) } + private fun CreatorChannelCommunityCommentRecord.toCommentDomain( + latestReply: CreatorChannelCommunityReply? + ): CreatorChannelCommunityComment { + return CreatorChannelCommunityComment( + commentId = commentId, + writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + writerNickname = writerNickname.removeDeletedNicknamePrefix(), + content = content, + createdAt = createdAt, + latestReply = latestReply + ) + } + + private fun CreatorChannelCommunityCommentRecord.toReplyDomain(): CreatorChannelCommunityReply { + return CreatorChannelCommunityReply( + commentId = commentId, + writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + writerNickname = writerNickname.removeDeletedNicknamePrefix(), + content = content, + createdAt = createdAt + ) + } + private fun String?.toSignedAudioUrl(): String? { if (isNullOrBlank()) return null return audioContentCloudFront.generateSignedURL(this, AUDIO_SIGNED_URL_EXPIRATION_MILLIS) @@ -135,5 +280,6 @@ class CreatorChannelCommunityQueryService( companion object { private const val AUDIO_SIGNED_URL_EXPIRATION_MILLIS = 1000L * 60 * 30 + private const val INVALID_COMMUNITY_REQUEST_MESSAGE_KEY = "creator.community.invalid_request_retry" } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt index 200fdf20..1d6569cd 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt @@ -10,6 +10,8 @@ import kr.co.vividnext.sodalive.member.MemberProvider import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord +import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentRecord 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 kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityQueryPort @@ -173,6 +175,119 @@ class CreatorChannelCommunityQueryServiceTest { assertEquals(true, posts.single().isLiked) } + @Test + @DisplayName("커뮤니티 상세는 게시글과 초기 댓글 및 최신 답글을 조립한다") + fun shouldAssembleCommunityPostDetailWithInitialComments() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + communityPost = communityPostRecord(1L, price = 0, isLiked = true) + communityCommentCount = 21 + communityComments = (1L..21L).map { communityCommentRecord(it) } + latestReplies = listOf(communityCommentRecord(100L, parentCommentId = 1L, writerNickname = "deleted_reply-writer")) + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val detail = service.getCommunityPostDetail(1L, viewer, LocalDateTime.of(2026, 7, 6, 10, 0)) + + assertEquals(1L, detail.post.postId) + assertEquals(true, detail.post.isLiked) + assertEquals(21, detail.comments.commentCount) + assertEquals(20, detail.comments.comments.size) + assertEquals(true, detail.comments.hasNext) + assertEquals("reply-writer", detail.comments.comments.first().latestReply?.writerNickname) + assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl) + } + + @Test + @DisplayName("댓글 불가 게시글은 댓글 port를 호출하지 않고 빈 댓글 응답을 반환한다") + fun shouldReturnEmptyCommentsWhenCommentUnavailable() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + communityPost = communityPostRecord(1L, price = 0, isCommentAvailable = false) + } + val service = createService(port) + + val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0)) + + assertEquals(0, comments.commentCount) + assertEquals(0, comments.comments.size) + assertEquals(0, comments.page.page) + assertEquals(50, comments.page.size) + assertEquals(false, comments.hasNext) + assertEquals(false, port.commentListCalled) + } + + @Test + @DisplayName("커뮤니티 댓글은 page fallback과 최신 답글을 적용한다") + fun shouldApplyCommentPageFallbackAndLatestReply() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + communityPost = communityPostRecord(1L, price = 0) + communityCommentCount = 60 + communityComments = (1L..51L).map { communityCommentRecord(it, writerProfilePath = "profile/$it.png") } + latestReplies = listOf(communityCommentRecord(100L, parentCommentId = 2L)) + } + val service = createService(port) + + val comments = service.getCommunityComments(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0)) + + assertEquals(0L, port.commentListOffset) + assertEquals(51, port.commentListLimit) + assertEquals((1L..50L).toList(), port.latestReplyCommentIds) + assertEquals(50, comments.comments.size) + assertEquals(true, comments.hasNext) + assertEquals("https://cdn.test/profile/1.png", comments.comments.first().writerProfileImageUrl) + assertEquals(100L, comments.comments[1].latestReply?.commentId) + } + + @Test + @DisplayName("커뮤니티 답글은 부모 댓글 context를 확인하고 page fallback으로 조립한다") + fun shouldAssembleCommunityReplies() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + rootCommentContext = CreatorChannelCommunityCommentContextRecord(1L, 10L, 1L, true) + communityReplyCount = 51 + communityReplies = (1L..51L).map { communityCommentRecord(it, parentCommentId = 1L) } + } + val service = createService(port) + + val replies = service.getCommunityReplies(1L, createMember(id = 10L), -1, 100, LocalDateTime.of(2026, 7, 6, 10, 0)) + + assertEquals(51, replies.replyCount) + assertEquals(50, replies.replies.size) + assertEquals(0L, port.replyListOffset) + assertEquals(51, port.replyListLimit) + assertEquals(true, replies.hasNext) + } + + @Test + @DisplayName("부모 댓글 context가 보이지 않으면 커뮤니티 답글 조회는 예외를 던진다") + fun shouldThrowWhenRootCommentContextNotVisible() { + val port = FakeCreatorChannelCommunityQueryPort().apply { rootCommentContext = null } + val service = createService(port) + + val exception = assertThrows(SodaException::class.java) { + service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0)) + } + + assertEquals("creator.community.invalid_request_retry", exception.messageKey) + } + + @Test + @DisplayName("댓글 불가 게시글의 답글 직접 조회는 빈 응답을 반환한다") + fun shouldReturnEmptyRepliesWhenCommentUnavailable() { + val port = FakeCreatorChannelCommunityQueryPort().apply { + rootCommentContext = CreatorChannelCommunityCommentContextRecord(1L, 10L, 1L, false) + communityReplyCount = 1 + communityReplies = listOf(communityCommentRecord(2L, parentCommentId = 1L)) + } + val service = createService(port) + + val replies = service.getCommunityReplies(1L, createMember(id = 10L), null, null, LocalDateTime.of(2026, 7, 6, 10, 0)) + + assertEquals(0, replies.replyCount) + assertEquals(0, replies.replies.size) + assertEquals(false, replies.hasNext) + assertEquals(null, port.replyListOffset) + } + private fun createService( port: FakeCreatorChannelCommunityQueryPort, audioContentCloudFront: AudioContentCloudFront = Mockito.mock(AudioContentCloudFront::class.java), @@ -226,11 +341,29 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer var blocked = false var communityPostCount = 1 var communityPosts = listOf(communityPostRecord(1L, price = 0)) + var communityPost: CreatorChannelCommunityPostRecord? = communityPostRecord(1L, price = 0) var homeCommunityPosts = listOf(communityPostRecord(1L, price = 0)) + var communityCommentCount = 0 + var communityComments = emptyList() + var latestReplies = emptyList() + var rootCommentContext: CreatorChannelCommunityCommentContextRecord? = CreatorChannelCommunityCommentContextRecord( + commentId = 1L, + postId = 1L, + creatorId = 1L, + isCommentAvailable = true + ) + var communityReplyCount = 0 + var communityReplies = emptyList() var countCanViewAdultContent: Boolean? = null var listCanViewAdultContent: Boolean? = null var listOffset: Long? = null var listLimit: Int? = null + var commentListCalled = false + var commentListOffset: Long? = null + var commentListLimit: Int? = null + var latestReplyCommentIds = emptyList() + var replyListOffset: Long? = null + var replyListLimit: Int? = null var homeCreatorId: Long? = null var homeViewerId: Long? = null var homeIsPinned: Boolean? = null @@ -277,6 +410,54 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer homeLimit = limit return homeCommunityPosts } + + override fun findCommunityPost( + postId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): CreatorChannelCommunityPostRecord? = communityPost + + override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = communityCommentCount + + override fun findCommunityComments( + postId: Long, + viewerId: Long, + creatorId: Long, + offset: Long, + limit: Int + ): List { + commentListCalled = true + commentListOffset = offset + commentListLimit = limit + return communityComments + } + + override fun findLatestRepliesByCommentIds( + commentIds: List, + viewerId: Long + ): List { + latestReplyCommentIds = commentIds + return latestReplies + } + + override fun findRootCommentContext( + commentId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): CreatorChannelCommunityCommentContextRecord? = rootCommentContext + + override fun countCommunityReplies(commentId: Long, viewerId: Long): Int = communityReplyCount + + override fun findCommunityReplies( + commentId: Long, + viewerId: Long, + offset: Long, + limit: Int + ): List { + replyListOffset = offset + replyListLimit = limit + return communityReplies + } } private fun communityPostRecord( @@ -287,7 +468,8 @@ private fun communityPostRecord( creatorProfilePath: String? = "profile/$postId.png", imagePath: String? = "image/$postId.png", audioPath: String? = "audio/$postId.mp3", - isLiked: Boolean = false + isLiked: Boolean = false, + isCommentAvailable: Boolean = true ): CreatorChannelCommunityPostRecord { return CreatorChannelCommunityPostRecord( postId = postId, @@ -300,10 +482,28 @@ private fun communityPostRecord( price = price, createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(postId), existOrdered = existOrdered, - isCommentAvailable = true, + isCommentAvailable = isCommentAvailable, likeCount = postId.toInt(), commentCount = postId.toInt() + 1, isPinned = postId == 1L, isLiked = isLiked ) } + +private fun communityCommentRecord( + commentId: Long, + parentCommentId: Long? = null, + writerProfilePath: String? = null, + writerNickname: String = "writer-$commentId" +): CreatorChannelCommunityCommentRecord { + return CreatorChannelCommunityCommentRecord( + commentId = commentId, + parentCommentId = parentCommentId, + postId = 1L, + writerId = commentId + 100L, + writerProfilePath = writerProfilePath, + writerNickname = writerNickname, + content = "comment-$commentId", + createdAt = LocalDateTime.of(2026, 7, 6, 10, 0).plusMinutes(commentId) + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt index e3a6e9dd..6e234918 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt @@ -742,4 +742,40 @@ private class FakeCreatorChannelCommunityQueryPort : CreatorChannelCommunityQuer ) ) } + + override fun findCommunityPost( + postId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): CreatorChannelCommunityPostRecord? = null + + override fun countCommunityComments(postId: Long, viewerId: Long, creatorId: Long): Int = 0 + + override fun findCommunityComments( + postId: Long, + viewerId: Long, + creatorId: Long, + offset: Long, + limit: Int + ) = emptyList() + + override fun findLatestRepliesByCommentIds( + commentIds: List, + viewerId: Long + ) = emptyList() + + override fun findRootCommentContext( + commentId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCommentContextRecord? = null + + override fun countCommunityReplies(commentId: Long, viewerId: Long): Int = 0 + + override fun findCommunityReplies( + commentId: Long, + viewerId: Long, + offset: Long, + limit: Int + ) = emptyList() }