From 19ada9e57454bc38e9f33982cb0fe4fc4b44470e Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:55:19 +0900 Subject: [PATCH 01/17] =?UTF-8?q?docs(community):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EC=83=81=EC=84=B8=20API=20=EA=B3=84=ED=9A=8D?= =?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 --- .../plan-task.md | 856 ++++++++++++++++++ docs/20260706_커뮤니티_게시물_상세_API/prd.md | 301 ++++++ 2 files changed, 1157 insertions(+) create mode 100644 docs/20260706_커뮤니티_게시물_상세_API/plan-task.md create mode 100644 docs/20260706_커뮤니티_게시물_상세_API/prd.md diff --git a/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md b/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md new file mode 100644 index 00000000..2e4386d3 --- /dev/null +++ b/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md @@ -0,0 +1,856 @@ +# 커뮤니티 게시물 상세 API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. + +**Goal:** 인증 회원이 커뮤니티 게시물 상세, 댓글 목록, 댓글 답글 목록을 v2 API로 조회할 수 있게 한다. + +**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 조립 계층에 둔다. 게시물 상세/댓글/답글 조회 service, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 커뮤니티 탭의 `CreatorChannelCommunityPost` 조립 정책을 재사용하고, 댓글 저장 엔티티는 legacy `CreatorCommunityComment`를 사용하되 legacy response DTO는 v2 공개 응답으로 노출하지 않는다. + +**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper + +--- + +## 0. 구현 전 확정 사항 + +- 상세 API endpoint: `GET /api/v2/creator-channels/community-posts/{postId}` +- 댓글 API endpoint: `GET /api/v2/creator-channels/community-posts/{postId}/comments` +- 답글 API endpoint: `GET /api/v2/creator-channels/community-comments/{commentId}/replies` +- 인증 정책: 세 API 모두 인증 회원만 조회 가능하다. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다. +- page 기준: 기존 크리에이터 채널 v2 탭 API와 동일한 0 기반 page index다. +- page fallback: + - `page` 기본값은 `0` + - `page < 0`이면 `0` + - `size` 기본값은 `20` + - `size < 20`이면 `20` + - `size > 50`이면 `50` +- 상세 응답 게시물 필드는 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 의미를 사용하고 `isLiked`를 포함한다. +- 상세 응답은 `comments: CreatorChannelCommunityCommentsResponse`로 첫 댓글 20개를 포함한다. +- 댓글 응답은 `commentCount`, `comments`, `page`, `size`, `hasNext`를 포함한다. +- 댓글 item은 `commentId`, `writerProfileImageUrl`, `writerNickname`, `content`, `createdAtUtc`, `latestReply`를 포함한다. +- 답글 응답은 `replyCount`, `replies`, `page`, `size`, `hasNext`를 포함한다. +- 답글 item은 댓글 item과 같은 작성자/본문/UTC 작성 시간 구조를 사용하지만 `latestReply`를 포함하지 않는다. +- 게시물 조회 정책: + - `CreatorCommunity.isActive == true` + - 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시물은 조회 불가 + - 조회자와 게시물 작성자 사이 활성 차단 관계가 있으면 접근 차단 오류 + - 유료 본문/이미지/오디오 접근 정책은 커뮤니티 탭 `CreatorChannelCommunityPost`와 동일 +- 댓글 조회 정책: + - 최상위 댓글: `CreatorCommunityComment.isActive == true`, `parent is null` + - 답글: `CreatorCommunityComment.isActive == true`, `parent.id == commentId` + - 댓글/답글 작성자가 조회자와 차단/피차단 관계이면 제외 + - 비밀 최상위 댓글은 게시물 작성자 또는 댓글 작성자 본인에게만 노출 + - 댓글 정렬: `createdAt desc`, `id desc` + - latest reply 정렬: `createdAt desc`, `id desc`의 첫 번째 + - 답글 목록 정렬: `createdAt desc`, `id desc` +- `isCommentAvailable == false`인 게시물은 상세 초기 댓글과 댓글 목록 API 모두 `commentCount=0`, 빈 목록, `hasNext=false`를 반환한다. +- `createdAtUtc`는 `kr.co.vividnext.sodalive.extensions.toUtcIso`를 사용한다. +- 프로필 이미지 URL은 `String?.toCdnUrl(cloudFrontHost)`와 기본 프로필 이미지 URL `"$cloudFrontHost/profile/default-profile.png"` 정책을 따른다. +- 탈퇴 회원 닉네임 prefix 제거는 `removeDeletedNicknamePrefix()`를 적용한다. +- legacy `/creator-community` endpoint와 응답 스키마는 변경하지 않는다. +- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다. + +--- + +## 1. 파일 구조 계획 + +### API 조립 계층 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt` + - 상세/댓글/답글 GET mapping 추가 + - 인증 회원 null guard는 기존 `requireMember` 재사용 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` + - `getCommunityPostDetail`, `getCommunityComments`, `getCommunityReplies` 추가 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt` + - 상세/댓글/답글 response DTO와 domain 변환 책임 +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` + +### 도메인 조회 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt` + - 상세/댓글/답글 domain model +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` + - 기존 page fallback을 댓글/답글에도 재사용하도록 이름 또는 주석 확인 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt` + - 게시물 상세, 댓글, 답글 조회 port method와 record 추가 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` + - 상세/댓글/답글 use case 추가 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt` + - port method 구현 계약 반영 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt` + - QueryDSL 상세/댓글/답글 조회 구현 +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` + +### 기존 파일 확인/재사용 +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/CdnUrlExtensions.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityComment.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/block/BlockMember.kt` + +### 문서 산출물 +- Create: `docs/20260706_커뮤니티_게시물_상세_API/plan-task.md` +- Verify: `docs/20260706_커뮤니티_게시물_상세_API/prd.md` + +--- + +## 2. Response data class 초안 + +구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt`를 아래 구조로 만든다. 공개 API 계약 변경이 필요하면 구현 전에 PRD와 이 문서를 먼저 갱신한다. + +```kotlin +package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.extensions.toUtcIso +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.CreatorChannelCommunityPostDetail +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply + +data class CreatorChannelCommunityPostDetailResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val existOrdered: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean, + @JsonProperty("isLiked") + val isLiked: Boolean, + val comments: CreatorChannelCommunityCommentsResponse +) { + companion object { + fun from(detail: CreatorChannelCommunityPostDetail): CreatorChannelCommunityPostDetailResponse { + val post = detail.post + return CreatorChannelCommunityPostDetailResponse( + postId = post.postId, + creatorId = post.creatorId, + creatorNickname = post.creatorNickname, + creatorProfileUrl = post.creatorProfileUrl, + createdAtUtc = post.createdAt.toUtcIso(), + content = post.content, + imageUrl = post.imageUrl, + audioUrl = post.audioUrl, + price = post.price, + isCommentAvailable = post.isCommentAvailable, + existOrdered = post.existOrdered, + likeCount = post.likeCount, + commentCount = post.commentCount, + isPinned = post.isPinned, + isLiked = post.isLiked, + comments = CreatorChannelCommunityCommentsResponse.from(detail.comments) + ) + } + } +} + +data class CreatorChannelCommunityCommentsResponse( + val commentCount: Int, + val comments: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(comments: CreatorChannelCommunityComments): CreatorChannelCommunityCommentsResponse { + return CreatorChannelCommunityCommentsResponse( + commentCount = comments.commentCount, + comments = comments.comments.map(CreatorChannelCommunityCommentResponse::from), + page = comments.page.page, + size = comments.page.size, + hasNext = comments.hasNext + ) + } + } +} + +data class CreatorChannelCommunityCommentResponse( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String, + val latestReply: CreatorChannelCommunityReplyResponse? +) { + companion object { + fun from(comment: CreatorChannelCommunityComment): CreatorChannelCommunityCommentResponse { + return CreatorChannelCommunityCommentResponse( + commentId = comment.commentId, + writerProfileImageUrl = comment.writerProfileImageUrl, + writerNickname = comment.writerNickname, + content = comment.content, + createdAtUtc = comment.createdAt.toUtcIso(), + latestReply = comment.latestReply?.let(CreatorChannelCommunityReplyResponse::from) + ) + } + } +} + +data class CreatorChannelCommunityRepliesResponse( + val replyCount: Int, + val replies: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(replies: CreatorChannelCommunityReplies): CreatorChannelCommunityRepliesResponse { + return CreatorChannelCommunityRepliesResponse( + replyCount = replies.replyCount, + replies = replies.replies.map(CreatorChannelCommunityReplyResponse::from), + page = replies.page.page, + size = replies.page.size, + hasNext = replies.hasNext + ) + } + } +} + +data class CreatorChannelCommunityReplyResponse( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String +) { + companion object { + fun from(reply: CreatorChannelCommunityReply): CreatorChannelCommunityReplyResponse { + return CreatorChannelCommunityReplyResponse( + commentId = reply.commentId, + writerProfileImageUrl = reply.writerProfileImageUrl, + writerNickname = reply.writerNickname, + content = reply.content, + createdAtUtc = reply.createdAt.toUtcIso() + ) + } + } +} +``` + +--- + +## 3. Domain / Port 초안 + +구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt`를 아래 구조로 만든다. + +```kotlin +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 CreatorChannelCommunityPostDetail( + val post: CreatorChannelCommunityPost, + val comments: CreatorChannelCommunityComments +) + +data class CreatorChannelCommunityComments( + val commentCount: Int, + val comments: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelCommunityComment( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAt: LocalDateTime, + val latestReply: CreatorChannelCommunityReply? +) + +data class CreatorChannelCommunityReplies( + val replyCount: Int, + val replies: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelCommunityReply( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAt: LocalDateTime +) +``` + +구현 시 `CreatorChannelCommunityQueryPort`에 아래 method와 record를 추가한다. + +```kotlin +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 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 +) +``` + +--- + +## 4. Phase / Task Breakdown + +### Phase 1: DTO, domain, page 정책 기반 추가 + +- [x] **Task 1.1: 상세/댓글/답글 response DTO 추가** - 검증: `CreatorChannelCommunityPostDetailResponse.kt`와 DTO 직렬화 테스트를 추가했고 boolean is-prefix, comments/latestReply/replies 구조를 확인했다. + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt` + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `CreatorChannelCommunityPostDetailResponseTest.shouldSerializeBooleanFieldsWithIsPrefixAndReuseCommentsResponse`를 추가한다. + - 검증 항목: + - `isCommentAvailable`, `isPinned`, `isLiked`, `hasNext` JSON 필드명이 유지된다. + - 상세 응답의 `comments`가 `CreatorChannelCommunityCommentsResponse` 구조로 직렬화된다. + - 댓글 item은 `latestReply`를 포함하고, 답글 item은 `latestReply`를 포함하지 않는다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponseTest` + - 기대 결과: + - 신규 DTO가 없어서 `compileTestKotlin` 실패한다. + - GREEN: 최소 구현/통과 확인 + - 위 response data class 초안 그대로 DTO 파일을 추가한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponseTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 기존 `CreatorChannelCommunityPostResponse`를 중복 수정하지 않는다. + - `rg -n "JsonProperty\\(\"isLiked\"\\)|JsonProperty\\(\"hasNext\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community`로 Boolean 필드명을 확인한다. + +- [x] **Task 1.2: 상세/댓글/답글 domain model 추가** - 검증: 상세/댓글/답글 domain model을 추가하고 기존 `CreatorChannelCommunityQueryPolicy.createPage` fallback 재사용 테스트를 통과시켰다. + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `CreatorChannelCommunityQueryPolicyTest.shouldUseSamePageFallbackForCommunityComments`를 추가한다. + - 검증 항목: + - `createPage(null, null)`은 `page=0`, `size=20`, `fetchLimit=21` + - `createPage(-1, 100)`은 `page=0`, `size=50`, `fetchLimit=51` + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest` + - 기대 결과: + - domain model import 또는 신규 테스트 의도에 맞춰 컴파일/테스트 실패를 확인한다. + - GREEN: 최소 구현/통과 확인 + - 위 domain data class 초안을 추가한다. + - 기존 `CreatorChannelCommunityQueryPolicy.createPage`를 그대로 사용하고, 동작 변경은 하지 않는다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - `CreatorChannelPage`를 새로 만들지 않고 기존 `v2.creator.channel.live.domain.CreatorChannelPage`를 재사용한다. + +### Phase 2: QueryDSL repository 상세/댓글/답글 조회 + +- [x] **Task 2.1: port method와 record 계약 추가** - 검증: 상세/댓글/답글 port method와 record를 추가했고 service/home 테스트 fake 구현을 갱신해 컴파일 및 service 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `FakeCreatorChannelCommunityQueryPort`가 신규 port method를 구현하지 않아 컴파일 실패하도록 service test에 상세/댓글/답글 테스트 skeleton을 추가한다. + - 추가할 테스트 이름: + - `shouldAssembleCommunityPostDetailWithInitialComments` + - `shouldReturnEmptyCommentsWhenCommentUnavailable` + - `shouldAssembleCommunityReplies` + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - 기대 결과: + - `CreatorChannelCommunityQueryPort` 신규 method 미정의 또는 fake 미구현으로 `compileTestKotlin` 실패한다. + - GREEN: 최소 구현/통과 확인 + - `CreatorChannelCommunityQueryPort`에 3장 port 초안의 method와 record를 추가한다. + - `FakeCreatorChannelCommunityQueryPort`에는 테스트용 기본값을 반환하는 구현을 추가한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - 기대 결과: + - 아직 service method가 없어서 의도한 RED 실패가 남는다. + - REFACTOR: 정리/회귀 확인 + - port record는 API DTO를 import하지 않는다. + - 실행 명령: + - `rg -n "v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` + - 기대 결과: + - 검색 결과 0건 + +- [x] **Task 2.2: 게시물 ID 기반 상세 row 조회** - 검증: `findCommunityPost`를 `selectCommunityPostRow`/`toCommunityPostRecords` 재사용으로 구현하고 좋아요/구매/성인/비활성 정책 repository 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindCommunityPostByIdWithLikePurchaseAndAdultPolicy`를 추가한다. + - fixture: + - creator, viewer, adult-disabled viewer + - 무료 게시물 1개 + - 유료 게시물 1개와 유효 구매 내역 + - 19금 게시물 1개 + - viewer 활성 좋아요와 다른 회원 좋아요 + - 검증 항목: + - `findCommunityPost(postId, viewerId, true)`는 `CreatorChannelCommunityPostRecord` 1건을 반환한다. + - `existOrdered`, `isLiked`, `likeCount`, `commentCount`, `isPinned`가 기존 목록과 같은 의미로 채워진다. + - `canViewAdultContent=false`이면 19금 게시물은 `null`이다. + - 비활성 게시물은 `null`이다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest` + - 기대 결과: + - `findCommunityPost` 미구현으로 컴파일 실패한다. + - GREEN: 최소 구현/통과 확인 + - `DefaultCreatorChannelCommunityQueryRepository.findCommunityPost(...)`를 추가한다. + - 기존 `selectCommunityPostRow`, `communityPostCondition`, `toCommunityPostRecords`를 재사용한다. + - 단일 게시물 조회 조건은 `creatorCommunity.id.eq(postId)`, `isActive=true`, `member.isActive=true`, 성인 필터를 적용한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 목록 조회와 상세 조회의 부가 필드 계산이 중복되지 않도록 `toCommunityPostRecords`를 재사용한다. + - 단일 조회 결과가 없으면 빈 리스트 변환 후 `firstOrNull()`로 처리해 NPE를 피한다. + +- [x] **Task 2.3: 댓글 목록 count/list와 최신 답글 bulk 조회** - 검증: 댓글 count/list/latest reply bulk 조회를 구현하고 비밀 댓글, 차단 작성자, 최신 답글 정렬 repository 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindVisibleRootCommentsWithLatestReply`를 추가한다. + - fixture: + - 공개 최상위 댓글 2개 + - 각 댓글의 답글 여러 개 + - 비밀 댓글 1개 + - 비활성 댓글 1개 + - 차단 작성자 댓글 1개 + - 최상위 댓글이 아닌 답글 1개 + - 검증 항목: + - viewer 기준 `countCommunityComments`는 공개/본인 비밀/차단 제외 정책을 반영한다. + - creator 기준 `countCommunityComments`는 비밀 댓글도 포함한다. + - `findCommunityComments`는 `createdAt desc`, `id desc` 순서로 `limit`개를 반환한다. + - `findLatestRepliesByCommentIds`는 parent comment별 최신 답글 1개만 반환한다. + - 답글 작성자가 차단 관계이면 latest reply 후보에서 제외된다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest` + - 기대 결과: + - 댓글 port method 미구현으로 컴파일 실패한다. + - GREEN: 최소 구현/통과 확인 + - `countCommunityComments`, `findCommunityComments`, `findLatestRepliesByCommentIds`를 구현한다. + - 기존 helper `visibleSecretCommentCondition(creatorId, viewerId)`와 `notBlockedCommentWriterCondition(viewerId)`를 재사용한다. + - `findLatestRepliesByCommentIds`는 parent id 목록이 비어 있으면 빈 목록을 반환한다. + - latest reply는 QueryDSL로 후보를 조회한 뒤 Kotlin group by로 parent별 첫 항목을 선택하거나 동등한 방식으로 구현한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - count 조건과 list 조건이 다르지 않은지 테스트 fixture를 재확인한다. + - `fetch().size` 대신 count query를 우선 사용한다. + +- [x] **Task 2.4: 답글 조회용 부모 댓글 context, count/list 조회** - 검증: root comment context와 답글 count/list 조회를 구현하고 보이지 않는 비밀 부모, 답글의 답글 제외, 차단/비활성 제외 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `DefaultCreatorChannelCommunityQueryRepositoryTest.shouldFindVisibleRepliesForRootCommentOnly`를 추가한다. + - fixture: + - 최상위 댓글 1개 + - 답글 3개 + - 비활성 답글 1개 + - 차단 작성자 답글 1개 + - 답글의 답글 형태 비정상 데이터 1개 + - 비밀 최상위 댓글 1개 + - 검증 항목: + - `findRootCommentContext`는 최상위 댓글만 반환한다. + - 조회자에게 보이지 않는 비밀 부모 댓글은 `null`이다. + - `countCommunityReplies`와 `findCommunityReplies`는 활성 답글과 차단 제외 정책을 반영한다. + - 답글 정렬은 `createdAt desc`, `id desc`다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest` + - 기대 결과: + - 답글 port method 미구현으로 컴파일 실패한다. + - GREEN: 최소 구현/통과 확인 + - `findRootCommentContext`, `countCommunityReplies`, `findCommunityReplies`를 구현한다. + - 부모 댓글 context 조회는 부모 댓글의 게시물 활성/성인 필터/비밀 댓글/차단 작성자 정책을 함께 적용한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 답글 목록 item에 `latestReply`용 데이터를 넣지 않는다. + - `parent.isNotNull`과 `parent.id.eq(commentId)` 조건을 모두 사용한다. + +### Phase 3: Query service 상세/댓글/답글 조립 + +- [x] **Task 3.1: 상세 조회 service 조립** - 검증: 상세 service가 기존 게시글 domain 변환, 초기 댓글 20개, 최신 답글을 조립하도록 구현하고 service 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `CreatorChannelCommunityQueryServiceTest.shouldAssembleCommunityPostDetailWithInitialComments`를 완성한다. + - fake port fixture: + - `findCommunityPost`는 `isCommentAvailable=true`, `isLiked=true` 게시물 반환 + - `findCommunityComments`는 21개 댓글 반환 + - `findLatestRepliesByCommentIds`는 첫 댓글 최신 답글 반환 + - 검증 항목: + - 상세 게시물의 이미지/오디오/본문 접근 정책이 기존 탭과 같다. + - `comments.page=0`, `comments.size=20`, `comments.hasNext=true` + - `comments.comments.size=20` + - 첫 댓글의 `latestReply`가 조립된다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - 기대 결과: + - `getCommunityPostDetail` 미구현으로 컴파일 실패한다. + - GREEN: 최소 구현/통과 확인 + - `CreatorChannelCommunityQueryService.getCommunityPostDetail(postId, viewer, now)`를 추가한다. + - `findCommunityPost`가 null이면 `SodaException(messageKey = "creator.community.invalid_request_retry")`를 던진다. + - 게시물 작성자와 조회자 사이 차단 관계는 `existsBlockedBetween(viewerId, post.creatorId)`로 확인한다. + - 댓글은 내부 helper `getCommunityComments(postId, viewer, page = 0, size = 20, now)`를 호출해 조립한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 기존 `CreatorChannelCommunityPostRecord.toDomain(viewerId)`를 상세에도 재사용한다. + - `now` parameter는 기존 facade 패턴과 맞춰 테스트 주입 가능하게 유지한다. + +- [x] **Task 3.2: 댓글 목록 service 조립** - 검증: 댓글 service가 page fallback, 댓글 불가 빈 응답, CDN/default profile, 탈퇴 닉네임 prefix 제거, latest reply 조립을 수행하도록 구현하고 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `CreatorChannelCommunityQueryServiceTest.shouldReturnEmptyCommentsWhenCommentUnavailable`를 추가한다. + - `CreatorChannelCommunityQueryServiceTest.shouldApplyCommentPageFallbackAndLatestReply`를 추가한다. + - 검증 항목: + - `isCommentAvailable=false`이면 comment count/list/latest reply port를 호출하지 않고 빈 응답을 반환한다. + - `page=-1`, `size=100` 요청은 `offset=0`, `limit=51`, 응답 `size=50`으로 조립된다. + - `findLatestRepliesByCommentIds`는 page 응답에 포함된 최대 `size`개 댓글 id만 대상으로 호출된다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - 기대 결과: + - `getCommunityComments` 미구현 또는 port 호출 누락으로 실패한다. + - GREEN: 최소 구현/통과 확인 + - `CreatorChannelCommunityQueryService.getCommunityComments(postId, viewer, page, size, now)`를 추가한다. + - 게시물 검증은 `findCommunityPost`를 사용한다. + - `CreatorChannelCommunityQueryPolicy.limitItems`와 `hasNext`를 재사용한다. + - 댓글 record는 CDN URL/default profile URL, 닉네임 prefix 제거 후 domain으로 변환한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 댓글 조립 helper와 답글 조립 helper를 분리해 중복을 줄인다. + - API DTO를 import하지 않는다. + +- [x] **Task 3.3: 답글 목록 service 조립** - 검증: 답글 service가 부모 context 검증, page fallback, reply DTO용 domain 조립을 수행하도록 구현하고 보이지 않는 부모 예외 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `CreatorChannelCommunityQueryServiceTest.shouldAssembleCommunityReplies`를 완성한다. + - `CreatorChannelCommunityQueryServiceTest.shouldThrowWhenRootCommentContextNotVisible`를 추가한다. + - 검증 항목: + - `findRootCommentContext`가 null이면 `SodaException(messageKey = "creator.community.invalid_request_retry")` + - page fallback과 `hasNext`가 댓글 목록과 동일하다. + - 응답 domain의 replies item에는 latest reply가 없다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - 기대 결과: + - `getCommunityReplies` 미구현으로 컴파일 실패한다. + - GREEN: 최소 구현/통과 확인 + - `CreatorChannelCommunityQueryService.getCommunityReplies(commentId, viewer, page, size, now)`를 추가한다. + - 부모 댓글 context 조회 후 게시물 작성자와 조회자 사이 차단 관계를 확인한다. + - `countCommunityReplies`, `findCommunityReplies`로 답글 응답을 조립한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 댓글/답글 record 변환에서 default profile URL과 닉네임 정리를 공통 private helper로 묶는다. + +### Phase 4: Facade와 Controller endpoint 연결 + +- [x] **Task 4.1: facade method 추가** - 검증: 상세/댓글/답글 facade method를 추가하고 raw page/size 전달 및 공개 response DTO 변환 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - 추가할 테스트 이름: + - `shouldReturnCommunityPostDetailResponse` + - `shouldReturnCommunityCommentsResponse` + - `shouldReturnCommunityRepliesResponse` + - 검증 항목: + - facade가 query service 결과를 response DTO로 변환한다. + - raw `page`, `size`를 service로 전달한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest` + - 기대 결과: + - 신규 facade method 미구현으로 컴파일 실패한다. + - GREEN: 최소 구현/통과 확인 + - `getCommunityPostDetail(postId, viewer, now)`, `getCommunityComments(postId, viewer, page, size, now)`, `getCommunityReplies(commentId, viewer, page, size, now)`를 추가한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - facade에는 비즈니스 조건문을 넣지 않는다. + +- [x] **Task 4.2: controller endpoint 추가** - 검증: 세 v2 GET mapping을 추가하고 인증 거부, 성공 JSON, raw page/size 전달 controller 테스트를 통과시켰다. + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - 추가할 테스트 이름: + - `shouldRejectAnonymousCommunityPostDetailRequest` + - `shouldReturnCommunityPostDetailForAuthenticatedMember` + - `shouldReturnCommunityCommentsForAuthenticatedMember` + - `shouldPassRawCommentPageAndSizeToFacade` + - `shouldReturnCommunityRepliesForAuthenticatedMember` + - `shouldPassRawReplyPageAndSizeToFacade` + - 검증 항목: + - 세 endpoint는 인증 회원만 통과한다. + - JSON 경로: + - `$.data.postId` + - `$.data.isLiked` + - `$.data.comments.commentCount` + - `$.data.comments.comments[0].latestReply.commentId` + - `$.data.hasNext` + - controller는 page/size 보정을 하지 않고 facade로 raw 값을 전달한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest` + - 기대 결과: + - 신규 mapping 미구현으로 404 또는 컴파일 실패를 확인한다. + - GREEN: 최소 구현/통과 확인 + - controller에 아래 mapping을 추가한다. + - `@GetMapping("/community-posts/{postId}")` + - `@GetMapping("/community-posts/{postId}/comments")` + - `@GetMapping("/community-comments/{commentId}/replies")` + - 기존 `requireMember`를 재사용한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 기존 `@GetMapping("/{creatorId}/community")`와 신규 literal path가 충돌하지 않는지 controller test에서 모두 통과 확인한다. + +### Phase 5: End-to-End와 회귀 검증 + +- [x] **Task 5.1: 상세 API E2E 테스트** - 검증: 상세 API E2E에서 `isLiked=true`, 초기 댓글 20개, `hasNext=true`, 최신 답글, UTC `Z` 문자열을 확인했다. + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - `CreatorChannelCommunityEndToEndTest.shouldReturnCommunityPostDetailWithInitialComments`를 추가한다. + - fixture: + - creator, viewer + - 커뮤니티 게시물 1개 + - 활성 좋아요 + - 댓글 21개 + - 첫 댓글 최신 답글 1개 + - 검증 항목: + - `GET /api/v2/creator-channels/community-posts/{postId}` 200 OK + - `data.isLiked=true` + - `data.comments.comments.length()==20` + - `data.comments.hasNext=true` + - `data.comments.comments[0].latestReply` 존재 + - `createdAtUtc`는 `Z`로 끝나는 UTC 문자열 + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest` + - 기대 결과: + - API 미구현 상태에서 404 또는 JSON assertion 실패를 확인한다. + - GREEN: 최소 구현/통과 확인 + - Phase 1~4 구현을 연결해 테스트를 통과시킨다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - E2E fixture helper가 기존 커뮤니티 탭 테스트 helper와 충돌하지 않도록 private helper로 유지한다. + +- [x] **Task 5.2: 댓글/답글 API E2E 테스트** - 검증: 댓글 API, 답글 API, 댓글 불가 빈 응답 E2E를 추가했고 답글 item에 `latestReply`가 없음을 확인했다. + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - 추가할 테스트 이름: + - `shouldReturnPagedCommunityCommentsWithLatestReply` + - `shouldReturnPagedCommunityRepliesWithoutLatestReply` + - `shouldReturnEmptyCommentsWhenCommentUnavailable` + - 검증 항목: + - 댓글 API는 `commentCount`, `comments`, `page`, `size`, `hasNext`를 반환한다. + - 답글 API는 `replyCount`, `replies`, `page`, `size`, `hasNext`를 반환한다. + - 답글 item에는 `latestReply` 필드가 없다. + - 댓글 불가 게시물은 빈 댓글 응답을 반환한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest` + - 기대 결과: + - API 미구현 상태에서 404 또는 JSON assertion 실패를 확인한다. + - GREEN: 최소 구현/통과 확인 + - 댓글/답글 controller, facade, service, repository 연결을 완료한다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 응답 필드명 `commentId`가 댓글과 답글 모두에서 동일하게 사용되는지 확인한다. + +- [x] **Task 5.3: 접근 정책 E2E 테스트** - 검증: 성인 콘텐츠 미허용 회원의 19금 상세 접근이 공통 오류 응답(`success=false`)으로 거부됨을 E2E로 확인했다. + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - 추가할 테스트 이름: + - `shouldRejectAdultCommunityPostWhenViewerCannotViewAdultContent` + - `shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked` + - `shouldFilterSecretAndBlockedComments` + - `shouldRejectRepliesWhenParentCommentIsNotVisible` + - 검증 항목: + - 19금 게시물 접근 불가 + - 게시물 작성자 차단 관계 접근 차단 + - 비밀 댓글은 게시물 작성자/댓글 작성자 본인에게만 노출 + - 차단 댓글 작성자와 답글 작성자는 count/list/latestReply에서 제외 + - 보이지 않는 부모 댓글의 답글 조회는 오류 + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest` + - 기대 결과: + - 접근 정책 미구현으로 assertion 실패를 확인한다. + - GREEN: 최소 구현/통과 확인 + - repository 조건과 service 예외 처리를 조정해 테스트를 통과시킨다. + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest` + - 기대 결과: + - `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - legacy `/creator-community` controller/service는 수정하지 않았는지 `git diff`로 확인한다. + +### Phase 6: 전체 회귀와 문서 갱신 + +- [x] **Task 6.1: 기존 커뮤니티 탭 회귀 테스트** - 검증: community controller/service/repository 포함 focused test 묶음과 domain 패키지 API 의존성 검색을 실행해 회귀가 없음을 확인했다. + - Files: + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` + - RED: 실패 테스트 작성/실패 확인 + - TDD 예외 사유: 이 task는 신규 테스트 작성이 아니라 기존 커뮤니티 탭 동작 회귀 검증이다. + - 대체 검증 방법: 기존 테스트 전체를 실행해 상세/댓글 추가가 탭 목록 동작을 깨지 않는지 확인한다. + - GREEN: 최소 구현/통과 확인 + - 실행 명령: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest` + - 기대 결과: + - 모두 `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - `rg -n "v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과가 0건인지 확인한다. + +- [x] **Task 6.2: formatting, compile, 전체 검증** - 검증: `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. + - Files: + - Verify: 전체 변경 파일 + - Modify: `docs/20260706_커뮤니티_게시물_상세_API/plan-task.md` + - RED: 실패 테스트 작성/실패 확인 + - TDD 예외 사유: 전체 검증 task는 구현 완료 후 회귀 확인 단계라 별도 실패 테스트를 만들지 않는다. + - 대체 검증 방법: 단일 테스트, compile, ktlint, 전체 테스트를 순서대로 실행한다. + - GREEN: 최소 구현/통과 확인 + - 실행 명령: + - `./gradlew compileKotlin` + - `./gradlew ktlintCheck` + - `./gradlew test` + - 기대 결과: + - 모두 `BUILD SUCCESSFUL` + - REFACTOR: 정리/회귀 확인 + - 실행 명령: + - `./gradlew tasks --all` + - 기대 결과: + - `BUILD SUCCESSFUL` + - 검증 결과를 이 문서 하단 "검증 기록"에 누적한다. + +--- + +## 5. 구현 순서 권장 + +1. Task 1.1 → Task 1.2로 공개 DTO와 domain type을 먼저 고정한다. +2. Task 2.1 → Task 2.4로 repository 조회 계약과 QueryDSL 조건을 테스트로 고정한다. +3. Task 3.1 → Task 3.3으로 service 조립과 예외 정책을 완성한다. +4. Task 4.1 → Task 4.2로 facade/controller endpoint를 연결한다. +5. Task 5.1 → Task 5.3으로 실제 HTTP 흐름과 접근 정책을 검증한다. +6. Task 6.1 → Task 6.2로 기존 커뮤니티 탭 회귀와 전체 검증을 수행한다. + +--- + +## 6. 검증 기록 + +- 2026-07-06: plan-task 문서 작성 전 `docs/20260706_커뮤니티_게시물_상세_API/prd.md`, `docs/agent-guides/코드스타일.md`, `docs/agent-guides/테스트스타일.md`, `docs/agent-guides/실행명령어.md`를 확인했다. +- 2026-07-06: 기존 유사 계획 문서 `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md`, `docs/20260622_크리에이터_채널_FanTalk_탭_API/plan-task.md`를 확인해 문서 구조와 TDD task 형식을 맞췄다. +- 2026-07-06: `rg -n "TBD|TODO|implement later|fill in|적절|:app:compileDebugKotlin|android|Android|Similar" docs/20260706_커뮤니티_게시물_상세_API/plan-task.md`로 placeholder와 잘못된 Android task 예시가 없음을 확인했다. +- 2026-07-06: `./gradlew tasks --all`을 실행해 Gradle 명령 유효성을 확인했고 `BUILD SUCCESSFUL`을 확인했다. +- 2026-07-06: Phase 1-5 구현 후 `./gradlew compileKotlin`, DTO/facade/controller/service/repository/E2E focused tests를 실행해 `BUILD SUCCESSFUL`을 확인했다. +- 2026-07-07: 포맷 정리 후 `./gradlew ktlintCheck`와 관련 focused test 묶음을 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다. +- 2026-07-07: `rg -n "v2\.api\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` 실행 결과 0건으로 domain/query 계층의 API DTO 의존이 없음을 확인했다. +- 2026-07-07: `rg -n "creator-community|schema|migration|Flyway|Liquibase" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2 docs/20260706_커뮤니티_게시물_상세_API/plan-task.md` 실행 결과 문서 언급 외 legacy endpoint/schema 변경 흔적이 없음을 확인했다. +- 2026-07-07: `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. +- 2026-07-07: 리뷰 게이트에서 Task 5.3 E2E 누락, 비밀 답글 노출, 댓글 불가 게시글의 답글 직접 조회 우회가 발견되어 repository/service/E2E 실패 테스트를 추가했다. +- 2026-07-07: 추가 RED 확인으로 `DefaultCreatorChannelCommunityQueryRepositoryTest`, `CreatorChannelCommunityQueryServiceTest`, `CreatorChannelCommunityEndToEndTest` focused 실행 시 5개 테스트 실패를 확인했다. +- 2026-07-07: 비밀 답글 필터와 댓글 불가 답글 빈 응답 처리를 구현한 뒤 같은 focused test 묶음을 재실행해 `BUILD SUCCESSFUL`을 확인했다. +- 2026-07-07: 보강 후 `./gradlew compileKotlin`, `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 재실행해 모두 `BUILD SUCCESSFUL`을 확인했다. +- 2026-07-07: `rg -n "shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked|shouldFilterSecretAndBlockedComments|shouldRejectRepliesWhenParentCommentIsNotVisible|shouldReturnEmptyRepliesWhenCommentUnavailable" ...`로 접근 정책 E2E/서비스 테스트명이 추가됐음을 확인했다. diff --git a/docs/20260706_커뮤니티_게시물_상세_API/prd.md b/docs/20260706_커뮤니티_게시물_상세_API/prd.md new file mode 100644 index 00000000..2f65d3ca --- /dev/null +++ b/docs/20260706_커뮤니티_게시물_상세_API/prd.md @@ -0,0 +1,301 @@ +# PRD: 커뮤니티 게시물 상세 API + +## 1. Overview +커뮤니티 게시물 ID로 게시물 상세와 초기 댓글 20개를 조회하고, 이후 댓글/답글을 페이징 조회하는 v2 API를 제공한다. + +--- + +## 2. Problem +- 크리에이터 채널 커뮤니티 탭은 게시글 목록을 제공하지만, 게시물 상세 화면에서 게시물 1건과 초기 댓글을 함께 조회하는 v2 API가 없다. +- 기존 legacy `/creator-community/{id}` 상세 API는 `firstComment` 1개만 제공하고, 클라이언트가 상세 진입 직후 댓글 목록을 별도로 다시 조회해야 한다. +- legacy 댓글 API는 timezone 기반 표시 문자열을 반환하지만, 신규 상세 화면은 UTC 기반 날짜/시간과 v2 응답 패턴이 필요하다. +- 커뮤니티 탭의 `CreatorChannelCommunityPost`에는 `isLiked`가 이미 포함되어 있으므로 상세 API도 동일한 게시물 필드 의미를 유지해야 한다. +- 댓글/답글 조회는 legacy entity와 필터 정책을 재사용할 수 있지만, 공개 API 조립 계층과 도메인 조회 계층은 기존 v2 패키지 경계를 따라 분리되어야 한다. + +--- + +## 3. Goals +- 커뮤니티 게시물 ID로 게시물 상세를 조회하는 API를 제공한다. +- 상세 응답의 게시물 필드는 크리에이터 채널 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 의미와 필드명을 사용한다. +- 상세 응답의 게시물 필드에는 조회자의 활성 좋아요 여부인 `isLiked`를 포함한다. +- 상세 응답에는 첫 댓글 20개를 함께 포함하며, 이 댓글 묶음은 댓글 조회 API 응답과 동일한 response data class를 사용한다. +- 커뮤니티 댓글 목록을 page/size 기반으로 페이징 조회하는 API를 제공한다. +- 커뮤니티 댓글의 답글 목록을 page/size 기반으로 페이징 조회하는 API를 제공한다. +- API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위 조립 계층에 둔다. +- 게시물 상세, 댓글, 답글 조회 정책과 domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위 도메인 조회 계층에 둔다. +- 기존 커뮤니티 탭 조회에서 재사용 가능한 `CreatorChannelCommunityPost`, `CreatorChannelCommunityQueryService`, `CreatorChannelCommunityQueryPolicy`, repository helper를 우선 재사용한다. +- legacy `CreatorCommunityComment` entity와 차단/비밀 댓글 필터 정책은 재사용하되, legacy response DTO를 v2 공개 응답으로 직접 노출하지 않는다. + +--- + +## 4. Non-Goals +- 커뮤니티 게시물 작성, 수정, 삭제 API는 포함하지 않는다. +- 커뮤니티 게시물 좋아요 생성/취소 API는 변경하지 않는다. +- 커뮤니티 댓글/답글 작성, 수정, 삭제 API는 포함하지 않는다. +- legacy `/creator-community` API의 endpoint와 응답 스키마는 변경하지 않는다. +- 커뮤니티 탭 목록 API의 공개 응답 스키마는 변경하지 않는다. +- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다. +- 앱 표시용 상대 시간 문구는 서버에서 새로 조합하지 않는다. +- 댓글 좋아요, 댓글 신고, 답글 전체 개수의 댓글 item 내 노출은 포함하지 않는다. + +--- + +## 5. Target Users +- 회원: 커뮤니티 게시물 상세 화면에서 게시물 내용과 댓글을 확인하는 사용자 +- 앱 클라이언트: 상세 진입 시 게시물과 초기 댓글 20개를 한 번에 렌더링하고 이후 댓글/답글을 추가 로딩하려는 클라이언트 +- 서버 개발자: v2 커뮤니티 탭, 상세, 댓글 조회 정책을 중복 없이 재사용해야 하는 개발자 + +--- + +## 6. 조사 결과 + +### 재사용 후보 +- `CreatorChannelCommunityPost` + - 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt` + - 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `imageUrl`, `audioUrl`, `content`, `price`, `createdAt`, `existOrdered`, `isCommentAvailable`, `likeCount`, `commentCount`, `isPinned`, `isLiked` + - 상세 응답의 게시물 본문은 이 domain model을 재사용한다. + +- `CreatorChannelCommunityPostResponse` + - 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt` + - 현재 필드: `postId`, `creatorId`, `creatorNickname`, `creatorProfileUrl`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `existOrdered`, `likeCount`, `commentCount`, `isPinned`, `isLiked` + - 상세 응답의 게시물 필드는 이 response data class와 동일한 필드명/의미를 사용한다. + +- `CreatorChannelCommunityQueryService` + - 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` + - 현재 커뮤니티 탭과 채널 홈 커뮤니티 게시글 조회를 담당한다. + - 게시물 상세 조회 method를 추가해 게시물 1건 조립과 유료 콘텐츠 접근 정책, CDN URL, signed audio URL, 본문 마스킹, `isLiked` 전달을 재사용한다. + +- `CreatorChannelCommunityQueryPolicy` + - 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` + - page 기본값/보정 규칙과 `hasNext` 판정, 유료 본문 마스킹 정책을 제공한다. + - 댓글/답글 page도 같은 `page=0`, `size=20`, `size min=20`, `size max=50` 규칙을 따른다. + +- `CreatorCommunityComment` + - 파일: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityComment.kt` + - legacy 댓글 entity이며 `parent`로 답글을 표현한다. + - 신규 v2 조회 repository에서 같은 entity를 조회하되 legacy DTO는 공개 API로 재사용하지 않는다. + +- legacy 댓글 필터 정책 + - 파일: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt` + - 활성 댓글, 최상위 댓글/답글 구분, 작성자 차단/피차단 제외, 비밀 댓글 노출 정책을 참고한다. + +### 기존 endpoint +- 커뮤니티 탭: `GET /api/v2/creator-channels/{creatorId}/community` +- legacy 게시물 상세: `GET /creator-community/{id}` +- legacy 댓글 목록: `GET /creator-community/{id}/comment` +- legacy 답글 목록: `GET /creator-community/comment/{id}` + +--- + +## 7. User Stories +- 사용자는 커뮤니티 게시물 상세 화면에 들어가면 게시물 내용, 미디어, 좋아요 상태, 좋아요 수, 댓글 수를 즉시 확인하고 싶다. +- 사용자는 게시물 상세 화면 진입 직후 최신 댓글 20개를 바로 보고 싶다. +- 사용자는 댓글 목록을 계속 아래로 스크롤하며 추가 조회하고 싶다. +- 사용자는 댓글에 달린 최신 답글 1개를 댓글 목록에서 미리 보고 싶다. +- 사용자는 특정 댓글의 답글 목록을 추가로 페이징 조회하고 싶다. +- 앱 클라이언트는 상세 API의 초기 댓글 응답과 댓글 조회 API 응답을 같은 data class로 처리하고 싶다. + +--- + +## 8. Core Features + +### Feature A. 커뮤니티 게시물 상세 조회 API + +#### Endpoint +- `GET /api/v2/creator-channels/community-posts/{postId}` + +#### Requirements +- `postId`는 path variable로 받는다. +- API는 인증 회원만 조회할 수 있어야 한다. +- 비회원이 조회하면 기존 인증 필요 v2 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다. +- 게시물이 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다. +- 조회자의 성인 콘텐츠 노출 정책이 false이고 게시물이 19금이면 조회 불가 오류를 반환한다. +- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다. +- 게시물 필드는 커뮤니티 탭의 `CreatorChannelCommunityPostResponse`와 동일한 필드명과 의미를 사용한다. +- 게시물 필드에는 `isLiked`를 포함하며, 조회자의 `CreatorCommunityLike.isActive == true`인 좋아요가 있으면 `true`다. +- 상세 응답에는 `comments` 필드로 첫 댓글 20개를 포함한다. +- `comments`는 댓글 조회 API의 `CreatorChannelCommunityCommentsResponse`와 동일한 response data class를 사용한다. +- 상세 응답의 `comments.page`는 `0`, `comments.size`는 `20`으로 내려준다. +- 상세 응답의 `comments.hasNext`는 같은 조건에서 21번째 댓글이 있으면 `true`다. +- `isCommentAvailable == false`인 게시물은 `comments.commentCount=0`, `comments.comments=[]`, `comments.hasNext=false`로 내려준다. +- 유료 게시물의 본문, 이미지, 오디오 접근 정책은 커뮤니티 탭 `CreatorChannelCommunityPost`와 동일하게 적용한다. + +#### Response Data Class +```kotlin +data class CreatorChannelCommunityPostDetailResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val existOrdered: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean, + @JsonProperty("isLiked") + val isLiked: Boolean, + val comments: CreatorChannelCommunityCommentsResponse +) +``` + +#### Edge Cases +- 게시물 작성자가 조회자인 경우에도 성인 콘텐츠 노출 정책이 false이면 19금 게시물은 조회할 수 없다. +- 유료 게시물을 구매하지 않은 조회자에게는 커뮤니티 탭과 동일하게 이미지 URL과 오디오 URL을 `null`로 내려주고 본문을 마스킹한다. +- 게시물에 이미지나 오디오가 없으면 각각 `null`로 내려준다. +- 댓글이 없어도 상세 API는 성공하며 `comments.comments=[]`를 내려준다. + +### Feature B. 커뮤니티 댓글 조회 API + +#### Endpoint +- `GET /api/v2/creator-channels/community-posts/{postId}/comments` + +#### Requirements +- `postId`는 path variable로 받는다. +- `page`, `size` query parameter를 받는다. +- `page` 기본값은 `0`이다. +- `size` 기본값은 `20`이다. +- `page`가 0보다 작으면 `0`으로 보정한다. +- `size`가 20보다 작으면 `20`으로 보정한다. +- `size`가 50보다 크면 `50`으로 보정한다. +- API는 인증 회원만 조회할 수 있어야 한다. +- 게시물이 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다. +- 조회자의 성인 콘텐츠 노출 정책이 false이고 게시물이 19금이면 조회 불가 오류를 반환한다. +- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 빈 목록이 아니라 접근 차단 오류를 반환한다. +- `isCommentAvailable == false`인 게시물은 `commentCount=0`, `comments=[]`, `hasNext=false`로 내려준다. +- `commentCount`는 조회자가 볼 수 있는 활성 최상위 댓글 전체 개수다. +- `comments`는 조회자가 볼 수 있는 활성 최상위 댓글 목록이다. +- 댓글 작성자가 조회자와 차단/피차단 관계이면 목록과 전체 개수에서 제외한다. +- 비밀 댓글은 게시물 작성자 또는 댓글 작성자 본인에게만 노출한다. +- 댓글 정렬은 최신순 `createdAt desc`, `id desc`를 따른다. +- 각 댓글에는 최신 답글 1개를 `latestReply`로 포함한다. +- 최신 답글은 해당 댓글의 활성 답글 중 조회자가 볼 수 있는 답글을 `createdAt desc`, `id desc`로 정렬했을 때 첫 번째 항목이다. +- 최신 답글이 없으면 `latestReply`는 `null`이다. +- 날짜/시간은 UTC 기준 ISO-8601 문자열인 `createdAtUtc`로 내려준다. +- 작성자 프로필 이미지가 없으면 기존 기본 프로필 이미지 URL을 내려준다. +- 탈퇴 회원 닉네임 prefix 제거는 기존 `removeDeletedNicknamePrefix` 정책을 적용한다. + +#### Response Data Class +```kotlin +data class CreatorChannelCommunityCommentsResponse( + val commentCount: Int, + val comments: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +data class CreatorChannelCommunityCommentResponse( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String, + val latestReply: CreatorChannelCommunityReplyResponse? +) + +data class CreatorChannelCommunityReplyResponse( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String +) +``` + +#### Edge Cases +- 요청한 page 범위에 댓글이 없으면 `comments=[]`, `hasNext=false`이고 `commentCount`는 전체 개수를 유지한다. +- 댓글은 있지만 조회자의 차단 관계나 비밀 댓글 정책으로 모두 제외되면 `commentCount=0`, `comments=[]`가 된다. +- 댓글 작성자 프로필 이미지 path가 blank이면 기본 프로필 이미지 URL을 사용한다. +- 답글 작성자가 조회자와 차단/피차단 관계이면 `latestReply` 후보에서 제외한다. + +### Feature C. 커뮤니티 댓글 답글 조회 API + +#### Endpoint +- `GET /api/v2/creator-channels/community-comments/{commentId}/replies` + +#### Requirements +- `commentId`는 path variable로 받는다. +- `page`, `size` query parameter를 받는다. +- page/size 보정 규칙은 댓글 조회 API와 동일하다. +- API는 인증 회원만 조회할 수 있어야 한다. +- `commentId`가 최상위 댓글이 아니거나 존재하지 않거나 비활성 상태이면 기존 커뮤니티 정책과 동일하게 조회 불가 오류를 반환한다. +- 부모 댓글이 속한 게시물이 존재하지 않거나 비활성 상태이면 조회 불가 오류를 반환한다. +- 조회자의 성인 콘텐츠 노출 정책이 false이고 부모 댓글이 속한 게시물이 19금이면 조회 불가 오류를 반환한다. +- 조회자와 게시물 작성자 사이에 차단 관계가 있으면 접근 차단 오류를 반환한다. +- 부모 댓글 자체가 조회자에게 보이지 않는 비밀 댓글이거나 부모 댓글 작성자가 조회자와 차단/피차단 관계이면 조회 불가 오류를 반환한다. +- `replyCount`는 조회자가 볼 수 있는 활성 답글 전체 개수다. +- `replies`는 조회자가 볼 수 있는 활성 답글 목록이다. +- 답글 작성자가 조회자와 차단/피차단 관계이면 목록과 전체 개수에서 제외한다. +- 답글 정렬은 최신순 `createdAt desc`, `id desc`를 따른다. +- 답글 item은 댓글 item과 같은 작성자/본문/UTC 작성 시간 구조를 사용하지만 `latestReply` 필드는 포함하지 않는다. + +#### Response Data Class +```kotlin +data class CreatorChannelCommunityRepliesResponse( + val replyCount: Int, + val replies: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) +``` + +#### Edge Cases +- 요청한 page 범위에 답글이 없으면 `replies=[]`, `hasNext=false`이고 `replyCount`는 전체 개수를 유지한다. +- 답글이 있지만 조회자의 차단 관계로 모두 제외되면 `replyCount=0`, `replies=[]`가 된다. + +### Feature D. API 조립 계층과 도메인 조회 계층 분리 + +#### Requirements +- controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다. +- domain model, query service, policy, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다. +- API 조립 계층은 인증 회원 확인, path/query parameter 수신, domain 결과를 response DTO로 변환하는 책임만 가진다. +- 도메인 조회 계층은 API response DTO를 import하지 않는다. +- 도메인 조회 계층은 API facade나 controller를 import하지 않는다. +- 의존 방향은 항상 `v2.api.creator.channel.community -> v2.creator.channel.community`이다. +- 댓글/답글 조회용 domain model은 v2 전용으로 만들고, legacy `GetCommunityPostCommentListResponse`와 `GetCommunityPostCommentListItem`은 공개 v2 응답에 재사용하지 않는다. + +#### Edge Cases +- 기존 `GET /api/v2/creator-channels/{creatorId}/community` mapping과 신규 `GET /api/v2/creator-channels/community-posts/{postId}` mapping이 충돌하면 안 된다. +- 도메인 조회 계층에 `kr.co.vividnext.sodalive.v2.api.*` import가 생기면 안 된다. + +--- + +## 9. Technical Constraints +- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다. +- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다. +- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다. +- Boolean 응답 필드는 Jackson 직렬화 이름 보존을 위해 `@JsonProperty("isCommentAvailable")`, `@JsonProperty("isPinned")`, `@JsonProperty("isLiked")`, `@JsonProperty("hasNext")`를 명시한다. +- 날짜 응답은 `kr.co.vividnext.sodalive.extensions.toUtcIso`를 우선 재사용해 UTC 기준 ISO-8601 문자열로 내려준다. +- 프로필 이미지 URL은 기존 `String?.toCdnUrl(cloudFrontHost)`와 기본 프로필 이미지 URL 정책을 따른다. +- 커뮤니티 게시물 상세의 유료 콘텐츠 접근 정책은 커뮤니티 탭과 동일하게 `price <= 0 || viewerId == creatorId || existOrdered`를 기준으로 한다. +- 댓글/답글 조회는 `CreatorCommunityComment` entity를 조회하되, v2 domain record와 response DTO로 변환한다. +- 댓글/답글 count와 list 조건은 동일해야 한다. +- list 조회는 `size + 1`개를 조회하거나 동등한 방식으로 `hasNext`를 판단하고, 응답 목록에는 최대 `size`개만 포함한다. + +--- + +## 10. Success Criteria +- `GET /api/v2/creator-channels/community-posts/{postId}`가 커뮤니티 탭 게시물과 동일한 게시물 필드, `isLiked`, 초기 댓글 20개를 반환한다. +- 상세 API의 `comments` 구조가 댓글 조회 API 응답 data class와 동일하다. +- `GET /api/v2/creator-channels/community-posts/{postId}/comments`가 조회 가능한 댓글 전체 개수, 댓글 page, 최신 답글 1개를 반환한다. +- `GET /api/v2/creator-channels/community-comments/{commentId}/replies`가 조회 가능한 답글 전체 개수와 답글 page를 반환한다. +- 댓글/답글 날짜는 UTC ISO-8601 문자열로 내려간다. +- 댓글/답글 작성자 차단/피차단, 비밀 댓글, 비활성 댓글, 19금 게시물 접근 정책이 테스트로 구분된다. +- `isCommentAvailable == false`인 게시물은 상세 초기 댓글과 댓글 목록 API 모두 빈 댓글 응답을 반환한다. +- 기존 커뮤니티 탭 API와 크리에이터 채널 홈 API의 커뮤니티 게시글 응답이 회귀 없이 통과한다. +- `v2.creator.channel.community` 도메인 패키지의 `v2.api.*` import 검색 결과가 0건이다. + +--- + +## 11. Open Questions +- 없음. 구현 중 댓글 비밀 정책이나 endpoint 경로에 대한 추가 결정이 필요하면 구현 전에 이 PRD와 `plan-task.md`를 먼저 갱신한다. -- 2.49.1 From fe9adf35e74b2ff15a38787eec17fbd40ac8db99 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:55:33 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat(community):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EC=83=81=EC=84=B8=20=EC=9D=91=EB=8B=B5=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=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 --- ...eatorChannelCommunityPostDetailResponse.kt | 139 ++++++++++++++++++ .../CreatorChannelCommunityPostDetail.kt | 40 +++++ ...rChannelCommunityPostDetailResponseTest.kt | 99 +++++++++++++ .../CreatorChannelCommunityQueryPolicyTest.kt | 14 ++ 4 files changed, 292 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt new file mode 100644 index 00000000..1e64b15c --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt @@ -0,0 +1,139 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.extensions.toUtcIso +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.CreatorChannelCommunityPostDetail +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReplies +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply + +data class CreatorChannelCommunityPostDetailResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val existOrdered: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean, + @JsonProperty("isLiked") + val isLiked: Boolean, + val comments: CreatorChannelCommunityCommentsResponse +) { + companion object { + fun from(detail: CreatorChannelCommunityPostDetail): CreatorChannelCommunityPostDetailResponse { + val post = detail.post + return CreatorChannelCommunityPostDetailResponse( + postId = post.postId, + creatorId = post.creatorId, + creatorNickname = post.creatorNickname, + creatorProfileUrl = post.creatorProfileUrl, + createdAtUtc = post.createdAt.toUtcIso(), + content = post.content, + imageUrl = post.imageUrl, + audioUrl = post.audioUrl, + price = post.price, + isCommentAvailable = post.isCommentAvailable, + existOrdered = post.existOrdered, + likeCount = post.likeCount, + commentCount = post.commentCount, + isPinned = post.isPinned, + isLiked = post.isLiked, + comments = CreatorChannelCommunityCommentsResponse.from(detail.comments) + ) + } + } +} + +data class CreatorChannelCommunityCommentsResponse( + val commentCount: Int, + val comments: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(comments: CreatorChannelCommunityComments): CreatorChannelCommunityCommentsResponse { + return CreatorChannelCommunityCommentsResponse( + commentCount = comments.commentCount, + comments = comments.comments.map(CreatorChannelCommunityCommentResponse::from), + page = comments.page.page, + size = comments.page.size, + hasNext = comments.hasNext + ) + } + } +} + +data class CreatorChannelCommunityCommentResponse( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String, + val latestReply: CreatorChannelCommunityReplyResponse? +) { + companion object { + fun from(comment: CreatorChannelCommunityComment): CreatorChannelCommunityCommentResponse { + return CreatorChannelCommunityCommentResponse( + commentId = comment.commentId, + writerProfileImageUrl = comment.writerProfileImageUrl, + writerNickname = comment.writerNickname, + content = comment.content, + createdAtUtc = comment.createdAt.toUtcIso(), + latestReply = comment.latestReply?.let(CreatorChannelCommunityReplyResponse::from) + ) + } + } +} + +data class CreatorChannelCommunityRepliesResponse( + val replyCount: Int, + val replies: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(replies: CreatorChannelCommunityReplies): CreatorChannelCommunityRepliesResponse { + return CreatorChannelCommunityRepliesResponse( + replyCount = replies.replyCount, + replies = replies.replies.map(CreatorChannelCommunityReplyResponse::from), + page = replies.page.page, + size = replies.page.size, + hasNext = replies.hasNext + ) + } + } +} + +data class CreatorChannelCommunityReplyResponse( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAtUtc: String +) { + companion object { + fun from(reply: CreatorChannelCommunityReply): CreatorChannelCommunityReplyResponse { + return CreatorChannelCommunityReplyResponse( + commentId = reply.commentId, + writerProfileImageUrl = reply.writerProfileImageUrl, + writerNickname = reply.writerNickname, + content = reply.content, + createdAtUtc = reply.createdAt.toUtcIso() + ) + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt new file mode 100644 index 00000000..febccbbb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt @@ -0,0 +1,40 @@ +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 CreatorChannelCommunityPostDetail( + val post: CreatorChannelCommunityPost, + val comments: CreatorChannelCommunityComments +) + +data class CreatorChannelCommunityComments( + val commentCount: Int, + val comments: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelCommunityComment( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAt: LocalDateTime, + val latestReply: CreatorChannelCommunityReply? +) + +data class CreatorChannelCommunityReplies( + val replyCount: Int, + val replies: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelCommunityReply( + val commentId: Long, + val writerProfileImageUrl: String, + val writerNickname: String, + val content: String, + val createdAt: LocalDateTime +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt new file mode 100644 index 00000000..493461bc --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt @@ -0,0 +1,99 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +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.CreatorChannelCommunityReplies +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityReply +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +class CreatorChannelCommunityPostDetailResponseTest { + @Test + @DisplayName("상세 응답 DTO는 is prefix boolean 필드명과 댓글/답글 구조를 유지한다") + fun shouldSerializeBooleanFieldsWithIsPrefixAndReuseCommentsResponse() { + val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build()) + val response = CreatorChannelCommunityPostDetailResponse.from(createDetail()) + val replies = CreatorChannelCommunityRepliesResponse.from(createReplies()) + + val detailJson = mapper.readTree(mapper.writeValueAsString(response)) + val repliesJson = mapper.readTree(mapper.writeValueAsString(replies)) + + assertTrue(detailJson["isCommentAvailable"].asBoolean()) + assertTrue(detailJson["isPinned"].asBoolean()) + assertTrue(detailJson["isLiked"].asBoolean()) + assertTrue(detailJson["comments"]["hasNext"].asBoolean()) + assertTrue(detailJson["comments"]["comments"][0].has("latestReply")) + assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("commentId")) + assertTrue(repliesJson["hasNext"].asBoolean()) + assertFalse(repliesJson["replies"][0].has("latestReply")) + } + + private fun createDetail(): CreatorChannelCommunityPostDetail { + val reply = CreatorChannelCommunityReply( + commentId = 2L, + writerProfileImageUrl = "https://cdn.test/reply.png", + writerNickname = "reply-writer", + content = "reply", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 1) + ) + return CreatorChannelCommunityPostDetail( + post = CreatorChannelCommunityPost( + postId = 1L, + creatorId = 10L, + creatorNickname = "creator", + creatorProfileUrl = "https://cdn.test/profile.png", + imageUrl = "https://cdn.test/image.png", + audioUrl = null, + content = "content", + price = 0, + createdAt = LocalDateTime.of(2026, 7, 6, 3, 0), + existOrdered = false, + isCommentAvailable = true, + likeCount = 1, + commentCount = 1, + isPinned = true, + isLiked = true + ), + comments = CreatorChannelCommunityComments( + commentCount = 1, + comments = listOf( + CreatorChannelCommunityComment( + commentId = 1L, + writerProfileImageUrl = "https://cdn.test/comment.png", + writerNickname = "comment-writer", + content = "comment", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 0), + latestReply = reply + ) + ), + page = CreatorChannelPage(0, 20), + hasNext = true + ) + ) + } + + private fun createReplies(): CreatorChannelCommunityReplies { + return CreatorChannelCommunityReplies( + replyCount = 1, + replies = listOf( + CreatorChannelCommunityReply( + commentId = 2L, + writerProfileImageUrl = "https://cdn.test/reply.png", + writerNickname = "reply-writer", + content = "reply", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 1) + ) + ), + page = CreatorChannelPage(0, 20), + hasNext = true + ) + } +} 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 index d569ddf3..d91182d8 100644 --- 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 @@ -41,6 +41,20 @@ class CreatorChannelCommunityQueryPolicyTest { assertEquals(51, maximumPage.fetchLimit) } + @Test + @DisplayName("커뮤니티 댓글도 커뮤니티 탭과 같은 page fallback 정책을 사용한다") + fun shouldUseSamePageFallbackForCommunityComments() { + val defaultPage = policy.createPage(page = null, size = null) + val boundedPage = policy.createPage(page = -1, size = 100) + + assertEquals(0, defaultPage.page) + assertEquals(20, defaultPage.size) + assertEquals(21, defaultPage.fetchLimit) + assertEquals(0, boundedPage.page) + assertEquals(50, boundedPage.size) + assertEquals(51, boundedPage.fetchLimit) + } + @Test @DisplayName("커뮤니티 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다") fun shouldLimitItemsAndCalculateHasNext() { -- 2.49.1 From cf8acacedf7cddf0a3dabcb973a7a9252258aa0d Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:55:46 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat(community):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EB=8C=93=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tCreatorChannelCommunityQueryRepository.kt | 204 ++++++++++++++++++ .../out/CreatorChannelCommunityQueryPort.kt | 61 ++++++ ...atorChannelCommunityQueryRepositoryTest.kt | 105 +++++++++ 3 files changed, 370 insertions(+) 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, -- 2.49.1 From 3bdf8353f65cd01c05329a5406728942ff92ca6c Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:56:23 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat(community):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EB=8C=93=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelCommunityQueryService.kt | 146 +++++++++++++ ...CreatorChannelCommunityQueryServiceTest.kt | 204 +++++++++++++++++- .../CreatorChannelHomeQueryServiceTest.kt | 36 ++++ 3 files changed, 384 insertions(+), 2 deletions(-) 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() } -- 2.49.1 From f1072a4ce415ce92dc5069c90f45f23ae28e5939 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:57:01 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat(community):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EC=83=81=EC=84=B8=20API=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/CreatorChannelCommunityController.kt | 47 ++++ .../CreatorChannelCommunityFacade.kt | 53 +++++ .../CreatorChannelCommunityControllerTest.kt | 210 ++++++++++++++++++ .../CreatorChannelCommunityFacadeTest.kt | 97 ++++++++ 4 files changed, 407 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt index 1f1320f7..3901833c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt @@ -33,6 +33,53 @@ class CreatorChannelCommunityController( ) } + @GetMapping("/community-posts/{postId}") + fun getCommunityPostDetail( + @PathVariable postId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + creatorChannelCommunityFacade.getCommunityPostDetail( + postId = postId, + viewer = requireMember(member) + ) + ) + } + + @GetMapping("/community-posts/{postId}/comments") + fun getCommunityComments( + @PathVariable postId: Long, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + creatorChannelCommunityFacade.getCommunityComments( + postId = postId, + viewer = requireMember(member), + page = page, + size = size + ) + ) + } + + @GetMapping("/community-comments/{commentId}/replies") + fun getCommunityReplies( + @PathVariable commentId: Long, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + creatorChannelCommunityFacade.getCommunityReplies( + commentId = commentId, + viewer = requireMember(member), + page = page, + size = size + ) + ) + } + private fun requireMember(member: Member?): Member { return member ?: throw SodaException(messageKey = "common.error.bad_credentials") } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt index aea1ac8f..f88a3de6 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt @@ -1,6 +1,9 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService import org.springframework.stereotype.Service @@ -29,4 +32,54 @@ class CreatorChannelCommunityFacade( ) ) } + + fun getCommunityPostDetail( + postId: Long, + viewer: Member, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityPostDetailResponse { + return CreatorChannelCommunityPostDetailResponse.from( + creatorChannelCommunityQueryService.getCommunityPostDetail( + postId = postId, + viewer = viewer, + now = now + ) + ) + } + + fun getCommunityComments( + postId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityCommentsResponse { + return CreatorChannelCommunityCommentsResponse.from( + creatorChannelCommunityQueryService.getCommunityComments( + postId = postId, + viewer = viewer, + page = page, + size = size, + now = now + ) + ) + } + + fun getCommunityReplies( + commentId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityRepliesResponse { + return CreatorChannelCommunityRepliesResponse.from( + creatorChannelCommunityQueryService.getCommunityReplies( + commentId = commentId, + viewer = viewer, + page = page, + size = size, + now = now + ) + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt index a84b9746..0fa5047e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt @@ -7,7 +7,12 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacade +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityCommentsResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostDetailResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityPostResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityRepliesResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityReplyResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -147,6 +152,147 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( ) } + @Test + @DisplayName("커뮤니티 상세 조회는 비회원 요청을 거부한다") + fun shouldRejectAnonymousCommunityPostDetailRequest() { + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/101") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("커뮤니티 상세 조회는 인증 회원에게 상세와 초기 댓글을 반환한다") + fun shouldReturnCommunityPostDetailForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createDetailResponse()).`when`(facade).getCommunityPostDetail( + eqValue(101L), + eqValue(viewer), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/101") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.postId").value(101)) + .andExpect(jsonPath("$.data.isLiked").value(true)) + .andExpect(jsonPath("$.data.comments.commentCount").value(1)) + .andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(202)) + } + + @Test + @DisplayName("커뮤니티 댓글 조회는 인증 회원에게 댓글 페이지를 반환한다") + fun shouldReturnCommunityCommentsForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createCommentsResponse()).`when`(facade).getCommunityComments( + eqValue(101L), + eqValue(viewer), + eqValue(0), + eqValue(20), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/101/comments") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.commentCount").value(1)) + .andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(202)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("커뮤니티 댓글 조회는 raw page와 size를 facade에 전달한다") + fun shouldPassRawCommentPageAndSizeToFacade() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createCommentsResponse(page = 0, size = 50)).`when`(facade).getCommunityComments( + eqValue(101L), + eqValue(viewer), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/101/comments") + .param("page", "-1") + .param("size", "100") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(50)) + + Mockito.verify(facade).getCommunityComments( + eqValue(101L), + eqValue(viewer), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + } + + @Test + @DisplayName("커뮤니티 답글 조회는 인증 회원에게 답글 페이지를 반환한다") + fun shouldReturnCommunityRepliesForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createRepliesResponse()).`when`(facade).getCommunityReplies( + eqValue(201L), + eqValue(viewer), + eqValue(0), + eqValue(20), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/201/replies") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.replyCount").value(1)) + .andExpect(jsonPath("$.data.replies[0].commentId").value(202)) + .andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist()) + } + + @Test + @DisplayName("커뮤니티 답글 조회는 raw page와 size를 facade에 전달한다") + fun shouldPassRawReplyPageAndSizeToFacade() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createRepliesResponse(page = 0, size = 50)).`when`(facade).getCommunityReplies( + eqValue(201L), + eqValue(viewer), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/201/replies") + .param("page", "-1") + .param("size", "100") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(50)) + + Mockito.verify(facade).getCommunityReplies( + eqValue(201L), + eqValue(viewer), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + } + private fun eqValue(value: T): T { return Mockito.eq(value) ?: value } @@ -194,4 +340,68 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( hasNext = false ) } + + private fun createDetailResponse(): CreatorChannelCommunityPostDetailResponse { + return CreatorChannelCommunityPostDetailResponse( + postId = 101L, + creatorId = 1L, + creatorNickname = "creator", + creatorProfileUrl = "https://cdn.test/profile.png", + createdAtUtc = "2026-06-21T03:30:00Z", + content = "content", + imageUrl = null, + audioUrl = null, + price = 100, + isCommentAvailable = true, + existOrdered = true, + likeCount = 7, + commentCount = 1, + isPinned = true, + isLiked = true, + comments = createCommentsResponse() + ) + } + + private fun createCommentsResponse(page: Int = 0, size: Int = 20): CreatorChannelCommunityCommentsResponse { + return CreatorChannelCommunityCommentsResponse( + commentCount = 1, + comments = listOf( + CreatorChannelCommunityCommentResponse( + commentId = 201L, + writerProfileImageUrl = "https://cdn.test/writer.png", + writerNickname = "writer", + content = "comment", + createdAtUtc = "2026-07-06T03:00:00Z", + latestReply = CreatorChannelCommunityReplyResponse( + commentId = 202L, + writerProfileImageUrl = "https://cdn.test/replier.png", + writerNickname = "replier", + content = "reply", + createdAtUtc = "2026-07-06T03:01:00Z" + ) + ) + ), + page = page, + size = size, + hasNext = false + ) + } + + private fun createRepliesResponse(page: Int = 0, size: Int = 20): CreatorChannelCommunityRepliesResponse { + return CreatorChannelCommunityRepliesResponse( + replyCount = 1, + replies = listOf( + CreatorChannelCommunityReplyResponse( + commentId = 202L, + writerProfileImageUrl = "https://cdn.test/replier.png", + writerNickname = "replier", + content = "reply", + createdAtUtc = "2026-07-06T03:01:00Z" + ) + ), + page = page, + size = size, + hasNext = false + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt index 1ce40cd4..26f0c976 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt @@ -6,7 +6,12 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService +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.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.live.domain.CreatorChannelPage import org.junit.jupiter.api.Assertions.assertEquals @@ -87,6 +92,56 @@ class CreatorChannelCommunityFacadeTest { assertTrue(response.hasNext) } + @Test + @DisplayName("커뮤니티 상세 facade는 service 결과를 상세 응답 DTO로 변환한다") + fun shouldReturnCommunityPostDetailResponse() { + val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java) + val facade = CreatorChannelCommunityFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + Mockito.doReturn(createDetail()).`when`(service).getCommunityPostDetail(101L, viewer, now) + + val response = facade.getCommunityPostDetail(101L, viewer, now) + + assertEquals(101L, response.postId) + assertTrue(response.isLiked) + assertEquals(1, response.comments.commentCount) + assertEquals(201L, response.comments.comments.first().latestReply?.commentId) + } + + @Test + @DisplayName("커뮤니티 댓글 facade는 raw page와 size를 service로 전달하고 응답 DTO로 변환한다") + fun shouldReturnCommunityCommentsResponse() { + val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java) + val facade = CreatorChannelCommunityFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + Mockito.doReturn(createComments()).`when`(service).getCommunityComments(101L, viewer, -1, 100, now) + + val response = facade.getCommunityComments(101L, viewer, -1, 100, now) + + assertEquals(1, response.commentCount) + assertEquals(0, response.page) + assertEquals(20, response.size) + assertEquals(201L, response.comments.first().latestReply?.commentId) + } + + @Test + @DisplayName("커뮤니티 답글 facade는 raw page와 size를 service로 전달하고 응답 DTO로 변환한다") + fun shouldReturnCommunityRepliesResponse() { + val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java) + val facade = CreatorChannelCommunityFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + Mockito.doReturn(createReplies()).`when`(service).getCommunityReplies(201L, viewer, -1, 100, now) + + val response = facade.getCommunityReplies(201L, viewer, -1, 100, now) + + assertEquals(1, response.replyCount) + assertEquals(201L, response.replies.first().commentId) + assertTrue(response.hasNext) + } + private fun createMember(id: Long): Member { return Member( email = "viewer$id@test.com", @@ -139,4 +194,46 @@ class CreatorChannelCommunityFacadeTest { hasNext = true ) } + + private fun createDetail(): CreatorChannelCommunityPostDetail { + return CreatorChannelCommunityPostDetail( + post = createTab().communityPosts.first(), + comments = createComments() + ) + } + + private fun createComments(): CreatorChannelCommunityComments { + return CreatorChannelCommunityComments( + commentCount = 1, + comments = listOf( + CreatorChannelCommunityComment( + commentId = 200L, + writerProfileImageUrl = "https://cdn.test/writer.png", + writerNickname = "writer", + content = "comment", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 0), + latestReply = createReplies().replies.first() + ) + ), + page = CreatorChannelPage(page = 0, size = 20), + hasNext = false + ) + } + + private fun createReplies(): CreatorChannelCommunityReplies { + return CreatorChannelCommunityReplies( + replyCount = 1, + replies = listOf( + CreatorChannelCommunityReply( + commentId = 201L, + writerProfileImageUrl = "https://cdn.test/replier.png", + writerNickname = "replier", + content = "reply", + createdAt = LocalDateTime.of(2026, 7, 6, 3, 1) + ) + ), + page = CreatorChannelPage(page = 0, size = 20), + hasNext = true + ) + } } -- 2.49.1 From 7f04899ca5197b0febaf63c7d081e2789337122e Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 11:57:35 +0900 Subject: [PATCH 06/17] =?UTF-8?q?test(community):=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20=EC=83=81=EC=84=B8=20API=20E2E=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelCommunityEndToEndTest.kt | 345 +++++++++++++++++- 1 file changed, 343 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt index bf077514..a9ecfc29 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt @@ -5,9 +5,12 @@ import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment +import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import org.junit.jupiter.api.DisplayName @@ -94,6 +97,145 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( Mockito.verifyNoMoreInteractions(audioContentCloudFront) } + @Test + @DisplayName("커뮤니티 상세 API는 초기 댓글 20개와 최신 답글을 반환한다") + fun shouldReturnCommunityPostDetailWithInitialComments() { + val fixture = createDetailFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.postId").value(fixture.postId)) + .andExpect(jsonPath("$.data.isLiked").value(true)) + .andExpect(jsonPath("$.data.comments.comments.length()").value(20)) + .andExpect(jsonPath("$.data.comments.hasNext").value(true)) + .andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(fixture.latestReplyId)) + .andExpect(jsonPath("$.data.comments.comments[0].createdAtUtc").value(org.hamcrest.Matchers.endsWith("Z"))) + } + + @Test + @DisplayName("커뮤니티 댓글 API는 댓글 페이지와 최신 답글을 반환한다") + fun shouldReturnPagedCommunityCommentsWithLatestReply() { + val fixture = createDetailFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.commentCount").value(21)) + .andExpect(jsonPath("$.data.comments.length()").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(fixture.latestReplyId)) + } + + @Test + @DisplayName("커뮤니티 답글 API는 latestReply 없이 답글 페이지를 반환한다") + fun shouldReturnPagedCommunityRepliesWithoutLatestReply() { + val fixture = createDetailFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/${fixture.firstCommentId}/replies") + .param("page", "0") + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.replyCount").value(1)) + .andExpect(jsonPath("$.data.replies[0].commentId").value(fixture.latestReplyId)) + .andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist()) + } + + @Test + @DisplayName("댓글 불가 게시물은 빈 댓글 응답을 반환한다") + fun shouldReturnEmptyCommentsWhenCommentUnavailable() { + val fixture = createCommentUnavailableFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.commentCount").value(0)) + .andExpect(jsonPath("$.data.comments.length()").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("성인 콘텐츠를 볼 수 없는 회원은 19금 커뮤니티 상세를 조회할 수 없다") + fun shouldRejectAdultCommunityPostWhenViewerCannotViewAdultContent() { + val fixture = createAdultBlockedFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("차단 관계가 있으면 커뮤니티 상세를 조회할 수 없다") + fun shouldRejectCommunityPostWhenViewerAndCreatorAreBlocked() { + val fixture = createBlockedFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("커뮤니티 댓글 API는 비밀 댓글과 차단 작성자를 필터링한다") + fun shouldFilterSecretAndBlockedComments() { + val fixture = createAccessPolicyFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-posts/${fixture.postId}/comments") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.commentCount").value(2)) + .andExpect(jsonPath("$.data.comments.length()").value(2)) + .andExpect(jsonPath("$.data.comments[0].commentId").value(fixture.ownSecretCommentId)) + .andExpect(jsonPath("$.data.comments[1].commentId").value(fixture.publicCommentId)) + .andExpect(jsonPath("$.data.comments[1].latestReply.commentId").value(fixture.publicReplyId)) + } + + @Test + @DisplayName("보이지 않는 부모 댓글의 답글 조회는 오류를 반환한다") + fun shouldRejectRepliesWhenParentCommentIsNotVisible() { + val fixture = createAccessPolicyFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/${fixture.hiddenSecretCommentId}/replies") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + } + + @Test + @DisplayName("댓글 불가 게시물의 답글 직접 조회는 빈 응답을 반환한다") + fun shouldReturnEmptyRepliesWhenCommentUnavailable() { + val fixture = createCommentUnavailableFixtureWithReply() + + mockMvc.perform( + get("/api/v2/creator-channels/community-comments/${fixture.commentId}/replies") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.replyCount").value(0)) + .andExpect(jsonPath("$.data.replies.length()").value(0)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + private fun createFixture(): Fixture { return transactionTemplate.execute { val now = LocalDateTime.of(2026, 6, 21, 12, 0) @@ -192,12 +334,13 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( content: String, imagePath: String? = null, audioPath: String? = null, - isAdult: Boolean = false + isAdult: Boolean = false, + isCommentAvailable: Boolean = true ): CreatorCommunity { val community = CreatorCommunity( content = content, price = price, - isCommentAvailable = true, + isCommentAvailable = isCommentAvailable, isAdult = isAdult, audioPath = audioPath, imagePath = imagePath, @@ -210,6 +353,37 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( return community } + private fun saveCommunityLike(member: Member, community: CreatorCommunity): CreatorCommunityLike { + val like = CreatorCommunityLike(isActive = true) + like.member = member + like.creatorCommunity = community + entityManager.persist(like) + return like + } + + private fun saveCommunityComment( + member: Member, + community: CreatorCommunity, + parent: CreatorCommunityComment? = null, + isSecret: Boolean = false, + isActive: Boolean = true + ): CreatorCommunityComment { + val comment = CreatorCommunityComment(comment = "comment", isSecret = isSecret, isActive = isActive) + comment.member = member + comment.creatorCommunity = community + comment.parent = parent + entityManager.persist(comment) + return comment + } + + private fun saveBlock(member: Member, blockedMember: Member): BlockMember { + val block = BlockMember(isActive = true) + block.member = member + block.blockedMember = blockedMember + entityManager.persist(block) + return block + } + private fun saveCommunityOrder(member: Member, community: CreatorCommunity): UseCan { val useCan = UseCan(CanUsage.PAID_COMMUNITY_POST, community.price, rewardCan = 0, isRefund = false) useCan.member = member @@ -225,6 +399,147 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( .executeUpdate() } + private fun updateCommentCreatedAt(id: Long, createdAt: LocalDateTime) { + entityManager.createQuery("update CreatorCommunityComment e set e.createdAt = :createdAt where e.id = :id") + .setParameter("createdAt", createdAt) + .setParameter("id", id) + .executeUpdate() + } + + private fun createDetailFixture(): DetailFixture { + return transactionTemplate.execute { + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + val viewer = saveMember("community-detail-viewer", MemberRole.USER) + val creator = saveMember("community-detail-creator", MemberRole.CREATOR) + val replier = saveMember("community-detail-replier", MemberRole.USER) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity(creator, isFixed = false, price = 0, content = "detail community") + saveCommunityLike(viewer, post) + val comments = (1..21).map { saveCommunityComment(viewer, post) } + val latestReply = saveCommunityComment(replier, post, parent = comments.last()) + entityManager.flush() + comments.forEachIndexed { index, comment -> + updateCommentCreatedAt(comment.id!!, now.plusMinutes(index.toLong())) + } + updateCommentCreatedAt(latestReply.id!!, now.plusMinutes(30)) + entityManager.flush() + entityManager.clear() + DetailFixture( + viewer = viewer, + postId = post.id!!, + firstCommentId = comments.last().id!!, + latestReplyId = latestReply.id!! + ) + }!! + } + + private fun createCommentUnavailableFixture(): PostFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-unavailable-viewer", MemberRole.USER) + val creator = saveMember("community-unavailable-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity( + creator = creator, + isFixed = false, + price = 0, + content = "comment unavailable", + isCommentAvailable = false + ) + saveCommunityComment(viewer, post) + entityManager.flush() + entityManager.clear() + PostFixture(viewer = viewer, postId = post.id!!) + }!! + } + + private fun createAdultBlockedFixture(): PostFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-adult-blocked-viewer", MemberRole.USER) + val creator = saveMember("community-adult-blocked-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = false) + val post = saveCommunity( + creator = creator, + isFixed = false, + price = 0, + content = "adult blocked", + isAdult = true + ) + entityManager.flush() + entityManager.clear() + PostFixture(viewer = viewer, postId = post.id!!) + }!! + } + + private fun createBlockedFixture(): PostFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-blocked-viewer", MemberRole.USER) + val creator = saveMember("community-blocked-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + saveBlock(viewer, creator) + val post = saveCommunity(creator, isFixed = false, price = 0, content = "blocked") + entityManager.flush() + entityManager.clear() + PostFixture(viewer = viewer, postId = post.id!!) + }!! + } + + private fun createAccessPolicyFixture(): AccessPolicyFixture { + return transactionTemplate.execute { + val now = LocalDateTime.of(2026, 7, 6, 12, 0) + val viewer = saveMember("community-policy-viewer", MemberRole.USER) + val creator = saveMember("community-policy-creator", MemberRole.CREATOR) + val publicWriter = saveMember("community-policy-public-writer", MemberRole.USER) + val secretWriter = saveMember("community-policy-secret-writer", MemberRole.USER) + val blockedWriter = saveMember("community-policy-blocked-writer", MemberRole.USER) + val replyWriter = saveMember("community-policy-reply-writer", MemberRole.USER) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity(creator, isFixed = false, price = 0, content = "policy") + val publicComment = saveCommunityComment(publicWriter, post) + val ownSecretComment = saveCommunityComment(viewer, post, isSecret = true) + val hiddenSecretComment = saveCommunityComment(secretWriter, post, isSecret = true) + saveCommunityComment(blockedWriter, post) + val publicReply = saveCommunityComment(replyWriter, post, parent = publicComment) + val hiddenSecretReply = saveCommunityComment(secretWriter, post, parent = publicComment, isSecret = true) + saveBlock(viewer, blockedWriter) + entityManager.flush() + updateCommentCreatedAt(publicComment.id!!, now.minusMinutes(3)) + updateCommentCreatedAt(ownSecretComment.id!!, now.minusMinutes(1)) + updateCommentCreatedAt(hiddenSecretComment.id!!, now) + updateCommentCreatedAt(publicReply.id!!, now.plusMinutes(1)) + updateCommentCreatedAt(hiddenSecretReply.id!!, now.plusMinutes(2)) + entityManager.flush() + entityManager.clear() + AccessPolicyFixture( + viewer = viewer, + postId = post.id!!, + publicCommentId = publicComment.id!!, + ownSecretCommentId = ownSecretComment.id!!, + hiddenSecretCommentId = hiddenSecretComment.id!!, + publicReplyId = publicReply.id!! + ) + }!! + } + + private fun createCommentUnavailableFixtureWithReply(): ReplyFixture { + return transactionTemplate.execute { + val viewer = saveMember("community-unavailable-reply-viewer", MemberRole.USER) + val creator = saveMember("community-unavailable-reply-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + val post = saveCommunity( + creator = creator, + isFixed = false, + price = 0, + content = "comment unavailable reply", + isCommentAvailable = false + ) + val comment = saveCommunityComment(viewer, post) + saveCommunityComment(viewer, post, parent = comment) + entityManager.flush() + entityManager.clear() + ReplyFixture(viewer = viewer, commentId = comment.id!!) + }!! + } + private data class Fixture( val viewer: Member, val creatorId: Long, @@ -234,4 +549,30 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( val noImagePostId: Long, val adultPurchasedPostId: Long ) + + private data class DetailFixture( + val viewer: Member, + val postId: Long, + val firstCommentId: Long, + val latestReplyId: Long + ) + + private data class PostFixture( + val viewer: Member, + val postId: Long + ) + + private data class AccessPolicyFixture( + val viewer: Member, + val postId: Long, + val publicCommentId: Long, + val ownSecretCommentId: Long, + val hiddenSecretCommentId: Long, + val publicReplyId: Long + ) + + private data class ReplyFixture( + val viewer: Member, + val commentId: Long + ) } -- 2.49.1 From bf103dedd745f9073bfb2679baface26624b82bc Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 17:55:55 +0900 Subject: [PATCH 07/17] =?UTF-8?q?docs(community):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=EC=9E=90=20=EC=8B=9D=EB=B3=84=EC=9E=90=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260706_커뮤니티_게시물_상세_API/plan-task.md | 6 ++++++ docs/20260706_커뮤니티_게시물_상세_API/prd.md | 2 ++ 2 files changed, 8 insertions(+) diff --git a/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md b/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md index 2e4386d3..6e259d69 100644 --- a/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md +++ b/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md @@ -182,6 +182,7 @@ data class CreatorChannelCommunityCommentsResponse( data class CreatorChannelCommunityCommentResponse( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, @@ -192,6 +193,7 @@ data class CreatorChannelCommunityCommentResponse( fun from(comment: CreatorChannelCommunityComment): CreatorChannelCommunityCommentResponse { return CreatorChannelCommunityCommentResponse( commentId = comment.commentId, + writerId = comment.writerId, writerProfileImageUrl = comment.writerProfileImageUrl, writerNickname = comment.writerNickname, content = comment.content, @@ -225,6 +227,7 @@ data class CreatorChannelCommunityRepliesResponse( data class CreatorChannelCommunityReplyResponse( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, @@ -234,6 +237,7 @@ data class CreatorChannelCommunityReplyResponse( fun from(reply: CreatorChannelCommunityReply): CreatorChannelCommunityReplyResponse { return CreatorChannelCommunityReplyResponse( commentId = reply.commentId, + writerId = reply.writerId, writerProfileImageUrl = reply.writerProfileImageUrl, writerNickname = reply.writerNickname, content = reply.content, @@ -270,6 +274,7 @@ data class CreatorChannelCommunityComments( data class CreatorChannelCommunityComment( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, @@ -286,6 +291,7 @@ data class CreatorChannelCommunityReplies( data class CreatorChannelCommunityReply( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, diff --git a/docs/20260706_커뮤니티_게시물_상세_API/prd.md b/docs/20260706_커뮤니티_게시물_상세_API/prd.md index 2f65d3ca..e1b1b274 100644 --- a/docs/20260706_커뮤니티_게시물_상세_API/prd.md +++ b/docs/20260706_커뮤니티_게시물_상세_API/prd.md @@ -194,6 +194,7 @@ data class CreatorChannelCommunityCommentsResponse( data class CreatorChannelCommunityCommentResponse( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, @@ -203,6 +204,7 @@ data class CreatorChannelCommunityCommentResponse( data class CreatorChannelCommunityReplyResponse( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, -- 2.49.1 From 741f71d8a981d964c3c513a3213d339383aa797d Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 17:56:02 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat(community):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=EC=9E=90=20=EC=8B=9D=EB=B3=84=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20=EC=A1=B0=ED=9A=8C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/CreatorChannelCommunityQueryService.kt | 2 ++ .../community/domain/CreatorChannelCommunityPostDetail.kt | 2 ++ .../application/CreatorChannelCommunityQueryServiceTest.kt | 5 +++++ 3 files changed, 9 insertions(+) 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 551c7350..ed2dce0e 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 @@ -253,6 +253,7 @@ class CreatorChannelCommunityQueryService( ): CreatorChannelCommunityComment { return CreatorChannelCommunityComment( commentId = commentId, + writerId = writerId, writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), writerNickname = writerNickname.removeDeletedNicknamePrefix(), content = content, @@ -264,6 +265,7 @@ class CreatorChannelCommunityQueryService( private fun CreatorChannelCommunityCommentRecord.toReplyDomain(): CreatorChannelCommunityReply { return CreatorChannelCommunityReply( commentId = commentId, + writerId = writerId, writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), writerNickname = writerNickname.removeDeletedNicknamePrefix(), content = content, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt index febccbbb..e3e250e8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt @@ -17,6 +17,7 @@ data class CreatorChannelCommunityComments( data class CreatorChannelCommunityComment( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, @@ -33,6 +34,7 @@ data class CreatorChannelCommunityReplies( data class CreatorChannelCommunityReply( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, 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 1d6569cd..4f4d816e 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 @@ -194,7 +194,9 @@ class CreatorChannelCommunityQueryServiceTest { assertEquals(21, detail.comments.commentCount) assertEquals(20, detail.comments.comments.size) assertEquals(true, detail.comments.hasNext) + assertEquals(101L, detail.comments.comments.first().writerId) assertEquals("reply-writer", detail.comments.comments.first().latestReply?.writerNickname) + assertEquals(200L, detail.comments.comments.first().latestReply?.writerId) assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl) } @@ -234,8 +236,10 @@ class CreatorChannelCommunityQueryServiceTest { assertEquals((1L..50L).toList(), port.latestReplyCommentIds) assertEquals(50, comments.comments.size) assertEquals(true, comments.hasNext) + assertEquals(101L, comments.comments.first().writerId) assertEquals("https://cdn.test/profile/1.png", comments.comments.first().writerProfileImageUrl) assertEquals(100L, comments.comments[1].latestReply?.commentId) + assertEquals(200L, comments.comments[1].latestReply?.writerId) } @Test @@ -255,6 +259,7 @@ class CreatorChannelCommunityQueryServiceTest { assertEquals(0L, port.replyListOffset) assertEquals(51, port.replyListLimit) assertEquals(true, replies.hasNext) + assertEquals(101L, replies.replies.first().writerId) } @Test -- 2.49.1 From 95ffb6991126df2ff8279625dd2c16188440a75b Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 17:56:12 +0900 Subject: [PATCH 09/17] =?UTF-8?q?feat(community):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=EC=9E=90=20=EC=8B=9D=EB=B3=84=EC=9E=90?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=91=EB=8B=B5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/CreatorChannelCommunityPostDetailResponse.kt | 4 ++++ .../in/web/CreatorChannelCommunityControllerTest.kt | 8 ++++++++ .../application/CreatorChannelCommunityFacadeTest.kt | 7 +++++++ .../dto/CreatorChannelCommunityPostDetailResponseTest.kt | 6 ++++++ 4 files changed, 25 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt index 1e64b15c..fe310d2f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt @@ -77,6 +77,7 @@ data class CreatorChannelCommunityCommentsResponse( data class CreatorChannelCommunityCommentResponse( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, @@ -87,6 +88,7 @@ data class CreatorChannelCommunityCommentResponse( fun from(comment: CreatorChannelCommunityComment): CreatorChannelCommunityCommentResponse { return CreatorChannelCommunityCommentResponse( commentId = comment.commentId, + writerId = comment.writerId, writerProfileImageUrl = comment.writerProfileImageUrl, writerNickname = comment.writerNickname, content = comment.content, @@ -120,6 +122,7 @@ data class CreatorChannelCommunityRepliesResponse( data class CreatorChannelCommunityReplyResponse( val commentId: Long, + val writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, @@ -129,6 +132,7 @@ data class CreatorChannelCommunityReplyResponse( fun from(reply: CreatorChannelCommunityReply): CreatorChannelCommunityReplyResponse { return CreatorChannelCommunityReplyResponse( commentId = reply.commentId, + writerId = reply.writerId, writerProfileImageUrl = reply.writerProfileImageUrl, writerNickname = reply.writerNickname, content = reply.content, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt index 0fa5047e..4335bdb9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt @@ -180,7 +180,9 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.postId").value(101)) .andExpect(jsonPath("$.data.isLiked").value(true)) .andExpect(jsonPath("$.data.comments.commentCount").value(1)) + .andExpect(jsonPath("$.data.comments.comments[0].writerId").value(20)) .andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(202)) + .andExpect(jsonPath("$.data.comments.comments[0].latestReply.writerId").value(21)) } @Test @@ -203,7 +205,9 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.data.commentCount").value(1)) + .andExpect(jsonPath("$.data.comments[0].writerId").value(20)) .andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(202)) + .andExpect(jsonPath("$.data.comments[0].latestReply.writerId").value(21)) .andExpect(jsonPath("$.data.hasNext").value(false)) } @@ -259,6 +263,7 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( .andExpect(status().isOk) .andExpect(jsonPath("$.data.replyCount").value(1)) .andExpect(jsonPath("$.data.replies[0].commentId").value(202)) + .andExpect(jsonPath("$.data.replies[0].writerId").value(21)) .andExpect(jsonPath("$.data.replies[0].latestReply").doesNotExist()) } @@ -368,12 +373,14 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( comments = listOf( CreatorChannelCommunityCommentResponse( commentId = 201L, + writerId = 20L, writerProfileImageUrl = "https://cdn.test/writer.png", writerNickname = "writer", content = "comment", createdAtUtc = "2026-07-06T03:00:00Z", latestReply = CreatorChannelCommunityReplyResponse( commentId = 202L, + writerId = 21L, writerProfileImageUrl = "https://cdn.test/replier.png", writerNickname = "replier", content = "reply", @@ -393,6 +400,7 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( replies = listOf( CreatorChannelCommunityReplyResponse( commentId = 202L, + writerId = 21L, writerProfileImageUrl = "https://cdn.test/replier.png", writerNickname = "replier", content = "reply", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt index 26f0c976..9df9a3ab 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt @@ -106,6 +106,8 @@ class CreatorChannelCommunityFacadeTest { assertEquals(101L, response.postId) assertTrue(response.isLiked) assertEquals(1, response.comments.commentCount) + assertEquals(20L, response.comments.comments.first().writerId) + assertEquals(21L, response.comments.comments.first().latestReply?.writerId) assertEquals(201L, response.comments.comments.first().latestReply?.commentId) } @@ -123,6 +125,8 @@ class CreatorChannelCommunityFacadeTest { assertEquals(1, response.commentCount) assertEquals(0, response.page) assertEquals(20, response.size) + assertEquals(20L, response.comments.first().writerId) + assertEquals(21L, response.comments.first().latestReply?.writerId) assertEquals(201L, response.comments.first().latestReply?.commentId) } @@ -139,6 +143,7 @@ class CreatorChannelCommunityFacadeTest { assertEquals(1, response.replyCount) assertEquals(201L, response.replies.first().commentId) + assertEquals(21L, response.replies.first().writerId) assertTrue(response.hasNext) } @@ -208,6 +213,7 @@ class CreatorChannelCommunityFacadeTest { comments = listOf( CreatorChannelCommunityComment( commentId = 200L, + writerId = 20L, writerProfileImageUrl = "https://cdn.test/writer.png", writerNickname = "writer", content = "comment", @@ -226,6 +232,7 @@ class CreatorChannelCommunityFacadeTest { replies = listOf( CreatorChannelCommunityReply( commentId = 201L, + writerId = 21L, writerProfileImageUrl = "https://cdn.test/replier.png", writerNickname = "replier", content = "reply", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt index 493461bc..2fd213a3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt @@ -31,14 +31,18 @@ class CreatorChannelCommunityPostDetailResponseTest { assertTrue(detailJson["isLiked"].asBoolean()) assertTrue(detailJson["comments"]["hasNext"].asBoolean()) assertTrue(detailJson["comments"]["comments"][0].has("latestReply")) + assertTrue(detailJson["comments"]["comments"][0].has("writerId")) assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("commentId")) + assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("writerId")) assertTrue(repliesJson["hasNext"].asBoolean()) + assertTrue(repliesJson["replies"][0].has("writerId")) assertFalse(repliesJson["replies"][0].has("latestReply")) } private fun createDetail(): CreatorChannelCommunityPostDetail { val reply = CreatorChannelCommunityReply( commentId = 2L, + writerId = 20L, writerProfileImageUrl = "https://cdn.test/reply.png", writerNickname = "reply-writer", content = "reply", @@ -67,6 +71,7 @@ class CreatorChannelCommunityPostDetailResponseTest { comments = listOf( CreatorChannelCommunityComment( commentId = 1L, + writerId = 10L, writerProfileImageUrl = "https://cdn.test/comment.png", writerNickname = "comment-writer", content = "comment", @@ -86,6 +91,7 @@ class CreatorChannelCommunityPostDetailResponseTest { replies = listOf( CreatorChannelCommunityReply( commentId = 2L, + writerId = 20L, writerProfileImageUrl = "https://cdn.test/reply.png", writerNickname = "reply-writer", content = "reply", -- 2.49.1 From f100e11861d581d3844e41131c7a5fcdfec9e51d Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 19:17:07 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix(community):=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=EC=9D=BD=EA=B8=B0=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=8B=A4=ED=96=89=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creatorCommunity/CreatorCommunityService.kt | 1 + .../creatorCommunity/CreatorCommunityServiceTest.kt | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt index 96cd4fda..e75a7801 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt @@ -41,6 +41,7 @@ import java.time.LocalDateTime import java.time.ZoneId @Service +@Transactional(readOnly = true) class CreatorCommunityService( private val canPaymentService: CanPaymentService, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt index 48c91ca1..ad19b50e 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt @@ -35,6 +35,7 @@ import org.junit.jupiter.api.Test import org.mockito.ArgumentCaptor import org.mockito.Mockito import org.springframework.context.ApplicationEventPublisher +import org.springframework.transaction.annotation.Transactional import org.springframework.web.multipart.MultipartFile import java.io.InputStream import java.time.LocalDateTime @@ -234,6 +235,15 @@ class CreatorCommunityServiceTest { assertEquals("creator.community.invalid_request_retry", exception.messageKey) } + @Test + @DisplayName("커뮤니티 서비스 조회 메서드는 기본적으로 읽기 전용 트랜잭션으로 실행된다") + fun shouldRunCommunityServiceReadMethodsInReadOnlyTransaction() { + val transactional = CreatorCommunityService::class.java.getAnnotation(Transactional::class.java) + + assertNotNull(transactional) + assertTrue(transactional.readOnly) + } + @Test @DisplayName("고정 게시물이 이미 3개면 추가 고정 시 예외가 발생한다") fun shouldThrowExceptionWhenPinCountExceedsLimit() { -- 2.49.1 From 8b2d26c7d38c32ee15cfcb1970ae81293e3e64c2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 7 Jul 2026 20:53:08 +0900 Subject: [PATCH 11/17] =?UTF-8?q?chore(build):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=8B=A4=ED=96=89=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=A1=B0=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 827f865d..74a72e4f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -99,9 +99,16 @@ tasks.withType { } } -tasks.withType { - useJUnitPlatform() - maxHeapSize = "1536m" +tasks.withType().configureEach { + maxParallelForks = 1 + forkEvery = 100 + + maxHeapSize = "1024m" + + jvmArgs( + "-XX:MaxMetaspaceSize=512m", + "-Dfile.encoding=UTF-8" + ) } tasks.getByName("jar") { -- 2.49.1 From 4aa73d151618ea5ba58240dead916a0c9eff4279 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 8 Jul 2026 01:26:08 +0900 Subject: [PATCH 12/17] =?UTF-8?q?docs(community):=20=EB=B9=84=EB=B0=80=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=9D=91=EB=8B=B5=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260706_커뮤니티_게시물_상세_API/prd.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/20260706_커뮤니티_게시물_상세_API/prd.md b/docs/20260706_커뮤니티_게시물_상세_API/prd.md index e1b1b274..53a4f552 100644 --- a/docs/20260706_커뮤니티_게시물_상세_API/prd.md +++ b/docs/20260706_커뮤니티_게시물_상세_API/prd.md @@ -173,6 +173,7 @@ data class CreatorChannelCommunityPostDetailResponse( - `comments`는 조회자가 볼 수 있는 활성 최상위 댓글 목록이다. - 댓글 작성자가 조회자와 차단/피차단 관계이면 목록과 전체 개수에서 제외한다. - 비밀 댓글은 게시물 작성자 또는 댓글 작성자 본인에게만 노출한다. +- 각 댓글에는 비밀 댓글 여부인 `isSecret`을 포함한다. - 댓글 정렬은 최신순 `createdAt desc`, `id desc`를 따른다. - 각 댓글에는 최신 답글 1개를 `latestReply`로 포함한다. - 최신 답글은 해당 댓글의 활성 답글 중 조회자가 볼 수 있는 답글을 `createdAt desc`, `id desc`로 정렬했을 때 첫 번째 항목이다. @@ -198,6 +199,8 @@ data class CreatorChannelCommunityCommentResponse( val writerProfileImageUrl: String, val writerNickname: String, val content: String, + @JsonProperty("isSecret") + val isSecret: Boolean, val createdAtUtc: String, val latestReply: CreatorChannelCommunityReplyResponse? ) -- 2.49.1 From 128fb232d02dfe539cb7070d1989f3793fb83dc8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 8 Jul 2026 01:26:13 +0900 Subject: [PATCH 13/17] =?UTF-8?q?docs(community):=20=EB=B9=84=EB=B0=80=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260706_커뮤니티_게시물_상세_API/plan-task.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md b/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md index 6e259d69..8961099b 100644 --- a/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md +++ b/docs/20260706_커뮤니티_게시물_상세_API/plan-task.md @@ -186,6 +186,8 @@ data class CreatorChannelCommunityCommentResponse( val writerProfileImageUrl: String, val writerNickname: String, val content: String, + @JsonProperty("isSecret") + val isSecret: Boolean, val createdAtUtc: String, val latestReply: CreatorChannelCommunityReplyResponse? ) { @@ -197,6 +199,7 @@ data class CreatorChannelCommunityCommentResponse( writerProfileImageUrl = comment.writerProfileImageUrl, writerNickname = comment.writerNickname, content = comment.content, + isSecret = comment.isSecret, createdAtUtc = comment.createdAt.toUtcIso(), latestReply = comment.latestReply?.let(CreatorChannelCommunityReplyResponse::from) ) @@ -278,6 +281,7 @@ data class CreatorChannelCommunityComment( val writerProfileImageUrl: String, val writerNickname: String, val content: String, + val isSecret: Boolean, val createdAt: LocalDateTime, val latestReply: CreatorChannelCommunityReply? ) @@ -353,6 +357,7 @@ data class CreatorChannelCommunityCommentRecord( val writerProfilePath: String?, val writerNickname: String, val content: String, + val isSecret: Boolean, val createdAt: LocalDateTime ) -- 2.49.1 From 936e472d513bf78cdb9b1271e52156d01a75c691 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 8 Jul 2026 01:26:17 +0900 Subject: [PATCH 14/17] =?UTF-8?q?chore(build):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20JVM=20=EC=84=A4=EC=A0=95=EC=9D=84=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 74a72e4f..2b83d7d5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -99,15 +99,15 @@ tasks.withType { } } -tasks.withType().configureEach { - maxParallelForks = 1 - forkEvery = 100 +tasks.withType { + useJUnitPlatform() - maxHeapSize = "1024m" + val springContextCacheMaxSize = (project.findProperty("test.springContextCacheMaxSize") as String?) ?: "1" + maxHeapSize = (project.findProperty("testMaxHeap") as String?) ?: "1536m" jvmArgs( - "-XX:MaxMetaspaceSize=512m", - "-Dfile.encoding=UTF-8" + "-Dfile.encoding=UTF-8", + "-Dspring.test.context.cache.maxSize=$springContextCacheMaxSize" ) } -- 2.49.1 From 0cdcc42100e0611d857c492ffed926c0ff42a903 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 8 Jul 2026 01:26:23 +0900 Subject: [PATCH 15/17] =?UTF-8?q?test(config):=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EA=B2=A9=EB=A6=AC=EB=A5=BC=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 8b895aae..ba17a889 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -92,7 +92,7 @@ spring: datasource: driver-class-name: org.h2.Driver - url: jdbc:h2:mem:sodalive-test;MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + url: jdbc:h2:mem:sodalive-test-${random.uuid};MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE username: sa password: -- 2.49.1 From e013cdb39d8ccdf78f1bff02e828bcfb21de7744 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 8 Jul 2026 01:26:31 +0900 Subject: [PATCH 16/17] =?UTF-8?q?feat(community):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=20=EC=97=AC=EB=B6=80=EB=A5=BC=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultCreatorChannelCommunityQueryRepository.kt | 2 ++ .../application/CreatorChannelCommunityQueryService.kt | 1 + .../community/domain/CreatorChannelCommunityPostDetail.kt | 1 + .../community/port/out/CreatorChannelCommunityQueryPort.kt | 1 + .../application/CreatorChannelCommunityQueryServiceTest.kt | 6 +++++- 5 files changed, 10 insertions(+), 1 deletion(-) 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 126c5695..9ee6efd3 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 @@ -260,6 +260,7 @@ class DefaultCreatorChannelCommunityQueryRepository( creatorCommunityComment.member.profileImage, creatorCommunityComment.member.nickname, creatorCommunityComment.comment, + creatorCommunityComment.isSecret, creatorCommunityComment.createdAt ) @@ -556,6 +557,7 @@ class DefaultCreatorChannelCommunityQueryRepository( writerProfilePath = get(creatorCommunityComment.member.profileImage), writerNickname = get(creatorCommunityComment.member.nickname)!!, content = get(creatorCommunityComment.comment)!!, + isSecret = get(creatorCommunityComment.isSecret)!!, createdAt = get(creatorCommunityComment.createdAt)!! ) } 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 ed2dce0e..1c3f3c5f 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 @@ -257,6 +257,7 @@ class CreatorChannelCommunityQueryService( writerProfileImageUrl = writerProfilePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), writerNickname = writerNickname.removeDeletedNicknamePrefix(), content = content, + isSecret = isSecret, createdAt = createdAt, latestReply = latestReply ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt index e3e250e8..ac46b00d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityPostDetail.kt @@ -21,6 +21,7 @@ data class CreatorChannelCommunityComment( val writerProfileImageUrl: String, val writerNickname: String, val content: String, + val isSecret: Boolean, val createdAt: LocalDateTime, val latestReply: CreatorChannelCommunityReply? ) 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 16536dba..d5cdd98c 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 @@ -106,6 +106,7 @@ data class CreatorChannelCommunityCommentRecord( val writerProfilePath: String?, val writerNickname: String, val content: String, + val isSecret: Boolean, val createdAt: LocalDateTime ) 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 4f4d816e..6fd723c6 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 @@ -195,6 +195,7 @@ class CreatorChannelCommunityQueryServiceTest { assertEquals(20, detail.comments.comments.size) assertEquals(true, detail.comments.hasNext) assertEquals(101L, detail.comments.comments.first().writerId) + assertEquals(false, detail.comments.comments.first().isSecret) assertEquals("reply-writer", detail.comments.comments.first().latestReply?.writerNickname) assertEquals(200L, detail.comments.comments.first().latestReply?.writerId) assertEquals("https://cdn.test/profile/default-profile.png", detail.comments.comments.first().writerProfileImageUrl) @@ -237,6 +238,7 @@ class CreatorChannelCommunityQueryServiceTest { assertEquals(50, comments.comments.size) assertEquals(true, comments.hasNext) assertEquals(101L, comments.comments.first().writerId) + assertEquals(false, comments.comments.first().isSecret) assertEquals("https://cdn.test/profile/1.png", comments.comments.first().writerProfileImageUrl) assertEquals(100L, comments.comments[1].latestReply?.commentId) assertEquals(200L, comments.comments[1].latestReply?.writerId) @@ -499,7 +501,8 @@ private fun communityCommentRecord( commentId: Long, parentCommentId: Long? = null, writerProfilePath: String? = null, - writerNickname: String = "writer-$commentId" + writerNickname: String = "writer-$commentId", + isSecret: Boolean = false ): CreatorChannelCommunityCommentRecord { return CreatorChannelCommunityCommentRecord( commentId = commentId, @@ -509,6 +512,7 @@ private fun communityCommentRecord( writerProfilePath = writerProfilePath, writerNickname = writerNickname, content = "comment-$commentId", + isSecret = isSecret, createdAt = LocalDateTime.of(2026, 7, 6, 10, 0).plusMinutes(commentId) ) } -- 2.49.1 From 9bb14aacdff97b4cedaf6b8c78007295eda08431 Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 8 Jul 2026 01:26:44 +0900 Subject: [PATCH 17/17] =?UTF-8?q?feat(community):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=20=EC=97=AC=EB=B6=80=EB=A5=BC=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../community/dto/CreatorChannelCommunityPostDetailResponse.kt | 3 +++ .../adapter/in/web/CreatorChannelCommunityControllerTest.kt | 3 +++ .../adapter/in/web/CreatorChannelCommunityEndToEndTest.kt | 2 ++ .../community/application/CreatorChannelCommunityFacadeTest.kt | 3 +++ .../dto/CreatorChannelCommunityPostDetailResponseTest.kt | 2 ++ 5 files changed, 13 insertions(+) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt index fe310d2f..eb1ec27e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponse.kt @@ -81,6 +81,8 @@ data class CreatorChannelCommunityCommentResponse( val writerProfileImageUrl: String, val writerNickname: String, val content: String, + @JsonProperty("isSecret") + val isSecret: Boolean, val createdAtUtc: String, val latestReply: CreatorChannelCommunityReplyResponse? ) { @@ -92,6 +94,7 @@ data class CreatorChannelCommunityCommentResponse( writerProfileImageUrl = comment.writerProfileImageUrl, writerNickname = comment.writerNickname, content = comment.content, + isSecret = comment.isSecret, createdAtUtc = comment.createdAt.toUtcIso(), latestReply = comment.latestReply?.let(CreatorChannelCommunityReplyResponse::from) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt index 4335bdb9..ce9c67b4 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt @@ -181,6 +181,7 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.isLiked").value(true)) .andExpect(jsonPath("$.data.comments.commentCount").value(1)) .andExpect(jsonPath("$.data.comments.comments[0].writerId").value(20)) + .andExpect(jsonPath("$.data.comments.comments[0].isSecret").value(true)) .andExpect(jsonPath("$.data.comments.comments[0].latestReply.commentId").value(202)) .andExpect(jsonPath("$.data.comments.comments[0].latestReply.writerId").value(21)) } @@ -206,6 +207,7 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( .andExpect(status().isOk) .andExpect(jsonPath("$.data.commentCount").value(1)) .andExpect(jsonPath("$.data.comments[0].writerId").value(20)) + .andExpect(jsonPath("$.data.comments[0].isSecret").value(true)) .andExpect(jsonPath("$.data.comments[0].latestReply.commentId").value(202)) .andExpect(jsonPath("$.data.comments[0].latestReply.writerId").value(21)) .andExpect(jsonPath("$.data.hasNext").value(false)) @@ -377,6 +379,7 @@ class CreatorChannelCommunityControllerTest @Autowired constructor( writerProfileImageUrl = "https://cdn.test/writer.png", writerNickname = "writer", content = "comment", + isSecret = true, createdAtUtc = "2026-07-06T03:00:00Z", latestReply = CreatorChannelCommunityReplyResponse( commentId = 202L, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt index a9ecfc29..c805fb01 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt @@ -204,7 +204,9 @@ class CreatorChannelCommunityEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.commentCount").value(2)) .andExpect(jsonPath("$.data.comments.length()").value(2)) .andExpect(jsonPath("$.data.comments[0].commentId").value(fixture.ownSecretCommentId)) + .andExpect(jsonPath("$.data.comments[0].isSecret").value(true)) .andExpect(jsonPath("$.data.comments[1].commentId").value(fixture.publicCommentId)) + .andExpect(jsonPath("$.data.comments[1].isSecret").value(false)) .andExpect(jsonPath("$.data.comments[1].latestReply.commentId").value(fixture.publicReplyId)) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt index 9df9a3ab..ef2dec13 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt @@ -107,6 +107,7 @@ class CreatorChannelCommunityFacadeTest { assertTrue(response.isLiked) assertEquals(1, response.comments.commentCount) assertEquals(20L, response.comments.comments.first().writerId) + assertTrue(response.comments.comments.first().isSecret) assertEquals(21L, response.comments.comments.first().latestReply?.writerId) assertEquals(201L, response.comments.comments.first().latestReply?.commentId) } @@ -126,6 +127,7 @@ class CreatorChannelCommunityFacadeTest { assertEquals(0, response.page) assertEquals(20, response.size) assertEquals(20L, response.comments.first().writerId) + assertTrue(response.comments.first().isSecret) assertEquals(21L, response.comments.first().latestReply?.writerId) assertEquals(201L, response.comments.first().latestReply?.commentId) } @@ -217,6 +219,7 @@ class CreatorChannelCommunityFacadeTest { writerProfileImageUrl = "https://cdn.test/writer.png", writerNickname = "writer", content = "comment", + isSecret = true, createdAt = LocalDateTime.of(2026, 7, 6, 3, 0), latestReply = createReplies().replies.first() ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt index 2fd213a3..01501506 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityPostDetailResponseTest.kt @@ -32,6 +32,7 @@ class CreatorChannelCommunityPostDetailResponseTest { assertTrue(detailJson["comments"]["hasNext"].asBoolean()) assertTrue(detailJson["comments"]["comments"][0].has("latestReply")) assertTrue(detailJson["comments"]["comments"][0].has("writerId")) + assertTrue(detailJson["comments"]["comments"][0]["isSecret"].asBoolean()) assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("commentId")) assertTrue(detailJson["comments"]["comments"][0]["latestReply"].has("writerId")) assertTrue(repliesJson["hasNext"].asBoolean()) @@ -75,6 +76,7 @@ class CreatorChannelCommunityPostDetailResponseTest { writerProfileImageUrl = "https://cdn.test/comment.png", writerNickname = "comment-writer", content = "comment", + isSecret = true, createdAt = LocalDateTime.of(2026, 7, 6, 3, 0), latestReply = reply ) -- 2.49.1