From 94b5c70cc6b4faf8325c6c3a9c4338feba740531 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sun, 21 Jun 2026 18:29:56 +0900 Subject: [PATCH] =?UTF-8?q?docs(creator-channel):=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=ED=83=AD=20API=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EB=A1=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 509 ++++++++++++++++++ .../prd.md | 239 ++++++++ 2 files changed, 748 insertions(+) create mode 100644 docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md create mode 100644 docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md diff --git a/docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md b/docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md new file mode 100644 index 00000000..fc49b463 --- /dev/null +++ b/docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md @@ -0,0 +1,509 @@ +# 크리에이터 채널 커뮤니티 탭 API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. + +**Goal:** 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/community`로 크리에이터 채널 커뮤니티 탭의 조회 가능한 전체 게시글 개수와 페이징된 게시글 목록을 조회할 수 있게 한다. + +**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 조립 계층에 둔다. 커뮤니티 게시글 조회 service, page/content masking 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 두고 `v2.api.*`에 의존하지 않는다. 홈 API는 홈 repository에 커뮤니티 조회 쿼리를 직접 두지 않고, 분리된 커뮤니티 조회 도메인의 홈 요약 조회 메서드를 호출해 기존 `notices`, `communities` 응답 계약을 유지한다. + +**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/{creatorId}/community` +- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다. +- request: + - path variable: `creatorId` + - query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback + - query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback +- response: + - `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수 + - `communityPosts`: 커뮤니티 게시글 목록 + - `page`: fallback 보정 후 실제 적용된 page index + - `size`: fallback 보정 후 실제 적용된 page size + - `hasNext`: 다음 page 존재 여부 +- community post item: + - `postId`, `creatorId`, `creatorNickname`, `createdAtUtc`, `content`, `imageUrl`, `audioUrl`, `price`, `isCommentAvailable`, `likeCount`, `commentCount`, `isPinned` +- 공개 게시글 기준: `CreatorCommunity.isActive == true`, `CreatorCommunity.member.id == creatorId`, `CreatorCommunity.member.isActive == true`. +- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다. +- 성인 콘텐츠 필터는 구매 여부보다 우선한다. 조회자가 19금 게시글을 구매했거나 작성자여도 성인 콘텐츠 노출 정책이 false이면 제외한다. +- 목록 정렬: + - 고정 게시글을 먼저 노출한다. + - 고정 게시글 사이의 정렬은 `fixedAt desc`, `id desc`다. + - 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`다. + - 고정 게시글과 일반 게시글은 하나의 목록으로 페이징한다. +- `communityPostCount`는 고정 게시글과 일반 게시글을 모두 포함한 전체 개수다. +- `createdAtUtc`는 게시글 생성 시각을 UTC 기준 ISO-8601 문자열로 내려준다. +- `imageUrl`은 `CreatorCommunity.imagePath`를 `String?.toCdnUrl(cloudFrontHost)`로 변환한다. 경로가 없거나 blank이면 `null`이다. +- `audioUrl`은 `CreatorCommunity.audioPath`가 있고 접근 권한이 있을 때만 `AudioContentCloudFront.generateSignedURL(resourcePath, 1000 * 60 * 30)` 결과를 내려준다. +- 오디오 접근 권한: + - 무료 게시글이면 접근 가능 + - 유료 게시글이고 조회자가 게시글 작성자이면 접근 가능 + - 유료 게시글이고 조회자가 `CanUsage.PAID_COMMUNITY_POST`, `isRefund == false` 구매 내역을 가지면 접근 가능 + - 그 외에는 `audioUrl == null` +- 유료 게시글 본문은 기존 홈 API/legacy 커뮤니티 정책과 같은 마스킹을 적용한다. + - 접근 가능하면 원문 + - 접근 불가이고 길이가 15 code point 초과이면 앞 15 code point + `...` + - 접근 불가이고 길이가 15 code point 이하이면 앞 절반 code point + `...` +- `commentCount`는 `isCommentAvailable == false`이면 `0`이다. +- `commentCount`는 활성 최상위 댓글만 세고, 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다. +- `likeCount`는 활성 좋아요 수만 센다. +- legacy `/creator-community` 공개 endpoint는 변경하지 않는다. +- 홈 API 공개 응답 스키마는 변경하지 않는다. + +--- + +## 1. 파일 구조 계획 + +### 커뮤니티 탭 신규 API 조립 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt` + +### 커뮤니티 도메인 조회 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` + +### 홈 API 커뮤니티 조회 분리 대상 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` +- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` +- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.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/aws/cloudfront/AudioContentCloudFront.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/comment/CreatorCommunityCommentRepository.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/like/CreatorCommunityLikeRepository.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/UseCan.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/can/use/CanUsage.kt` + +### 문서 산출물 +- Create: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md` +- Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md` + +--- + +## 2. Response data class 초안 + +구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 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.v2.creator.channel.community.domain.CreatorChannelCommunityPost +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab +import java.time.LocalDateTime +import java.time.ZoneOffset + +data class CreatorChannelCommunityTabResponse( + val communityPostCount: Int, + val communityPosts: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(tab: CreatorChannelCommunityTab): CreatorChannelCommunityTabResponse { + return CreatorChannelCommunityTabResponse( + communityPostCount = tab.communityPostCount, + communityPosts = tab.communityPosts.map(CreatorChannelCommunityPostResponse::from), + page = tab.page.page, + size = tab.page.size, + hasNext = tab.hasNext + ) + } + } +} + +data class CreatorChannelCommunityPostResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean +) { + companion object { + fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse { + return CreatorChannelCommunityPostResponse( + postId = post.postId, + creatorId = post.creatorId, + creatorNickname = post.creatorNickname, + createdAtUtc = post.createdAt.toUtcIso(), + content = post.content, + imageUrl = post.imageUrl, + audioUrl = post.audioUrl, + price = post.price, + isCommentAvailable = post.isCommentAvailable, + likeCount = post.likeCount, + commentCount = post.commentCount, + isPinned = post.isPinned + ) + } + } +} + +private fun LocalDateTime.toUtcIso(): String { + return atOffset(ZoneOffset.UTC).toInstant().toString() +} +``` + +--- + +## 3. Domain / Port 초안 + +구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다. + +```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 CreatorChannelCommunityTab( + val communityPostCount: Int, + val communityPosts: List, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelCommunityPost( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val imageUrl: String?, + val audioUrl: String?, + val content: String, + val price: Int, + val createdAt: LocalDateTime, + val existOrdered: Boolean, + val isCommentAvailable: Boolean, + val likeCount: Int, + val commentCount: Int, + val isPinned: Boolean +) +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out + +import kr.co.vividnext.sodalive.member.MemberRole +import java.time.LocalDateTime + +interface CreatorChannelCommunityQueryPort { + fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord? + fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean + fun countCommunityPosts( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean + ): Int + fun findCommunityPosts( + creatorId: Long, + viewerId: Long, + canViewAdultContent: Boolean, + offset: Long, + limit: Int + ): List + fun findHomeCommunityPosts( + creatorId: Long, + viewerId: Long, + isPinned: Boolean, + canViewAdultContent: Boolean, + limit: Int + ): List +} + +data class CreatorChannelCommunityCreatorRecord( + val creatorId: Long, + val role: MemberRole, + val nickname: String +) + +data class CreatorChannelCommunityPostRecord( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfilePath: String?, + val imagePath: String?, + val audioPath: String?, + val content: String, + val price: Int, + val createdAt: LocalDateTime, + val existOrdered: Boolean, + val isCommentAvailable: Boolean, + val likeCount: Int, + val commentCount: Int, + val isPinned: Boolean +) +``` + +--- + +## 4. 작업 계획 + +### Phase 1: 커뮤니티 도메인 계약과 순수 정책 추가 + +- [ ] **Task 1.1: `CreatorChannelCommunityQueryPolicy`와 domain/port 계약 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicyTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityTab.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/port/out/CreatorChannelCommunityQueryPort.kt` + - RED: 아래 케이스를 테스트로 먼저 작성한다. + - `page = null`, `size = null`이면 `page=0`, `size=20`, `offset=0`, `fetchLimit=21`이다. + - `page = -1`, `size = 10`이면 `page=0`, `size=20`, `fetchLimit=21`이다. + - `page = 2`, `size = 100`이면 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이다. + - `limitItems`는 `size`만큼만 남기고 `hasNext`는 `fetched.size > size`로 계산한다. + - 유료 본문 마스킹은 15 code point 초과면 앞 15자 + `...`, 15자 이하면 앞 절반 + `...`로 계산한다. + - 무료 게시글, 작성자 본인, 구매자는 유료 본문 원문을 볼 수 있다. + - domain model과 port record가 Phase 1 계약 필드를 유지한다. + - RED 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"` + - 기대 결과: `CreatorChannelCommunityQueryPolicy`, domain, port 미구현으로 컴파일 실패 또는 테스트 실패 + - GREEN: `CreatorChannelPage`를 재사용해 page 정책을 만들고, `maskPaidContent(content, price, isCreatorSelf, existOrdered)` 순수 함수를 추가한다. + - GREEN 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityQueryPolicyTest"` + - 기대 결과: `BUILD SUCCESSFUL` + - REFACTOR: 정책 클래스에는 DB, Spring MVC, API DTO 의존성을 넣지 않는다. + +### Phase 2: QueryDSL repository 분리와 조회 정책 구현 + +- [ ] **Task 2.1: 커뮤니티 repository의 creator/차단/count/list 조회 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepositoryTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/CreatorChannelCommunityQueryRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/adapter/out/persistence/DefaultCreatorChannelCommunityQueryRepository.kt` + - RED: `@DataJpaTest(properties = ["spring.cache.type=none", "spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"])`, `@Import(QueryDslConfig::class)` 패턴으로 아래 케이스를 작성한다. + - 활성 creator는 `findCreator`로 조회되고 비활성 creator는 `null`이다. + - viewer와 creator 사이 양방향 활성 차단 관계는 `existsBlockedBetween`에서 `true`다. + - `countCommunityPosts`는 creator의 활성 게시글만 세고 다른 creator, 비활성 게시글은 제외한다. + - `canViewAdultContent=false`이면 19금 게시글은 count와 list에서 제외된다. + - `canViewAdultContent=false`이고 viewer가 19금 게시글을 구매했어도 count와 list에서 제외된다. + - list는 고정 게시글을 먼저 반환하고, 고정 게시글은 `fixedAt desc`, 일반 게시글은 `createdAt desc` 순서를 따른다. + - `offset`, `limit`으로 하나의 통합 목록을 페이징한다. + - `likeCount`는 활성 좋아요만 센다. + - `isCommentAvailable=false`인 게시글의 `commentCount`는 `0`이다. + - `commentCount`는 활성 최상위 댓글만 세고, 비밀 댓글은 작성자 본인 또는 콘텐츠 creator에게만 포함한다. + - 차단 관계에 걸린 댓글 작성자의 댓글은 `commentCount`에서 제외된다. + - 유효 구매 내역은 `CanUsage.PAID_COMMUNITY_POST`, `UseCan.member.id == viewerId`, `UseCan.communityPost.id == postId`, `UseCan.isRefund == false`다. + - 같은 게시글에 구매 내역이 중복되어도 list item은 중복되지 않는다. + - RED 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"` + - 기대 결과: repository 미구현으로 컴파일 실패 또는 테스트 실패 + - GREEN: 기존 `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 차단 sub query, adult condition을 커뮤니티 repository로 옮기되 탭용 통합 정렬과 count를 추가한다. + - GREEN 구현 기준: + - tab list where는 `isActive == true`, `member.id == creatorId`, `member.isActive == true`, adult condition을 먼저 적용한다. + - 구매 내역 exists/join은 접근 권한 계산에만 사용하고 adult condition을 우회하지 않는다. + - 정렬은 `isFixed desc`, `fixedAt desc nullsLast`, `createdAt desc`, `id desc`를 사용한다. + - home summary 조회는 `findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`로 제공하고, 기존 홈과 동일하게 고정글/일반글을 분리 조회한다. + - GREEN 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.adapter.out.persistence.DefaultCreatorChannelCommunityQueryRepositoryTest"` + - 기대 결과: `BUILD SUCCESSFUL` + - REFACTOR: `v2.creator.channel.community.adapter.out.persistence`는 `v2.api.*`를 import하지 않는다. + +### Phase 3: 커뮤니티 조회 service 구현 + +- [ ] **Task 3.1: `CreatorChannelCommunityQueryService` 단위 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` + - RED: fake `CreatorChannelCommunityQueryPort`, mock `MemberContentPreferenceService`, mock `AudioContentCloudFront`, `LangContext`, `SodaMessageSource`를 사용해 아래 케이스를 작성한다. + - 요청 page/size fallback 결과를 port의 `offset`, `limit`에 전달하고 `hasNext`와 응답 목록 size를 조립한다. + - creator가 없으면 `member.validation.user_not_found` 예외를 던진다. + - creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다. + - 차단 관계가 있으면 기존 `explorer.creator.blocked_access` 메시지 예외를 던진다. + - `MemberContentPreferenceService`와 `isAdultVisibleByPolicy` 결과를 port의 `canViewAdultContent`로 전달한다. + - 이미지 path는 `toCdnUrl(cloudFrontHost)`로 변환하고 blank path는 `null`이다. + - 작성자 프로필 path가 없으면 기존 홈 API의 기본 프로필 URL 정책을 적용한다. + - 무료 오디오, 구매한 유료 오디오, 작성자 본인 유료 오디오는 `AudioContentCloudFront.generateSignedURL(audioPath, 1000 * 60 * 30)` 결과를 사용한다. + - 미구매 유료 오디오는 signed URL을 생성하지 않고 `audioUrl == null`이다. + - 유료 미구매 본문은 policy의 마스킹 결과를 사용한다. + - `findHomeCommunityPosts`는 탭 전체 검증 없이 받은 `viewerId`, `canViewAdultContent`, `isPinned`, `limit`로 홈 요약 목록을 조립한다. + - RED 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"` + - 기대 결과: service 미구현으로 컴파일 실패 또는 테스트 실패 + - GREEN: `getCommunityTab(creatorId, viewer, page, size, now)`와 `findHomeCommunityPosts(creatorId, viewerId, isPinned, canViewAdultContent, limit)`를 구현한다. + - GREEN 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest"` + - 기대 결과: `BUILD SUCCESSFUL` + - REFACTOR: signed URL 생성은 접근 가능한 오디오 path가 있을 때만 호출하고, service는 API DTO를 반환하지 않는다. + +### Phase 4: 홈 API 커뮤니티 조회 로직을 새 도메인으로 연결 + +- [ ] **Task 4.1: 홈 service 회귀 테스트를 새 커뮤니티 service 의존으로 갱신** + - Files: + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt` + - RED: 기존 홈 service 테스트에서 home query port의 `findCommunityPosts` stub 대신 `CreatorChannelCommunityQueryService.findHomeCommunityPosts` 결과를 사용하도록 테스트를 먼저 바꾼다. + - `notices`는 `isPinned=true`, `limit=3`으로 조회한다. + - `communities`는 `isPinned=false`, `limit=3`으로 조회한다. + - 홈 응답의 커뮤니티 필드명과 의미는 기존과 동일하다. + - RED 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"` + - 기대 결과: service 생성자/호출부 미구현으로 컴파일 실패 또는 테스트 실패 + - GREEN: `CreatorChannelHomeQueryService`에 `CreatorChannelCommunityQueryService`를 주입하고, 기존 `queryPort.findCommunityPosts` 호출 2곳을 새 community service 호출로 교체한다. + - GREEN 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest"` + - 기대 결과: `BUILD SUCCESSFUL` + - REFACTOR: 홈 domain의 기존 `CreatorChannelCommunityPost` data class를 제거하고, 홈의 `notices`, `communities` 타입은 `kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost`를 사용한다. 홈 API response DTO 변환 결과가 바뀌지 않는지 테스트로 확인한다. + +- [ ] **Task 4.2: 홈 port/repository에서 커뮤니티 조회 책임 제거** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt` + - Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` + - RED: 홈 repository 테스트에서 커뮤니티 게시글 조회 전용 테스트가 있으면 동일한 케이스가 `DefaultCreatorChannelCommunityQueryRepositoryTest`로 이동되어야 함을 먼저 확인한다. + - RED 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"` + - 기대 결과: 기존 home port method 제거 전에는 테스트/컴파일이 아직 기존 구조를 기대해 실패할 수 있다. + - GREEN: + - `CreatorChannelHomeQueryPort.findCommunityPosts`와 `CreatorChannelCommunityPostRecord`를 제거한다. + - `DefaultCreatorChannelHomeQueryRepository.findCommunityPosts`, `orderedCommunityPostIds`, `communityLikeCounts`, `communityCommentCounts`, 커뮤니티 전용 차단 sub query, `canAccessPaidCommunityContent`, `maskPaidCommunityContent`, `adultCommunityCondition`, `fixedNoticeCondition`, `visibleCommunityPostCondition` 중 홈 repository에서 더 이상 쓰지 않는 커뮤니티 전용 helper를 제거한다. + - 같은 로직은 `DefaultCreatorChannelCommunityQueryRepository`에만 남긴다. + - GREEN 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest"` + - 기대 결과: `BUILD SUCCESSFUL` + - REFACTOR: 홈 repository에서 `creatorCommunity`, `creatorCommunityLike`, `creatorCommunityComment`, `useCan` import가 더 이상 필요 없으면 제거한다. 다른 홈 조회에서 쓰는 import는 유지한다. + +### Phase 5: 커뮤니티 탭 API 조립 계층 추가 + +- [ ] **Task 5.1: response DTO와 facade 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` + - RED: 아래 케이스를 테스트로 먼저 작성한다. + - facade는 `CreatorChannelCommunityQueryService.getCommunityTab` 결과를 `CreatorChannelCommunityTabResponse`로 변환한다. + - `createdAtUtc`는 UTC ISO-8601 문자열이다. + - `imageUrl == null`, `audioUrl == null`이 그대로 응답된다. + - `@JsonProperty`로 `isCommentAvailable`, `isPinned`, `hasNext` 필드명이 유지된다. + - RED 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"` + - 기대 결과: DTO/facade 미구현으로 컴파일 실패 또는 테스트 실패 + - GREEN: PRD와 이 문서의 response data class 초안을 기준으로 DTO와 facade를 구현한다. + - GREEN 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.application.CreatorChannelCommunityFacadeTest"` + - 기대 결과: `BUILD SUCCESSFUL` + - REFACTOR: DTO는 공개 API 필드 변환만 담당하고, 구매/성인/정렬 정책을 포함하지 않는다. + +- [ ] **Task 5.2: controller 테스트와 endpoint 구현** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityControllerTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityController.kt` + - RED: `@WebMvcTest(CreatorChannelCommunityController::class)`와 기존 시리즈/오디오 controller test의 `TestSecurityConfig` 패턴으로 아래 케이스를 작성한다. + - 비회원 요청은 `401 Unauthorized`다. + - 인증 회원 요청은 `GET /api/v2/creator-channels/{creatorId}/community`를 호출하고 `creatorId`, `page`, `size`, `viewer`를 facade에 전달한다. + - `page=-1`, `size=100` 같은 값은 controller에서 거부하지 않고 facade로 전달한다. + - 성공 응답은 `success=true`, `data.communityPostCount`, `data.communityPosts[0].postId`, `data.communityPosts[0].isCommentAvailable`, `data.communityPosts[0].isPinned`, `data.page`, `data.size`, `data.hasNext`를 포함한다. + - RED 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"` + - 기대 결과: controller 미구현으로 컴파일 실패 또는 테스트 실패 + - GREEN: `@RestController`, `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/community")`, `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member")` 패턴으로 구현한다. + - GREEN 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityControllerTest"` + - 기대 결과: `BUILD SUCCESSFUL` + - REFACTOR: 인증 null guard는 기존 탭 controller와 같은 `requireMember` private 함수로 둔다. + +### Phase 6: E2E와 회귀 검증 + +- [ ] **Task 6.1: 커뮤니티 탭 end-to-end 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` + - RED: `@SpringBootTest`, `@AutoConfigureMockMvc`, `@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])`, `TransactionTemplate` 패턴으로 아래 케이스를 작성한다. + - controller-service-repository를 거쳐 전체 응답 필드를 반환한다. + - 고정 게시글이 일반 게시글보다 먼저 반환된다. + - `page=-1`, `size=10` 요청은 `page=0`, `size=20`으로 fallback된다. + - 성인 콘텐츠 노출 정책이 false인 조회자는 19금 게시글을 count와 list에서 받지 않는다. + - 성인 콘텐츠 노출 정책이 false인 조회자가 19금 게시글을 구매했더라도 count와 list에서 받지 않는다. + - 구매한 유료 게시글의 `audioUrl`은 signed URL 형태이고, 미구매 유료 게시글의 `audioUrl`은 `null`이다. + - 이미지가 없는 게시글의 `imageUrl`은 `null`이다. + - RED 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"` + - 기대 결과: API 미구현 또는 fixture 미연결로 실패 + - GREEN: 필요한 fixture helper를 테스트 내부에 추가하고, `@MockBean AudioContentCloudFront`로 signed URL 결과를 `https://signed.test/community-audio`처럼 고정한다. E2E 테스트에서 실제 CloudFront private key 파일을 요구하지 않게 한다. + - GREEN 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest"` + - 기대 결과: `BUILD SUCCESSFUL` + - REFACTOR: E2E fixture는 테스트 내부 helper로 유지하고 운영 코드에 테스트 전용 분기를 넣지 않는다. + +- [ ] **Task 6.2: 홈 API 회귀와 의존 방향 검증** + - Files: + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt` + - Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt` + - RED: 홈 API 응답 스키마가 변경되지 않아야 하므로 기존 테스트가 실패하면 변경 원인을 확인한다. + - 검증 실행: + - `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.creator.channel.home.*" --tests "kr.co.vividnext.sodalive.v2.creator.channel.home.*"` + - 기대 결과: `BUILD SUCCESSFUL` + - 의존 방향 검색: + - `rg -n "kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\." src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community` + - 기대 결과: 검색 결과 0건 + - REFACTOR: 홈 API response DTO의 필드명, `dateUtc`, `existOrdered`, `likeCount`, `commentCount` 의미가 바뀌지 않도록 API DTO 변경을 피한다. + +### Phase 7: 전체 검증과 문서 갱신 + +- [ ] **Task 7.1: 전체 테스트와 ktlint 검증** + - Files: + - Verify: `docs/20260621_크리에이터_채널_커뮤니티_탭_API/plan-task.md` + - 검증 실행: + - `./gradlew --no-daemon test` + - 기대 결과: `BUILD SUCCESSFUL` + - `./gradlew --no-daemon ktlintCheck` + - 기대 결과: `BUILD SUCCESSFUL` + - 문서 검증: + - 각 완료 task의 체크박스를 `- [x]`로 갱신한다. + - 각 task 아래에 무엇을, 왜, 어떻게 검증했는지, 실행 명령과 결과를 한국어로 누적 기록한다. + - 전체 검증 결과는 아래 `전체 검증 기록` 섹션에 누적한다. + - REFACTOR: 검증 실패가 구현 범위 변경을 요구하면 먼저 이 문서의 task를 갱신한 뒤 코드를 수정한다. + +--- + +## 5. 구현 순서 요약 + +1. Phase 1에서 순수 정책과 domain/port 계약을 먼저 고정한다. +2. Phase 2에서 QueryDSL repository를 새 커뮤니티 도메인으로 분리한다. +3. Phase 3에서 service가 인증/성인/차단/URL/signed URL/마스킹 정책을 조립하게 한다. +4. Phase 4에서 홈 API의 커뮤니티 조회를 새 도메인으로 연결하고 홈 repository의 커뮤니티 쿼리를 제거한다. +5. Phase 5에서 공개 API DTO/facade/controller를 추가한다. +6. Phase 6에서 커뮤니티 탭 E2E와 홈 API 회귀를 확인한다. +7. Phase 7에서 전체 테스트, ktlint, 의존 방향 검색 결과를 누적 기록한다. + +--- + +## 6. 전체 검증 기록 + +- 구현 전 문서 작성 단계에서는 코드 검증을 수행하지 않는다. 구현 단계에서 각 task 완료 즉시 실행 명령과 결과를 이 섹션에 누적한다. +- 2026-06-21: 문서 작성 검증 - placeholder와 모호한 문구 검색 결과 0건. +- 2026-06-21: 문서 변경 whitespace 검증 - `git diff --check` 실행 결과 출력 없음, exit code 0. +- 2026-06-21: 문서 유지보수 규칙 검증 - sandbox 내부 `./gradlew tasks --all`은 `~/.gradle` lock 파일 접근 제한으로 실패했고, 승인 실행으로 재시도한 `./gradlew tasks --all`은 `BUILD SUCCESSFUL` 확인. diff --git a/docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md b/docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md new file mode 100644 index 00000000..0cf143e3 --- /dev/null +++ b/docs/20260621_크리에이터_채널_커뮤니티_탭_API/prd.md @@ -0,0 +1,239 @@ +# PRD: 크리에이터 채널 커뮤니티 탭 API + +## 1. Overview +크리에이터 채널의 커뮤니티 탭에서 조회자가 볼 수 있는 커뮤니티 게시글 전체 개수와 게시글 목록을 페이징 조회하는 API를 제공한다. + +--- + +## 2. Problem +- 크리에이터 채널 홈 API는 커뮤니티 게시글 일부를 홈 화면 요약용으로 조회하지만, 커뮤니티 탭은 전체 개수와 페이징 목록이 필요하다. +- 기존 홈 API의 커뮤니티 조회 로직이 `home` 도메인 repository 안에 포함되어 있어, 커뮤니티 탭 API에서 그대로 재사용하려면 홈 도메인에 의존하게 된다. +- 커뮤니티 게시글 조회 로직은 홈 화면과 커뮤니티 탭에서 모두 쓰일 수 있으므로, 하나의 커뮤니티 조회 도메인으로 분리되어야 한다. +- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 커뮤니티 게시글은 전체 개수와 목록에서 모두 제외되어야 한다. +- legacy `/creator-community` 목록 조회는 구매 내역 조건과 성인 필터 조건이 섞일 수 있으므로, `isAdult=false` 조회에서 구매한 19금 게시글이 개수나 목록에 포함되지 않도록 새 v2 조회 정책에서 명확히 보장해야 한다. + +--- + +## 3. Goals +- 크리에이터 채널 커뮤니티 탭 조회 API를 제공한다. +- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/community`로 한다. +- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위 조립 계층에 둔다. +- 커뮤니티 게시글 목록, 전체 개수, 구매 여부, 좋아요 수, 댓글 수, 성인 콘텐츠 노출, 유료 오디오 접근 정책은 API 패키지 밖 커뮤니티 도메인 패키지에 둔다. +- 크리에이터 채널 홈 API와 커뮤니티 탭 API는 동일한 커뮤니티 조회 도메인을 사용한다. +- 응답에는 조회 가능한 커뮤니티 게시글 전체 개수, 게시글 목록, page, size, hasNext를 포함한다. +- 게시글 목록 item에는 게시글 id, 크리에이터 id, 크리에이터 닉네임, 작성 시간 UTC, 게시글 본문, 이미지 URL, 오디오 URL, 가격, 댓글 쓰기 가능 여부, 좋아요 개수, 댓글 개수, pin 여부를 포함한다. +- 유료 게시글의 오디오 콘텐츠는 조회자가 구매했거나 게시글 작성자인 경우에만 signed URL로 내려준다. +- 유료 게시글을 구매하지 않은 조회자에게는 오디오 콘텐츠 URL을 `null`로 내려준다. +- 이미지가 없는 게시글은 `imageUrl`을 `null`로 내려준다. +- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 전체 개수와 목록에서 제외한다. +- 페이징 요청값은 기존 오디오/시리즈 탭 API와 같은 보정 규칙을 따른다. + +--- + +## 4. Non-Goals +- 커뮤니티 게시글 작성, 수정, 삭제 API는 포함하지 않는다. +- 커뮤니티 게시글 구매 API는 포함하지 않는다. +- 커뮤니티 댓글 작성, 수정, 삭제, 목록 조회 API는 포함하지 않는다. +- 커뮤니티 좋아요 생성/취소 API는 포함하지 않는다. +- legacy `/creator-community` API의 공개 endpoint 변경은 포함하지 않는다. +- 크리에이터 채널 홈 API의 공개 응답 스키마 변경은 포함하지 않는다. +- 홈 API의 커뮤니티 노출 개수나 홈 화면 구성 정책 변경은 포함하지 않는다. +- DB schema, 운영 DDL, 마이그레이션은 포함하지 않는다. +- 앱 표시용 상대 시간 문구는 서버에서 새로 조합하지 않는다. + +--- + +## 5. Target Users +- 회원: 크리에이터 채널 커뮤니티 탭에서 크리에이터의 커뮤니티 게시글을 탐색하는 사용자 +- 앱 클라이언트: 커뮤니티 탭 구성에 필요한 전체 개수와 게시글 목록을 단일 API 응답으로 표시하려는 클라이언트 +- 서버 개발자: 홈 API와 커뮤니티 탭 API에서 커뮤니티 조회 정책을 중복 없이 재사용하려는 개발자 + +--- + +## 6. User Stories +- 사용자는 크리에이터 채널 커뮤니티 탭에 들어가면 자신이 조회 가능한 게시글 전체 개수를 확인하고 싶다. +- 사용자는 커뮤니티 게시글을 최신순으로 추가 로딩하고 싶다. +- 성인 콘텐츠 노출이 꺼진 사용자는 19금 게시글이 개수와 목록에 포함되지 않기를 원한다. +- 사용자는 이미지가 없는 게시글도 정상적으로 목록에서 확인하고 싶다. +- 사용자는 구매한 유료 게시글의 오디오 콘텐츠를 재생할 수 있어야 한다. +- 구매하지 않은 사용자는 유료 게시글의 오디오 콘텐츠 URL을 받지 않아야 한다. +- 앱 클라이언트는 댓글 작성 가능 여부, 좋아요 개수, 댓글 개수, pin 여부를 게시글 item에서 바로 확인하고 싶다. +- 서버 개발자는 홈 API와 커뮤니티 탭 API가 동일한 커뮤니티 조회 도메인을 사용한다는 것을 패키지 의존 방향으로 확인하고 싶다. + +--- + +## 7. Core Features + +### Feature A. 크리에이터 채널 커뮤니티 탭 조회 API + +#### Requirements +- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다. +- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/community`로 한다. +- `creatorId`는 path variable로 받는다. +- 커뮤니티 게시글 추가 로딩을 위해 `page`, `size` query parameter를 받는다. +- `page`는 0부터 시작하는 page index로 처리한다. +- `page`를 보내지 않으면 기본값 `0`을 사용한다. +- `size`를 보내지 않으면 기본값 `20`을 사용한다. +- `page`가 0보다 작으면 `0`으로 보정한다. +- `size`가 20보다 작으면 `20`으로 보정한다. +- `size`가 50보다 크면 `50`으로 보정한다. +- API는 인증 회원만 조회할 수 있어야 한다. +- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다. +- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다. +- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다. +- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다. +- 공개된 커뮤니티 게시글이 없어도 전체 API는 성공 처리한다. + +#### Edge Cases +- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다. +- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다. +- 요청한 page 범위에 게시글이 없으면 `communityPosts`는 빈 배열, `hasNext`는 `false`로 내려주되 `communityPostCount`는 전체 개수를 유지한다. + +### Feature B. 응답 스키마 + +#### Requirements +- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다. +- 응답 최상위 DTO 이름은 `CreatorChannelCommunityTabResponse`로 한다. +- 응답에는 다음 값을 포함한다. + - `communityPostCount`: 조회자가 조회 가능한 커뮤니티 게시글 전체 개수 + - `communityPosts`: 커뮤니티 게시글 목록 + - `page`: 현재 응답의 page index + - `size`: 현재 응답의 page size + - `hasNext`: 다음 page 존재 여부 +- `communityPostCount`는 목록 조회와 같은 공개 여부, 작성자, 성인 콘텐츠 노출, 차단 정책을 적용해 계산한다. +- `communityPostCount`에는 현재 page에 포함되지 않은 게시글도 포함한다. +- `communityPostCount`는 pinned 게시글과 일반 게시글을 모두 포함한 전체 개수다. +- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다. +- `hasNext`는 같은 조건에서 다음 page에 노출할 게시글이 있으면 `true`로 내려준다. +- 응답 스키마 예시는 다음과 같다. + +```kotlin +data class CreatorChannelCommunityTabResponse( + val communityPostCount: Int, + val communityPosts: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +data class CreatorChannelCommunityPostResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean +) +``` + +#### Edge Cases +- 조회 가능한 커뮤니티 게시글이 없으면 `communityPostCount`는 `0`, `communityPosts`는 빈 배열, `hasNext`는 `false`로 내려준다. +- 이미지가 없는 게시글은 `imageUrl`을 `null`로 내려준다. +- 오디오가 없는 게시글은 `audioUrl`을 `null`로 내려준다. +- `isCommentAvailable == false`인 게시글의 `commentCount`는 기존 커뮤니티 목록 정책과 동일하게 `0`으로 내려준다. +- Boolean 응답 필드는 Jackson 직렬화 시 `commentAvailable`, `pinned`로 바뀌지 않고 `isCommentAvailable`, `isPinned`로 내려가야 한다. + +### Feature C. 커뮤니티 게시글 목록과 개수 + +#### Requirements +- 조회 대상은 지정한 `creatorId`가 작성한 커뮤니티 게시글로 제한한다. +- 활성 게시글만 조회한다. +- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 목록에서 제외한다. +- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 `communityPostCount`에서도 제외한다. +- 성인 콘텐츠 필터는 구매 여부보다 우선 적용한다. +- 조회자가 19금 게시글을 구매했더라도 성인 콘텐츠 노출 정책이 false이면 해당 게시글은 목록과 전체 개수에 포함하지 않는다. +- 목록은 pinned 게시글을 먼저 노출하고, 그 다음 일반 게시글을 노출한다. +- pinned 게시글 사이의 정렬은 `fixedAt desc`, `id desc`를 따른다. +- 일반 게시글 사이의 정렬은 `createdAt desc`, `id desc`를 따른다. +- 목록은 `page`, `size` 기준으로 페이징 조회한다. +- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다. +- `createdAtUtc`는 게시글 생성 시간을 UTC 기준 ISO-8601 문자열로 내려준다. +- `imageUrl`은 커뮤니티 게시글 이미지 path가 있으면 기존 CDN URL 조합 정책으로 내려준다. +- `likeCount`는 활성 좋아요 수를 기준으로 계산한다. +- `commentCount`는 조회자가 볼 수 있는 활성 최상위 댓글 수를 기준으로 계산한다. +- 댓글 수 계산에는 기존 커뮤니티 댓글의 차단 관계와 비밀 댓글 노출 정책을 적용한다. + +#### Edge Cases +- pinned 게시글과 일반 게시글이 섞여 있어도 전체 목록은 하나의 페이징 결과로 내려준다. +- pinned 게시글 개수가 page size를 초과하면 첫 page는 pinned 게시글만 포함될 수 있다. +- 게시글 작성자가 조회자인 경우에도 성인 콘텐츠 노출 정책이 false이면 19금 게시글은 제외한다. +- 좋아요나 댓글이 없는 게시글은 `likeCount`, `commentCount`를 `0`으로 내려준다. + +### Feature D. 유료 오디오 콘텐츠 접근 정책 + +#### Requirements +- 커뮤니티 게시글에 오디오 path가 없으면 `audioUrl`은 `null`이다. +- 무료 게시글에 오디오 path가 있으면 signed URL을 내려준다. +- 유료 게시글에 오디오 path가 있고 조회자가 해당 게시글을 구매했으면 signed URL을 내려준다. +- 유료 게시글에 오디오 path가 있고 조회자가 게시글 작성자이면 signed URL을 내려준다. +- 유료 게시글에 오디오 path가 있지만 조회자가 구매하지 않았고 게시글 작성자도 아니면 `audioUrl`은 `null`이다. +- signed URL 생성은 기존 `AudioContentCloudFront.generateSignedURL` 방식을 재사용한다. +- signed URL 만료 시간은 legacy 커뮤니티 목록 정책과 동일하게 30분을 기본으로 한다. +- 유료 게시글 본문은 기존 크리에이터 채널 홈 API의 유료 커뮤니티 본문 마스킹 정책을 따른다. +- 유료 게시글 오디오 접근 여부는 `CanUsage.PAID_COMMUNITY_POST`의 유효 구매 내역을 기준으로 판단한다. +- 환불된 구매 내역은 접근 가능 구매로 보지 않는다. + +#### Edge Cases +- 조회자가 구매했더라도 성인 콘텐츠 노출 정책이 false인 19금 게시글은 목록에 포함되지 않으므로 signed URL도 내려주지 않는다. +- 구매 내역이 중복으로 있어도 응답 item은 게시글 1개로 중복 없이 내려준다. +- signed URL 생성 대상 path가 blank이면 `audioUrl`은 `null`로 내려준다. + +### Feature E. 커뮤니티 조회 도메인 분리 + +#### Requirements +- 커뮤니티 탭 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다. +- 커뮤니티 게시글 조회 service, 순수 정책, domain model, port, repository는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다. +- 도메인 조회 계층은 API response DTO를 import하지 않는다. +- 도메인 조회 계층은 API facade나 controller를 import하지 않는다. +- 의존 방향은 항상 `v2.api.creator.channel.community -> v2.creator.channel.community`이다. +- 크리에이터 채널 홈 API는 홈 도메인 내부에 커뮤니티 조회 쿼리를 직접 보유하지 않고, 분리된 커뮤니티 조회 도메인을 사용한다. +- 홈 API의 공개 응답 필드명과 필드 의미는 변경하지 않는다. +- 홈 API의 커뮤니티 요약 조회 limit와 notice 조회 정책은 기존 동작을 유지한다. +- legacy `kr.co.vividnext.sodalive.explorer.profile.creatorCommunity` 쓰기/상세/댓글/좋아요/구매 기능은 이번 분리 대상에 포함하지 않는다. + +#### Edge Cases +- 홈 API와 커뮤니티 탭 API가 같은 domain model을 사용하더라도 각 API response DTO는 각 API 패키지에서 따로 소유한다. +- 커뮤니티 도메인 분리 과정에서 기존 홈 API controller mapping과 신규 커뮤니티 탭 controller mapping이 충돌하면 안 된다. +- 도메인 분리 후 `v2.creator.channel.community` 하위에서 `v2.api.*` import 검색 결과가 0건이어야 한다. + +--- + +## 8. Technical Constraints +- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다. +- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다. +- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다. +- 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.community` 하위에 둔다. +- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다. +- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.community` 하위에 둔다. +- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다. +- 기존 크리에이터 채널 홈/라이브/오디오/시리즈 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책을 재사용한다. +- 성인 콘텐츠 노출 여부는 기존 v2 탭 API와 동일하게 `MemberContentPreferenceService`와 `isAdultVisibleByPolicy`를 기준으로 계산한다. +- 페이징 응답은 기존 오디오/시리즈 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다. +- 이미지 URL은 기존 `String?.toCdnUrl(cloudFrontHost)` 방식과 같은 CDN URL 조합 정책을 따른다. +- 오디오 URL은 콘텐츠 CloudFront signed URL 생성 정책을 따른다. +- 날짜 응답은 UTC 기준 ISO-8601 문자열로 내려준다. + +--- + +## 9. Metrics +- 커뮤니티 탭 API 성공/실패 건수 +- 커뮤니티 탭 API 응답 시간 +- 커뮤니티 탭 추가 로딩 요청 건수 +- 성인 콘텐츠 노출 정책이 false인 조회에서 19금 게시글이 개수와 목록에 포함되지 않는 테스트 통과 여부 +- 유료 오디오 콘텐츠 signed URL/null 처리 테스트 통과 여부 +- 홈 API 커뮤니티 요약 조회 회귀 테스트 통과 여부 +- `v2.creator.channel.community` 도메인 패키지의 `v2.api.*` import 검색 결과 0건 여부 + +--- + +## 10. Open Questions +- 없음. 구현 중 새 정책 결정이 필요하면 구현 전에 이 PRD와 `plan-task.md`를 먼저 갱신한다.