diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt index 3a8f41cb..126c5695 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt @@ -12,6 +12,8 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreat import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.block.QBlockMember +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 org.springframework.stereotype.Repository @@ -119,6 +121,122 @@ class DefaultCreatorChannelCommunityQueryRepository( return rows.toCommunityPostRecords(creatorId, viewerId) } + override fun findCommunityPost( + postId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): CreatorChannelCommunityPostRecord? { + val rows = queryFactory + .selectCommunityPostRow() + .from(creatorCommunity) + .where(communityPostByIdCondition(postId, canViewAdultContent)) + .fetch() + + val creatorId = rows.firstOrNull()?.creatorId ?: return null + return rows.toCommunityPostRecords(creatorId, viewerId).firstOrNull() + } + + override fun countCommunityComments( + postId: Long, + viewerId: Long, + creatorId: Long + ): Int { + return queryFactory + .select(creatorCommunityComment.id.count()) + .from(creatorCommunityComment) + .where(rootCommentCondition(postId, creatorId, viewerId)) + .fetchOne() + ?.toInt() + ?: 0 + } + + override fun findCommunityComments( + postId: Long, + viewerId: Long, + creatorId: Long, + offset: Long, + limit: Int + ): List { + return queryFactory + .selectCommunityCommentRow() + .from(creatorCommunityComment) + .where(rootCommentCondition(postId, creatorId, viewerId)) + .orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc()) + .offset(offset) + .limit(limit.toLong()) + .fetch() + .map { it.toCommunityCommentRecord() } + } + + override fun findLatestRepliesByCommentIds( + commentIds: List, + viewerId: Long + ): List { + if (commentIds.isEmpty()) return emptyList() + + return queryFactory + .selectCommunityCommentRow() + .from(creatorCommunityComment) + .where(replyCondition(commentIds, viewerId)) + .orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc()) + .fetch() + .map { it.toCommunityCommentRecord() } + .groupBy { it.parentCommentId } + .values + .map { it.first() } + } + + override fun findRootCommentContext( + commentId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): CreatorChannelCommunityCommentContextRecord? { + val row = queryFactory + .select( + creatorCommunityComment.id, + creatorCommunityComment.creatorCommunity.id, + creatorCommunityComment.creatorCommunity.member.id, + creatorCommunityComment.creatorCommunity.isCommentAvailable + ) + .from(creatorCommunityComment) + .where(rootCommentContextCondition(commentId, viewerId, canViewAdultContent)) + .fetchFirst() ?: return null + + return CreatorChannelCommunityCommentContextRecord( + commentId = row.get(creatorCommunityComment.id)!!, + postId = row.get(creatorCommunityComment.creatorCommunity.id)!!, + creatorId = row.get(creatorCommunityComment.creatorCommunity.member.id)!!, + isCommentAvailable = row.get(creatorCommunityComment.creatorCommunity.isCommentAvailable)!! + ) + } + + override fun countCommunityReplies(commentId: Long, viewerId: Long): Int { + return queryFactory + .select(creatorCommunityComment.id.count()) + .from(creatorCommunityComment) + .where(replyCondition(commentId, viewerId)) + .fetchOne() + ?.toInt() + ?: 0 + } + + override fun findCommunityReplies( + commentId: Long, + viewerId: Long, + offset: Long, + limit: Int + ): List { + return queryFactory + .selectCommunityCommentRow() + .from(creatorCommunityComment) + .where(replyCondition(commentId, viewerId)) + .orderBy(creatorCommunityComment.createdAt.desc(), creatorCommunityComment.id.desc()) + .offset(offset) + .limit(limit.toLong()) + .fetch() + .map { it.toCommunityCommentRecord() } + } + private fun JPAQueryFactory.selectCommunityPostRow() = select( creatorCommunity.id, creatorCommunity.member.id, @@ -134,6 +252,17 @@ class DefaultCreatorChannelCommunityQueryRepository( creatorCommunity.isCommentAvailable ) + private fun JPAQueryFactory.selectCommunityCommentRow() = select( + creatorCommunityComment.id, + creatorCommunityComment.parent.id, + creatorCommunityComment.creatorCommunity.id, + creatorCommunityComment.member.id, + creatorCommunityComment.member.profileImage, + creatorCommunityComment.member.nickname, + creatorCommunityComment.comment, + creatorCommunityComment.createdAt + ) + private fun communityPostCondition(creatorId: Long, canViewAdultContent: Boolean): BooleanExpression { val condition = creatorCommunity.isActive.isTrue .and(creatorCommunity.member.id.eq(creatorId)) @@ -142,6 +271,62 @@ class DefaultCreatorChannelCommunityQueryRepository( return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse) } + private fun communityPostByIdCondition(postId: Long, canViewAdultContent: Boolean): BooleanExpression { + val condition = creatorCommunity.id.eq(postId) + .and(creatorCommunity.isActive.isTrue) + .and(creatorCommunity.member.isActive.isTrue) + + return if (canViewAdultContent) condition else condition.and(creatorCommunity.isAdult.isFalse) + } + + private fun rootCommentCondition( + postId: Long, + creatorId: Long, + viewerId: Long + ): BooleanExpression { + return creatorCommunityComment.creatorCommunity.id.eq(postId) + .and(creatorCommunityComment.isActive.isTrue) + .and(creatorCommunityComment.parent.isNull) + .and(visibleSecretCommentCondition(creatorId, viewerId)) + .and(notBlockedCommentWriterCondition(viewerId)) + } + + private fun rootCommentContextCondition( + commentId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): BooleanExpression { + val condition = creatorCommunityComment.id.eq(commentId) + .and(creatorCommunityComment.isActive.isTrue) + .and(creatorCommunityComment.parent.isNull) + .and(creatorCommunityComment.creatorCommunity.isActive.isTrue) + .and(creatorCommunityComment.creatorCommunity.member.isActive.isTrue) + .and( + creatorCommunityComment.isSecret.isFalse + .or(creatorCommunityComment.creatorCommunity.member.id.eq(viewerId)) + .or(creatorCommunityComment.member.id.eq(viewerId)) + ) + .and(notBlockedCommentWriterCondition(viewerId)) + + return if (canViewAdultContent) condition else condition.and(creatorCommunityComment.creatorCommunity.isAdult.isFalse) + } + + private fun replyCondition(commentId: Long, viewerId: Long): BooleanExpression { + return creatorCommunityComment.isActive.isTrue + .and(creatorCommunityComment.parent.isNotNull) + .and(creatorCommunityComment.parent.id.eq(commentId)) + .and(visibleSecretReplyCondition(viewerId)) + .and(notBlockedCommentWriterCondition(viewerId)) + } + + private fun replyCondition(commentIds: List, viewerId: Long): BooleanExpression { + return creatorCommunityComment.isActive.isTrue + .and(creatorCommunityComment.parent.isNotNull) + .and(creatorCommunityComment.parent.id.`in`(commentIds)) + .and(visibleSecretReplyCondition(viewerId)) + .and(notBlockedCommentWriterCondition(viewerId)) + } + private fun homeCommunityPostCondition( creatorId: Long, viewerId: Long, @@ -302,6 +487,12 @@ class DefaultCreatorChannelCommunityQueryRepository( .or(creatorCommunityComment.member.id.eq(viewerId)) } + private fun visibleSecretReplyCondition(viewerId: Long): BooleanExpression { + return creatorCommunityComment.isSecret.isFalse + .or(creatorCommunityComment.creatorCommunity.member.id.eq(viewerId)) + .or(creatorCommunityComment.member.id.eq(viewerId)) + } + private fun notBlockedCommentWriterCondition(viewerId: Long): BooleanExpression { val viewerBlock = QBlockMember("communityCommentViewerBlockWriter") val writerBlock = QBlockMember("communityCommentWriterBlockViewer") @@ -355,4 +546,17 @@ class DefaultCreatorChannelCommunityQueryRepository( private val Tuple.isCommentAvailable: Boolean get() = get(creatorCommunity.isCommentAvailable)!! + + private fun Tuple.toCommunityCommentRecord(): CreatorChannelCommunityCommentRecord { + return CreatorChannelCommunityCommentRecord( + commentId = get(creatorCommunityComment.id)!!, + parentCommentId = get(creatorCommunityComment.parent.id), + postId = get(creatorCommunityComment.creatorCommunity.id)!!, + writerId = get(creatorCommunityComment.member.id)!!, + writerProfilePath = get(creatorCommunityComment.member.profileImage), + writerNickname = get(creatorCommunityComment.member.nickname)!!, + content = get(creatorCommunityComment.comment)!!, + createdAt = get(creatorCommunityComment.createdAt)!! + ) + } } 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 index 6517a1ce..16536dba 100644 --- 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 @@ -29,6 +29,49 @@ interface CreatorChannelCommunityQueryPort { canViewAdultContent: Boolean, limit: Int ): List + + fun findCommunityPost( + postId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): CreatorChannelCommunityPostRecord? + + fun countCommunityComments( + postId: Long, + viewerId: Long, + creatorId: Long + ): Int + + fun findCommunityComments( + postId: Long, + viewerId: Long, + creatorId: Long, + offset: Long, + limit: Int + ): List + + fun findLatestRepliesByCommentIds( + commentIds: List, + viewerId: Long + ): List + + fun findRootCommentContext( + commentId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): CreatorChannelCommunityCommentContextRecord? + + fun countCommunityReplies( + commentId: Long, + viewerId: Long + ): Int + + fun findCommunityReplies( + commentId: Long, + viewerId: Long, + offset: Long, + limit: Int + ): List } data class CreatorChannelCommunityCreatorRecord( @@ -54,3 +97,21 @@ data class CreatorChannelCommunityPostRecord( val isPinned: Boolean, val isLiked: Boolean ) + +data class CreatorChannelCommunityCommentRecord( + val commentId: Long, + val parentCommentId: Long?, + val postId: Long, + val writerId: Long, + val writerProfilePath: String?, + val writerNickname: String, + val content: String, + val createdAt: LocalDateTime +) + +data class CreatorChannelCommunityCommentContextRecord( + val commentId: Long, + val postId: Long, + val creatorId: Long, + val isCommentAvailable: Boolean +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt index bd307d8b..33ab5835 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt @@ -353,6 +353,111 @@ class DefaultCreatorChannelCommunityQueryRepositoryTest @Autowired constructor( assertFalse(adultInactivePurchasedPost.id in adultBlockedPosts.map { it.postId }) } + @Test + @DisplayName("게시물 ID 상세 조회는 좋아요와 구매와 성인 정책 필드를 채운다") + fun shouldFindCommunityPostByIdWithLikePurchaseAndAdultPolicy() { + val viewer = saveMember("detail-viewer", MemberRole.USER) + val creator = saveMember("detail-creator", MemberRole.CREATOR) + val liker = saveMember("detail-liker", MemberRole.USER) + val paidPost = saveCommunity(creator, isFixed = true, price = 100, isAdult = false) + val adultPost = saveCommunity(creator, isFixed = false, price = 0, isAdult = true) + val inactivePost = saveCommunity(creator, isFixed = false, price = 0, isActive = false) + saveCommunityOrder(viewer, paidPost, CanUsage.PAID_COMMUNITY_POST, isRefund = false) + saveCommunityLike(viewer, paidPost, isActive = true) + saveCommunityLike(liker, paidPost, isActive = true) + saveCommunityComment(viewer, paidPost, isActive = true) + flushAndClear() + + val record = repository.findCommunityPost(paidPost.id!!, viewer.id!!, canViewAdultContent = true) + val blockedAdultRecord = repository.findCommunityPost(adultPost.id!!, viewer.id!!, canViewAdultContent = false) + val inactiveRecord = repository.findCommunityPost(inactivePost.id!!, viewer.id!!, canViewAdultContent = true) + + assertNotNull(record) + assertEquals(paidPost.id, record!!.postId) + assertTrue(record.existOrdered) + assertTrue(record.isLiked) + assertEquals(2, record.likeCount) + assertEquals(1, record.commentCount) + assertTrue(record.isPinned) + assertNull(blockedAdultRecord) + assertNull(inactiveRecord) + } + + @Test + @DisplayName("최상위 댓글 목록은 비밀 댓글과 차단 작성자를 필터링하고 최신 답글을 반환한다") + fun shouldFindVisibleRootCommentsWithLatestReply() { + val creator = saveMember("visible-comment-creator", MemberRole.CREATOR) + val viewer = saveMember("visible-comment-viewer", MemberRole.USER) + val writer = saveMember("visible-comment-writer", MemberRole.USER) + val secretWriter = saveMember("visible-secret-writer", MemberRole.USER) + val blockedWriter = saveMember("visible-blocked-writer", MemberRole.USER) + val replyWriter = saveMember("visible-reply-writer", MemberRole.USER) + val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = true) + val oldComment = saveCommunityComment(writer, post, isActive = true) + val newComment = saveCommunityComment(viewer, post, isActive = true) + saveCommunityComment(secretWriter, post, isActive = true, isSecret = true) + saveCommunityComment(blockedWriter, post, isActive = true) + saveCommunityComment(writer, post, isActive = false) + val oldReply = saveCommunityComment(replyWriter, post, isActive = true, parent = newComment) + val latestReply = saveCommunityComment(replyWriter, post, isActive = true, parent = newComment) + val hiddenSecretReply = saveCommunityComment(secretWriter, post, isActive = true, isSecret = true, parent = newComment) + saveBlock(viewer, blockedWriter, isActive = true) + flushAndClear() + updateCreatedAt("CreatorCommunityComment", oldComment.id!!, LocalDateTime.of(2026, 7, 6, 10, 0)) + updateCreatedAt("CreatorCommunityComment", newComment.id!!, LocalDateTime.of(2026, 7, 6, 11, 0)) + updateCreatedAt("CreatorCommunityComment", oldReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 1)) + updateCreatedAt("CreatorCommunityComment", latestReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 2)) + updateCreatedAt("CreatorCommunityComment", hiddenSecretReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 3)) + flushAndClear() + + val viewerCount = repository.countCommunityComments(post.id!!, viewer.id!!, creator.id!!) + val creatorCount = repository.countCommunityComments(post.id!!, creator.id!!, creator.id!!) + val comments = repository.findCommunityComments(post.id!!, viewer.id!!, creator.id!!, offset = 0, limit = 10) + val latestReplies = repository.findLatestRepliesByCommentIds(comments.map { it.commentId }, viewer.id!!) + + assertEquals(2, viewerCount) + assertEquals(4, creatorCount) + assertEquals(listOf(newComment.id, oldComment.id), comments.map { it.commentId }) + assertEquals(listOf(latestReply.id), latestReplies.map { it.commentId }) + assertEquals(newComment.id, latestReplies.single().parentCommentId) + } + + @Test + @DisplayName("답글 조회는 보이는 최상위 댓글 context와 활성 답글만 반환한다") + fun shouldFindVisibleRepliesForRootCommentOnly() { + val creator = saveMember("reply-context-creator", MemberRole.CREATOR) + val viewer = saveMember("reply-context-viewer", MemberRole.USER) + val writer = saveMember("reply-context-writer", MemberRole.USER) + val blockedWriter = saveMember("reply-context-blocked", MemberRole.USER) + val secretWriter = saveMember("reply-context-secret", MemberRole.USER) + val post = saveCommunity(creator, isFixed = false, price = 0, isCommentAvailable = true) + val root = saveCommunityComment(writer, post, isActive = true) + val secretRoot = saveCommunityComment(secretWriter, post, isActive = true, isSecret = true) + val oldReply = saveCommunityComment(writer, post, isActive = true, parent = root) + val newReply = saveCommunityComment(writer, post, isActive = true, parent = root) + saveCommunityComment(secretWriter, post, isActive = true, isSecret = true, parent = root) + saveCommunityComment(writer, post, isActive = false, parent = root) + saveCommunityComment(blockedWriter, post, isActive = true, parent = root) + saveBlock(viewer, blockedWriter, isActive = true) + flushAndClear() + updateCreatedAt("CreatorCommunityComment", oldReply.id!!, LocalDateTime.of(2026, 7, 6, 10, 0)) + updateCreatedAt("CreatorCommunityComment", newReply.id!!, LocalDateTime.of(2026, 7, 6, 11, 0)) + flushAndClear() + + val context = repository.findRootCommentContext(root.id!!, viewer.id!!, canViewAdultContent = true) + val invisibleSecretContext = repository.findRootCommentContext(secretRoot.id!!, viewer.id!!, canViewAdultContent = true) + val replyContext = repository.findRootCommentContext(newReply.id!!, viewer.id!!, canViewAdultContent = true) + val count = repository.countCommunityReplies(root.id!!, viewer.id!!) + val replies = repository.findCommunityReplies(root.id!!, viewer.id!!, offset = 0, limit = 10) + + assertNotNull(context) + assertEquals(post.id, context!!.postId) + assertNull(invisibleSecretContext) + assertNull(replyContext) + assertEquals(2, count) + assertEquals(listOf(newReply.id, oldReply.id), replies.map { it.commentId }) + } + private fun saveMember( nickname: String, role: MemberRole,