docs(content-all): 전체 탭 API 계획을 추가한다

This commit is contained in:
2026-06-25 11:24:24 +09:00
parent 87f6e47844
commit 74dc87db1e
2 changed files with 929 additions and 0 deletions

View File

@@ -0,0 +1,333 @@
# PRD: 메인 콘텐츠 전체 탭 API
## 1. Overview
메인 콘텐츠 탭의 내부 전체 탭에서 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다.
---
## 2. Problem
- 기존 메인 콘텐츠 추천 탭 API는 여러 추천 섹션을 한 번에 조립하지만, 전체 탭은 사용자가 선택한 구분별 전체 콘텐츠 목록과 전체 개수, 정렬 상태, 페이징 상태를 제공해야 한다.
- 기존 크리에이터 채널 오디오/시리즈 탭 API는 특정 크리에이터 기준 조회라서, 전체 탭처럼 차단 관계가 아닌 모든 크리에이터의 공개 콘텐츠를 대상으로 하기 어렵다.
- 정렬 기준은 기존 공용 `ContentSort` enum과 의미를 공유해야 하며, 인기순 매출 산식은 포인트 사용액을 제외한 `orders.can` 합계로 명확히 고정해야 한다.
- V2 패키지에는 API 조립 계층과 도메인 조회 계층 분리, 공통 오디오 카드 DTO, 차단/성인 콘텐츠/공개 콘텐츠 필터, 시리즈 정렬 패턴이 이미 있으므로 재사용 범위를 먼저 명시해야 한다.
---
## 3. Goals
- 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
- 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다.
- 공개된 콘텐츠만 조회한다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다.
- 비회원은 19금 콘텐츠를 노출하지 않는다.
- 인증 회원은 기존 콘텐츠 조회 설정에 따라 19금 콘텐츠 노출 가능 여부를 반영한다.
- 전체 콘텐츠 개수와 페이징 목록을 함께 응답한다.
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
- `SERIES` 구분은 legacy 시리즈 메인 요일별 조회와 동일하게 요일 선택을 지원한다.
- PRD에 API endpoint와 Response data class 초안을 포함한다.
---
## 4. Non-Goals
- 기존 `content.main.tab.*` legacy API 스키마를 변경하지 않는다.
- 기존 메인 콘텐츠 추천 탭 API와 랭킹 탭 API의 공개 스키마를 변경하지 않는다.
- 기존 크리에이터 채널 오디오/시리즈 탭 API의 endpoint, 응답 필드, 인증 정책을 변경하지 않는다.
- 신규 스냅샷 테이블이나 배치 집계는 이번 범위에 포함하지 않는다.
- 개인화 추천, 랜덤 노출, 운영자 고정/제외 기능은 포함하지 않는다.
- 구매, 대여, 소장, 포인트 결제 API는 포함하지 않는다.
- `ContentSort` enum에 신규 값을 추가하지 않는다.
- `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않는다.
---
## 5. Target Users
- 회원: 메인 콘텐츠 전체 탭에서 원하는 구분의 공개 콘텐츠를 정렬해 탐색하는 사용자
- 비회원: 인증 없이 조회 가능한 공개 콘텐츠를 탐색하는 사용자
- 앱 클라이언트: 전체 탭의 구분, 전체 개수, 정렬 상태, 페이징 목록을 단일 계약으로 구성하려는 클라이언트
---
## 6. User Stories
- 사용자는 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다.
- 사용자는 선택한 요일의 시리즈 목록을 보고 싶다.
- 사용자는 오리지널 시리즈만 따로 보고 싶다.
- 사용자는 무료 오디오만 따로 보고 싶다.
- 사용자는 포인트를 사용할 수 있는 오디오만 따로 보고 싶다.
- 앱 클라이언트는 현재 적용된 구분, 정렬, page, size, hasNext를 응답에서 확인해 화면 상태와 서버 결과를 맞추고 싶다.
---
## 7. Core Features
### Feature A. 메인 콘텐츠 전체 탭 조회 API
#### Requirements
- 신규 API endpoint는 `GET /api/v2/audio/contents`를 기본안으로 한다.
- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다.
- 비회원 조회를 허용한다.
- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다.
- 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다.
- `type` 값은 아래 enum으로 정의한다.
- `AUDIO`: 오디오
- `SERIES`: 시리즈
- `ORIGINAL`: 오리지널
- `FREE`: 무료
- `POINT`: 포인트
- `type`을 보내지 않으면 `AUDIO`를 기본값으로 사용한다.
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
- 전체 탭에서 지원하는 정렬 값은 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`다.
- `OWNED`가 들어오면 전체 탭 요구사항에 없는 정렬이므로 `LATEST`로 fallback한다.
- `dayOfWeek``type=SERIES`일 때만 적용한다.
- `dayOfWeek` 값은 legacy `SeriesMainController.getDayOfWeekSeriesList(...)`와 동일하게 `SeriesPublishedDaysOfWeek` enum 값을 사용한다.
- `dayOfWeek` 지원 값은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`이다.
- `dayOfWeek`를 보내지 않으면 전체 요일의 시리즈를 조회한다.
- `type``SERIES`가 아니면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서는 `null`로 내려준다.
- `page`는 0부터 시작하는 page index로 처리한다.
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
- `page`가 0보다 작으면 `0`으로 fallback한다.
- `size`가 20보다 작으면 `20`으로 fallback한다.
- `size`가 50보다 크면 `50`으로 fallback한다.
- 응답에는 같은 필터 조건의 전체 콘텐츠 개수와 현재 page 목록을 포함한다.
- 다음 page 존재 여부는 `size + 1`개 조회 또는 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
#### Edge Cases
- 공개된 콘텐츠가 없으면 `totalCount``0`, 목록은 빈 배열, `hasNext``false`로 내려준다.
- 요청한 page 범위에 콘텐츠가 없으면 목록은 빈 배열, `hasNext``false`로 내려주되 `totalCount`는 전체 개수를 유지한다.
- 특정 구분에서 지원하지 않는 응답 목록 필드는 빈 배열로 내려준다.
### Feature B. 공통 공개/차단/성인 콘텐츠 정책
#### Requirements
- 모든 구분은 공개 가능한 콘텐츠만 조회한다.
- 오디오 콘텐츠는 `isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터 조건을 만족해야 한다.
- 시리즈는 `isActive == true`, 활성 크리에이터 조건을 만족해야 한다.
- 시리즈의 콘텐츠 통계와 정렬 대표값은 공개 가능한 오디오 콘텐츠만 기준으로 계산한다.
- 회원이 차단했거나 회원을 차단한 크리에이터의 오디오/시리즈는 제외한다.
- 비회원은 19금 오디오/시리즈를 제외한다.
- 인증 회원은 `MemberContentPreferenceService`의 기존 성인 콘텐츠 노출 가능 여부를 반영한다.
- 이미지 경로는 기존 `v2.common.domain.CdnUrlExtensions`의 CDN URL 변환 패턴을 따른다.
#### Edge Cases
- 차단 관계가 있는 크리에이터의 시리즈에 속한 오디오도 조회 대상에서 제외한다.
- 예약 공개 전 오디오는 모든 구분의 목록, 개수, 정렬 대표값, 매출 집계에서 제외한다.
- 비활성 크리에이터의 콘텐츠는 모든 구분에서 제외한다.
### Feature C. 오디오 구분
#### Requirements
- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 공개 오디오 콘텐츠를 조회한다.
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 오디오 콘텐츠 개수다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
- 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다.
#### Edge Cases
- 시리즈에 속하지 않은 오디오도 목록에 포함한다.
- 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다.
### Feature D. 시리즈 구분
#### Requirements
- `type=SERIES`는 차단 관계가 아닌 모든 크리에이터의 요일별 시리즈 콘텐츠를 조회한다.
- 활성 시리즈를 조회 대상으로 한다.
- `dayOfWeek`가 있으면 `series.publishedDaysOfWeek`에 해당 값이 포함된 시리즈만 조회한다.
- 요일 필터는 legacy `GET /audio-content/series/main/day-of-week`와 동일하게 query parameter 이름 `dayOfWeek``SeriesPublishedDaysOfWeek` enum 값을 사용한다.
- `dayOfWeek`가 없으면 요일 조건 없이 전체 시리즈를 조회한다.
- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 시리즈 개수다.
- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다.
- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
- 응답 최상위 `dayOfWeek`에는 실제 적용된 요일 값을 내려준다.
#### Edge Cases
- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함할 수 있다.
- `dayOfWeek=RANDOM` 요청은 legacy와 동일하게 `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 시리즈만 조회한다.
- `dayOfWeek`가 지원 enum 값이 아니면 400 오류 대신 요일 조건을 적용하지 않는 fallback을 기본안으로 한다.
### Feature E. 오리지널 구분
#### Requirements
- `type=ORIGINAL`은 차단 관계가 아닌 모든 크리에이터의 `isOriginal == true`인 시리즈를 조회한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `SERIES`와 동일하다.
- 단, `dayOfWeek` 요일 필터는 `type=ORIGINAL`에 적용하지 않는다.
- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다.
#### Edge Cases
- 오리지널 시리즈에 공개 가능한 오디오 콘텐츠가 없어도 활성 시리즈이면 목록에 포함한다.
- 19금 오리지널 시리즈는 조회자의 성인 콘텐츠 노출 가능 여부를 따른다.
### Feature F. 무료 구분
#### Requirements
- `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다.
- 무료 오디오는 `price == 0`인 공개 오디오로 정의한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases
- 무료 콘텐츠의 `PRICE_HIGH``PRICE_LOW`는 가격이 모두 0일 수 있으므로 2차/3차 정렬인 `releaseDate desc`, `id desc`가 실제 순서를 결정할 수 있다.
### Feature G. 포인트 구분
#### Requirements
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases
- 포인트 사용 가능 여부는 결제 가능 여부 필터일 뿐이며, 인기순 매출 산식에는 포인트 사용액을 포함하지 않는다.
### Feature H. 콘텐츠 정렬
#### Requirements
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
- 공개 요청/응답 값은 다음을 사용한다.
- `LATEST`: 최신순, 기본값
- `POPULAR`: 인기순
- `PRICE_HIGH`: 높은 가격순
- `PRICE_LOW`: 낮은 가격순
- `LATEST``releaseDate desc`, `id desc` 순으로 정렬한다.
- `POPULAR`은 인기순 매출 내림차순, `releaseDate desc`, `id desc` 순으로 정렬한다.
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 한다.
- 인기순 매출에는 포인트 사용액(`orders.point`)을 포함하지 않는다.
- 인기순 매출에는 `orders.isActive == true`인 주문만 포함한다.
- `PRICE_HIGH``price desc`, `releaseDate desc`, `id desc` 순으로 정렬한다.
- `PRICE_LOW``price asc`, `releaseDate desc`, `id desc` 순으로 정렬한다.
- 시리즈 정렬에서 `releaseDate`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다.
- 시리즈 정렬에서 `price desc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 높은 가격을 대표값으로 사용한다.
- 시리즈 정렬에서 `price asc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 낮은 가격을 대표값으로 사용한다.
- 시리즈 인기순 매출은 시리즈에 속한 공개 오디오 콘텐츠의 `orders.can` 합계를 사용한다.
#### Edge Cases
- 매출이 없는 오디오 또는 시리즈의 인기순 매출값은 0으로 처리한다.
- 콘텐츠가 없는 시리즈는 정렬 대표값이 없는 항목으로 처리해 같은 정렬 내 마지막에 노출한다.
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
---
## 8. API Endpoint
```http
GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20
Authorization: Bearer {accessToken} (optional)
```
- 비회원 조회를 허용한다.
- `SecurityConfig``GET /api/v2/audio/contents` permitAll 설정을 추가한다.
- `type` 미지정 시 `AUDIO`를 기본값으로 사용한다.
- `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다.
- `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다.
- 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20`
- `page`, `size`는 기존 크리에이터 채널 오디오/시리즈 탭과 같은 보정 정책을 따른다.
---
## 9. Response Data Class
```kotlin
data class MainContentAllTabResponse(
val type: MainContentAllType,
val totalCount: Int,
val audios: List<MainContentAudioResponse>,
val series: List<MainContentSeriesResponse>,
val sort: ContentSort,
val dayOfWeek: SeriesPublishedDaysOfWeek?,
val page: Int,
val size: Int,
@JsonProperty("hasNext")
val hasNext: Boolean
)
enum class MainContentAllType {
AUDIO,
SERIES,
ORIGINAL,
FREE,
POINT
}
data class MainContentAudioResponse(
val audioContentId: Long,
val title: String,
val imageUrl: String?,
val price: Int,
@JsonProperty("isAdult")
val isAdult: Boolean,
@JsonProperty("isPointAvailable")
val isPointAvailable: Boolean,
@JsonProperty("isFirstContent")
val isFirstContent: Boolean,
@JsonProperty("isOriginalSeries")
val isOriginalSeries: Boolean,
val creatorNickname: String
)
data class MainContentSeriesResponse(
val seriesId: Long,
val title: String,
val coverImageUrl: String?,
val creatorNickname: String,
@JsonProperty("isOriginal")
val isOriginal: Boolean,
@JsonProperty("isAdult")
val isAdult: Boolean
)
```
---
## 10. Technical Constraints
### 패키지 구조
- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.all` 하위에 둔다.
- Controller: `...adapter.in.web`
- Facade: `...application`
- Response DTO: `...dto`
- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.all` 하위에 둔다.
- Query service: `...application`
- 조회 정책/domain model: `...domain`
- 조회 port: `...port.out`
- QueryDSL/JPA 구현: `...adapter.out.persistence`
- 의존 방향은 `v2.api.content.all -> v2.content.all`만 허용한다.
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
### V2 공통화/재사용 대상
- `v2.common.domain.ContentSort`: 정렬 enum 재사용
- `creator.admin.content.series.SeriesPublishedDaysOfWeek`: legacy와 같은 요일 query parameter enum 재사용
- `content.series.main.SeriesMainController.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 API 계약 참고
- `content.series.ContentSeriesService.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 service 흐름 참고
- `v2.api.content.recommendation.adapter.in.web.AudioRecommendationController`: 비회원 허용 controller와 `ApiResponse.ok(...)` 패턴
- `v2.api.content.recommendation.application.AudioRecommendationFacade`: API 조립 계층에서 domain 결과를 response DTO로 변환하는 패턴
- `v2.content.recommendation.application.AudioRecommendationQueryService`: 회원 성인 콘텐츠 노출 가능 여부 계산과 전체 추천 조회 service 흐름
- `v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepository`: 전체 공개 오디오 조회, 차단 크리에이터 제외, CDN URL 변환, `AudioCard` 조립 패턴
- `v2.api.content.recommendation.dto.AudioCardResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴
- `v2.api.creator.channel.series.dto.CreatorChannelSeriesResponse`: 시리즈 응답 필드와 `JsonProperty` 네이밍 패턴 참고
- `v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy`: `sort`, `page`, `size` fallback 정책 참고
- `v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepository`: 시리즈 정렬 대표값, 시리즈 콘텐츠 통계, `orders.can` 매출 합산 패턴 참고
- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수
- `MemberContentPreferenceService`: 성인 콘텐츠 노출 가능 여부 판단
- `LangContext`: 시리즈 제목 다국어 처리
### 구현 주의사항
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.
- 기존 채널 오디오/시리즈 탭의 `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않으므로 전체 탭 policy에서 제외하거나 `LATEST`로 fallback한다.
- `POPULAR` 정렬은 기존 채널 탭 코드와 유사하되, 명시적으로 `orders.point`를 더하지 않고 `orders.can`만 집계한다.
- 오디오와 시리즈가 다른 응답 item 구조를 가지므로 최상위 응답은 `audios``series`를 분리한다.
- 신규 Entity나 DDL은 필요하지 않다.
---
## 11. Metrics
- 전체 탭 API 성공/실패 건수
- 전체 탭 API 응답 시간
- `type`별 조회 건수
- `sort`별 조회 건수
- 추가 로딩 요청 건수
---
## 12. Open Questions
- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.