# 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 writerId: Long, val writerProfileImageUrl: String, val writerNickname: String, val content: String, val createdAtUtc: String, val latestReply: CreatorChannelCommunityReplyResponse? ) data class CreatorChannelCommunityReplyResponse( val commentId: Long, val writerId: 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`를 먼저 갱신한다.