307 lines
19 KiB
Markdown
307 lines
19 KiB
Markdown
# 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`는 조회자가 볼 수 있는 활성 최상위 댓글 목록이다.
|
|
- 댓글 작성자가 조회자와 차단/피차단 관계이면 목록과 전체 개수에서 제외한다.
|
|
- 비밀 댓글은 게시물 작성자 또는 댓글 작성자 본인에게만 노출한다.
|
|
- 각 댓글에는 비밀 댓글 여부인 `isSecret`을 포함한다.
|
|
- 댓글 정렬은 최신순 `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<CreatorChannelCommunityCommentResponse>,
|
|
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,
|
|
@JsonProperty("isSecret")
|
|
val isSecret: Boolean,
|
|
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<CreatorChannelCommunityReplyResponse>,
|
|
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`를 먼저 갱신한다.
|