docs(creator-channel): 오디오 탭 API 계획을 기록한다
This commit is contained in:
265
docs/20260619_크리에이터_채널_오디오_탭_API/prd.md
Normal file
265
docs/20260619_크리에이터_채널_오디오_탭_API/prd.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# PRD: 크리에이터 채널 오디오 탭 API
|
||||
|
||||
## 1. Overview
|
||||
크리에이터 채널의 오디오 탭에서 테마 목록, 정렬별 오디오 콘텐츠 개수, 소장률, 오디오 콘텐츠 목록을 한 번에 조회하는 API를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- 크리에이터 채널 오디오 탭은 테마 필터, 정렬 상태, 콘텐츠 개수, 소장률, 콘텐츠 목록을 함께 표시해야 한다.
|
||||
- 기존 라이브 탭 API는 `다시듣기` 콘텐츠에 한정되어 있고, 오디오 탭은 전체 오디오 콘텐츠와 선택한 테마별 콘텐츠를 조회해야 한다.
|
||||
- 클라이언트는 오디오 탭 진입 시 테마 리스트와 콘텐츠 목록을 별도 API 조합 없이 일관된 계약으로 받아야 한다.
|
||||
- 테마명은 호출하는 유저의 언어코드에 따라 한글, 영문, 일본어로 표시되어야 한다.
|
||||
- 기존 API endpoint와 응답 필드의 의미는 변경하지 않아야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 크리에이터 채널 오디오 탭 조회 API를 제공한다.
|
||||
- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 하위 조립 계층에 둔다.
|
||||
- 오디오 리스트, 오디오 개수, 소장률 계산, 테마 조회처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다.
|
||||
- 요청은 `creatorId`, 정렬 순서, 테마를 받는다.
|
||||
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
|
||||
- 테마를 보내지 않으면 전체 테마의 오디오 콘텐츠를 조회한다.
|
||||
- 응답에는 오디오 콘텐츠 개수, 유료 콘텐츠 개수, 호출자가 구매한 콘텐츠 개수, 호출자가 구매한 콘텐츠 개수의 비율, 크리에이터의 콘텐츠 목록, 실제 적용된 정렬 순서, 테마 목록을 포함한다.
|
||||
- 콘텐츠 목록 item은 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 사용한다.
|
||||
- 오디오 콘텐츠 목록은 라이브 탭의 `다시듣기` 목록과 같은 조회/정렬/소장 상태 의미를 따르되, 시리즈 이름이 표시되어야 한다.
|
||||
- 테마 목록은 테마 id와 호출 유저 언어코드에 맞는 테마명을 내려준다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 이번 범위는 크리에이터 채널 `오디오` 탭 조회 API만 포함한다.
|
||||
- 기존 크리에이터 채널 홈 API, 라이브 탭 API endpoint와 응답 필드의 의미는 변경하지 않는다.
|
||||
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
|
||||
- 오디오 콘텐츠 생성/수정/삭제 API는 포함하지 않는다.
|
||||
- 테마 관리 화면, 테마 생성/수정/삭제 API, 테마 번역 관리 API는 포함하지 않는다.
|
||||
- 테마 번역 데이터가 없는 항목을 새로 번역하거나 생성하는 배치 작업은 포함하지 않는다.
|
||||
- 앱 표시용 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원: 크리에이터 채널 오디오 탭에서 크리에이터의 오디오 콘텐츠를 테마별로 탐색하는 사용자
|
||||
- 앱 클라이언트: 오디오 탭 구성에 필요한 테마/개수/소장률/목록 데이터를 단일 API 응답으로 표시하려는 클라이언트
|
||||
- 크리에이터: 자신의 오디오 콘텐츠가 테마와 정렬 기준에 따라 적절히 노출되기를 원하는 사용자
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 크리에이터 채널 오디오 탭에 들어가면 전체 오디오 콘텐츠 개수를 확인하고 싶다.
|
||||
- 사용자는 테마를 선택해 특정 테마의 오디오 콘텐츠만 보고 싶다.
|
||||
- 사용자는 최신순, 인기순, 소장순, 높은 가격순, 낮은 가격순으로 오디오 콘텐츠를 바꿔 보고 싶다.
|
||||
- 사용자는 유료 콘텐츠 중 자신이 구매한 콘텐츠 비율을 확인하고 싶다.
|
||||
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
|
||||
- 앱 클라이언트는 호출 유저 언어코드에 맞는 테마명을 받아 화면에 표시하고 싶다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 크리에이터 채널 오디오 탭 조회 API
|
||||
|
||||
#### Requirements
|
||||
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
|
||||
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/audio`를 기본안으로 한다.
|
||||
- `creatorId`는 path variable로 받는다.
|
||||
- 정렬 순서는 query parameter로 받는다.
|
||||
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
|
||||
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
|
||||
- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다.
|
||||
- 테마는 query parameter로 받는다.
|
||||
- 테마 query parameter 이름은 `themeId`를 기본안으로 한다.
|
||||
- `themeId`를 보내지 않으면 전체 테마의 오디오 콘텐츠를 조회한다.
|
||||
- 오디오 콘텐츠 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
||||
- `page`는 0부터 시작하는 page index로 처리한다.
|
||||
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
||||
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
||||
- `page`가 0보다 작으면 `0`으로 fallback한다.
|
||||
- `size`가 20보다 작으면 `20`으로 fallback한다.
|
||||
- `size`가 50보다 크면 `50`으로 fallback한다.
|
||||
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
||||
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
||||
- 조회자와 크리에이터 사이에 차단 관계가 있으면 기존 크리에이터 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
||||
- 공개된 오디오 콘텐츠가 없어도 전체 API는 성공 처리한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
||||
- 알 수 없는 `sort` 값은 400 오류를 반환하지 않고 `LATEST`로 fallback한다.
|
||||
- `themeId`가 존재하지 않거나 비활성 테마이면 오류를 반환하지 않고 전체 테마 조회로 fallback한다.
|
||||
- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다.
|
||||
|
||||
### Feature B. 응답 스키마
|
||||
|
||||
#### Requirements
|
||||
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
||||
- 응답 최상위 DTO 이름은 `CreatorChannelAudioTabResponse`를 기본안으로 한다.
|
||||
- 응답에는 다음 값을 포함한다.
|
||||
- `audioContentCount`: 선택한 테마 필터를 적용한 오디오 콘텐츠 전체 개수
|
||||
- `paidAudioContentCount`: 선택한 테마 필터를 적용한 유료 오디오 콘텐츠 전체 개수
|
||||
- `purchasedAudioContentCount`: 선택한 테마 필터를 적용한 유료 오디오 콘텐츠 중 호출자가 구매한 콘텐츠 개수
|
||||
- `purchasedAudioContentRate`: `paidAudioContentCount` 대비 `purchasedAudioContentCount`의 퍼센트 값
|
||||
- `themes`: 활성 테마 목록
|
||||
- `audioContents`: 오디오 콘텐츠 목록
|
||||
- `sort`: 콘텐츠 조회에 실제 적용한 정렬 순서
|
||||
- `themeId`: 콘텐츠 조회에 실제 적용한 테마 id, 전체 조회이면 `null`
|
||||
- `page`: 현재 응답의 page index
|
||||
- `size`: 현재 응답의 page size
|
||||
- `hasNext`: 다음 page 존재 여부
|
||||
- `audioContents`의 각 item은 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 사용한다.
|
||||
- `sort`는 요청값이 없거나 알 수 없는 값이면 실제 적용값인 `LATEST`를 내려준다.
|
||||
- `themeId`는 요청값이 없거나 비활성/미존재 테마라 전체 조회로 fallback하면 `null`을 내려준다.
|
||||
- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다.
|
||||
- `hasNext`는 같은 필터/정렬 조건에서 다음 page에 노출할 오디오 콘텐츠가 있으면 `true`로 내려준다.
|
||||
- `purchasedAudioContentRate`는 `paidAudioContentCount == 0`이면 `0.0`으로 내려준다.
|
||||
- `purchasedAudioContentRate`는 `(purchasedAudioContentCount / paidAudioContentCount) * 100`을 기준으로 계산한 퍼센트 값으로 내려준다.
|
||||
- 응답 스키마 예시는 다음과 같다.
|
||||
|
||||
```kotlin
|
||||
data class CreatorChannelAudioTabResponse(
|
||||
val audioContentCount: Int,
|
||||
val paidAudioContentCount: Int,
|
||||
val purchasedAudioContentCount: Int,
|
||||
val purchasedAudioContentRate: Double,
|
||||
val themes: List<CreatorChannelAudioThemeResponse>,
|
||||
val audioContents: List<CreatorChannelAudioContentResponse>,
|
||||
val sort: ContentSort,
|
||||
val themeId: Long?,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class CreatorChannelAudioThemeResponse(
|
||||
val themeId: Long,
|
||||
val themeName: String
|
||||
)
|
||||
|
||||
data class CreatorChannelAudioContentResponse(
|
||||
val audioContentId: Long,
|
||||
val title: String,
|
||||
val duration: String?,
|
||||
val imageUrl: String?,
|
||||
val price: Int,
|
||||
val isAdult: Boolean,
|
||||
val isPointAvailable: Boolean,
|
||||
val isFirstContent: Boolean,
|
||||
val seriesName: String?,
|
||||
val isOriginalSeries: Boolean?,
|
||||
val isOwned: Boolean,
|
||||
val isRented: Boolean
|
||||
)
|
||||
|
||||
enum class ContentSort {
|
||||
LATEST,
|
||||
POPULAR,
|
||||
OWNED,
|
||||
PRICE_HIGH,
|
||||
PRICE_LOW
|
||||
}
|
||||
```
|
||||
|
||||
#### Edge Cases
|
||||
- 공개된 오디오 콘텐츠가 없으면 `audioContentCount`, `paidAudioContentCount`, `purchasedAudioContentCount`는 `0`, `purchasedAudioContentRate`는 `0.0`, `audioContents`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
||||
- 요청한 page 범위에 콘텐츠가 없으면 `audioContents`는 빈 배열, `hasNext`는 `false`로 내려주되 개수 필드는 전체 개수를 유지한다.
|
||||
|
||||
### Feature C. 테마 목록
|
||||
|
||||
#### Requirements
|
||||
- 테마 목록은 `AudioContentTheme.isActive == true`인 테마만 내려준다.
|
||||
- 테마 목록은 기존 테마 정렬 정책인 `AudioContentTheme.orders`를 따른다.
|
||||
- 테마 응답은 테마 id와 테마명을 포함한다.
|
||||
- 테마명은 호출하는 유저의 언어코드에 따라 한글, 영문, 일본어로 반환한다.
|
||||
- 호출 유저 언어코드는 기존 `LangContext.lang.code` 값을 사용한다.
|
||||
- 지원 언어코드는 `ko`, `en`, `ja`를 기준으로 한다.
|
||||
- `ko`는 `AudioContentTheme.theme` 원문을 기본으로 사용한다.
|
||||
- `en`, `ja`는 `ContentThemeTranslation.locale`에 해당하는 번역값이 있으면 해당 `theme`을 사용한다.
|
||||
- 요청 언어의 번역값이 없으면 `AudioContentTheme.theme` 원문을 fallback으로 사용한다.
|
||||
- 테마 목록은 선택한 `themeId`와 무관하게 활성 테마 전체를 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- 활성 테마가 없으면 `themes`는 빈 배열로 내려준다.
|
||||
- 번역 데이터는 있지만 빈 문자열이면 원문 테마명을 fallback으로 사용한다.
|
||||
|
||||
### Feature D. 오디오 콘텐츠 목록과 개수
|
||||
|
||||
#### Requirements
|
||||
- 조회 대상은 지정한 `creatorId`의 오디오 콘텐츠로 제한한다.
|
||||
- 공개된 콘텐츠만 조회한다.
|
||||
- 예약 공개 전 콘텐츠는 포함하지 않는다.
|
||||
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 조회에서 제외한다.
|
||||
- `themeId`가 있고 활성 테마이면 해당 테마의 오디오 콘텐츠만 조회한다.
|
||||
- `themeId`가 없거나 비활성/미존재 테마이면 전체 활성 테마의 오디오 콘텐츠를 조회한다.
|
||||
- 콘텐츠 목록은 `page`, `size` 기준으로 페이징 조회한다.
|
||||
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
||||
- 오디오 콘텐츠 개수는 목록 조회와 같은 공개 여부, 예약 공개, 성인 콘텐츠, 차단 정책, 테마 필터를 적용해 계산한다.
|
||||
- 유료 콘텐츠 개수는 같은 필터를 적용한 콘텐츠 중 `price > 0`인 콘텐츠 개수로 계산한다.
|
||||
- 호출자가 구매한 콘텐츠 개수는 같은 필터를 적용한 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수로 계산한다.
|
||||
- 대여 중인 콘텐츠는 호출자가 구매한 콘텐츠 개수와 `purchasedAudioContentRate` 계산에 포함한다.
|
||||
- 조회자의 성인 콘텐츠 노출 정책이 false이면 성인 콘텐츠는 목록과 개수에서 제외한다.
|
||||
- 응답 item 필드는 기존 `CreatorChannelAudioContentResponse`와 같은 의미를 유지한다.
|
||||
- `seriesName`은 콘텐츠가 속한 시리즈 이름을 내려준다.
|
||||
- 시리즈 이름은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다.
|
||||
- `isFirstContent`, `seriesName`, `isOriginalSeries`, 구매/대여/포인트 사용 가능 여부의 의미는 기존 오디오 콘텐츠/시리즈 콘텐츠 응답과 동일하게 유지한다.
|
||||
- `isFirstContent`는 선택한 테마 안에서 첫 콘텐츠인지가 아니라, 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 시리즈에 속하지 않은 콘텐츠는 `seriesName`, `isOriginalSeries`를 `null`로 내려준다.
|
||||
- 무료 콘텐츠는 구매한 콘텐츠 개수와 구매 비율 계산에서 제외한다.
|
||||
- 일반적으로 `isOwned == true`와 `isRented == true`가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
|
||||
|
||||
### Feature E. 콘텐츠 정렬
|
||||
|
||||
#### Requirements
|
||||
- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다.
|
||||
- 공개 요청/응답 값은 다음을 사용한다.
|
||||
- `LATEST`: 최신순, 기본값
|
||||
- `POPULAR`: 인기순
|
||||
- `OWNED`: 소장순
|
||||
- `PRICE_HIGH`: 높은 가격순
|
||||
- `PRICE_LOW`: 낮은 가격순
|
||||
- `LATEST`는 공개일 최신순을 1차 정렬로 사용한다.
|
||||
- `LATEST`의 2차 정렬은 높은 가격순이다.
|
||||
- `LATEST`의 3차 정렬은 `audioContent.id desc`다.
|
||||
- `POPULAR`은 매출이 많은 콘텐츠를 먼저 노출한다.
|
||||
- `OWNED`는 조회자가 소장 또는 대여 중인 콘텐츠를 먼저 노출한다.
|
||||
- `PRICE_HIGH`는 가격이 높은 콘텐츠를 먼저 노출한다.
|
||||
- `PRICE_LOW`는 가격이 낮은 콘텐츠를 먼저 노출한다.
|
||||
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 2차 정렬은 최신순이다.
|
||||
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 3차 정렬은 `audioContent.id desc`다.
|
||||
- 최신순 기준에 사용하는 날짜는 기존 `CreatorChannelAudioContentResponse` 목록 정책과 동일하게 공개 시각(`releaseDate`)을 기준으로 한다.
|
||||
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 하며, 포인트 사용액은 매출 기준에 포함하지 않는다.
|
||||
- 환불되었거나 비활성 처리된 구매 내역은 기존 콘텐츠 구매/매출 정책과 동일하게 제외한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 매출이 없는 콘텐츠의 인기순 매출값은 0으로 처리한다.
|
||||
- 조회자가 소장 또는 대여 중인 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `audioContent.id desc` 보조 정렬과 같은 결과가 될 수 있다.
|
||||
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 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.audio` 하위에 둔다.
|
||||
- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다.
|
||||
- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.audio` 또는 재사용 범위가 더 넓은 기존 도메인 패키지 하위에 둔다.
|
||||
- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다.
|
||||
- 기존 라이브 탭의 `CreatorChannelAudioContentResponse`와 필드/의미가 어긋나지 않도록 오디오 탭 응답 DTO를 작성한다.
|
||||
- 기존 `ContentSort` enum을 재사용하고, API binding, service 정책, 테스트에서 같은 타입을 사용한다.
|
||||
- 기존 크리에이터 채널 홈/라이브 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용한다.
|
||||
- 페이징 응답은 기존 라이브 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다.
|
||||
- 테마명 다국어 처리는 기존 `LangContext`, `ContentThemeTranslation` 구조를 따른다.
|
||||
- 시리즈명 다국어 처리는 기존 `SeriesTranslation` 구조를 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Metrics
|
||||
- 오디오 탭 API 성공/실패 건수
|
||||
- 오디오 탭 API 응답 시간
|
||||
- 테마별 조회 건수
|
||||
- 정렬 기준별 조회 건수
|
||||
- 오디오 탭에서 콘텐츠 추가 로딩 요청 건수
|
||||
Reference in New Issue
Block a user