test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
2 changed files with 748 additions and 0 deletions
Showing only changes of commit 94b5c70cc6 - Show all commits

View File

@@ -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<CreatorChannelCommunityPostResponse>,
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<CreatorChannelCommunityPost>,
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<CreatorChannelCommunityPostRecord>
fun findHomeCommunityPosts(
creatorId: Long,
viewerId: Long,
isPinned: Boolean,
canViewAdultContent: Boolean,
limit: Int
): List<CreatorChannelCommunityPostRecord>
}
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` 확인.

View File

@@ -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<CreatorChannelCommunityPostResponse>,
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`를 먼저 갱신한다.