From c6b6c16e1211def6344f736e9c7c8a13cf980495 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Jun 2026 14:02:42 +0900 Subject: [PATCH] =?UTF-8?q?docs(creator-channel):=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=ED=83=AD=20API=20=EA=B3=84=ED=9A=8D=EC=9D=84=20?= =?UTF-8?q?=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 | 519 ++++++++++++++++++ .../prd.md | 265 +++++++++ 2 files changed, 784 insertions(+) create mode 100644 docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md create mode 100644 docs/20260619_크리에이터_채널_오디오_탭_API/prd.md diff --git a/docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md b/docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md new file mode 100644 index 00000000..5730f90a --- /dev/null +++ b/docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md @@ -0,0 +1,519 @@ +# 크리에이터 채널 오디오 탭 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}/audio`로 크리에이터 채널 오디오 탭의 테마 목록, 콘텐츠 개수, 소장률, 오디오 콘텐츠 목록을 조회할 수 있게 한다. + +**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.audio` 조립 계층에 둔다. 오디오 탭 조회 service, 순수 fallback/page 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.audio` 하위에 두고 `v2.api.*`에 의존하지 않는다. 라이브 탭에서 만든 `ContentSort`와 오디오 콘텐츠 응답 의미는 재사용하되, `sort/page/size/themeId` fallback 정책은 오디오 탭 전용 정책으로 명시한다. + +**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}/audio` +- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다. +- request: + - path variable: `creatorId` + - query parameter: `sort`, `required = false`, 기본값/fallback `LATEST` + - query parameter: `themeId`, `required = false`, 없거나 비활성/미존재이면 전체 활성 테마 조회 + - query parameter: `page`, `required = false`, 기본값 `0`, `page < 0`이면 `0`으로 fallback + - query parameter: `size`, `required = false`, 기본값 `20`, `size < 20`이면 `20`, `size > 50`이면 `50`으로 fallback +- controller는 invalid `sort` fallback을 위해 `sort: String?`으로 받고 service/facade 경계에서 `ContentSort`로 보정한다. +- response: + - `audioContentCount`: 적용된 필터 기준 오디오 콘텐츠 전체 개수 + - `paidAudioContentCount`: 적용된 필터 기준 `price > 0` 콘텐츠 개수 + - `purchasedAudioContentCount`: 적용된 필터 기준 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수 + - `purchasedAudioContentRate`: `paidAudioContentCount == 0`이면 `0.0`, 아니면 `(purchasedAudioContentCount / paidAudioContentCount) * 100` + - `themes`: 활성 테마 전체 목록. 선택한 `themeId`와 무관하게 내려준다. + - `audioContents`: 기존 `CreatorChannelAudioContentResponse`와 같은 필드/의미를 가진 item 목록 + - `sort`: 실제 적용한 `ContentSort` + - `themeId`: 실제 적용한 활성 테마 id, 전체 조회 fallback이면 `null` + - `page`: fallback 보정 후 실제 적용된 page index + - `size`: fallback 보정 후 실제 적용된 page size + - `hasNext`: 다음 page 존재 여부 +- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`. +- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다. +- 테마명은 `LangContext.lang.code` 기준으로 `ContentThemeTranslation`을 우선하고, 없거나 빈 문자열이면 `AudioContentTheme.theme` 원문으로 fallback한다. +- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다. +- `isFirstContent`는 선택 테마 안의 첫 콘텐츠가 아니라 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠인지로 판단한다. +- 정렬: + - `LATEST`: `releaseDate desc`, `price desc`, `audioContent.id desc` + - `POPULAR`: 구매 매출 합계 desc, `releaseDate desc`, `audioContent.id desc` + - `OWNED`: 조회자 소장 또는 유효 대여 여부 desc, `releaseDate desc`, `audioContent.id desc` + - `PRICE_HIGH`: `price desc`, `releaseDate desc`, `audioContent.id desc` + - `PRICE_LOW`: `price asc`, `releaseDate desc`, `audioContent.id desc` +- 인기순 매출은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출인 `orders.can` 합계를 사용한다. `orders.point`는 포함하지 않고, `orders.is_active = true`인 주문만 포함한다. + +--- + +## 1. 파일 구조 계획 + +### 오디오 탭 신규 API 조립 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt` + +### 오디오 탭 도메인 조회 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioTab.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicyTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt` + +### 기존 파일 확인/재사용 +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/theme/translation/ContentThemeTranslationRepository.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt` + +### 문서 산출물 +- Create: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md` +- Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/prd.md` + +--- + +## 2. Response data class 초안 + +구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다. + +```kotlin +package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioContent +import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTab +import kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioTheme + +data class CreatorChannelAudioTabResponse( + val audioContentCount: Int, + val paidAudioContentCount: Int, + val purchasedAudioContentCount: Int, + val purchasedAudioContentRate: Double, + val themes: List, + val audioContents: List, + val sort: ContentSort, + val themeId: Long?, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(tab: CreatorChannelAudioTab): CreatorChannelAudioTabResponse { + return CreatorChannelAudioTabResponse( + audioContentCount = tab.audioContentCount, + paidAudioContentCount = tab.paidAudioContentCount, + purchasedAudioContentCount = tab.purchasedAudioContentCount, + purchasedAudioContentRate = tab.purchasedAudioContentRate, + themes = tab.themes.map(CreatorChannelAudioThemeResponse::from), + audioContents = tab.audioContents.map(CreatorChannelAudioContentResponse::from), + sort = tab.sort, + themeId = tab.themeId, + page = tab.page.page, + size = tab.page.size, + hasNext = tab.hasNext + ) + } + } +} + +data class CreatorChannelAudioThemeResponse( + val themeId: Long, + val themeName: String +) { + companion object { + fun from(theme: CreatorChannelAudioTheme): CreatorChannelAudioThemeResponse { + return CreatorChannelAudioThemeResponse( + themeId = theme.themeId, + themeName = theme.themeName + ) + } + } +} + +data class CreatorChannelAudioContentResponse( + val audioContentId: Long, + val title: String, + val duration: String?, + val imageUrl: String?, + val price: Int, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isPointAvailable") + val isPointAvailable: Boolean, + @JsonProperty("isFirstContent") + val isFirstContent: Boolean, + val seriesName: String?, + @JsonProperty("isOriginalSeries") + val isOriginalSeries: Boolean?, + @JsonProperty("isOwned") + val isOwned: Boolean, + @JsonProperty("isRented") + val isRented: Boolean +) { + companion object { + fun from(content: CreatorChannelAudioContent): CreatorChannelAudioContentResponse { + return CreatorChannelAudioContentResponse( + audioContentId = content.audioContentId, + title = content.title, + duration = content.duration, + imageUrl = content.imageUrl, + price = content.price, + isAdult = content.isAdult, + isPointAvailable = content.isPointAvailable, + isFirstContent = content.isFirstContent, + seriesName = content.seriesName, + isOriginalSeries = content.isOriginalSeries, + isOwned = content.isOwned, + isRented = content.isRented + ) + } + } +} +``` + +--- + +## 3. Domain / Port 초안 + +구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다. + +```kotlin +package kr.co.vividnext.sodalive.v2.creator.channel.audio.domain + +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage + +data class CreatorChannelAudioTab( + val audioContentCount: Int, + val paidAudioContentCount: Int, + val purchasedAudioContentCount: Int, + val purchasedAudioContentRate: Double, + val themes: List, + val audioContents: List, + val sort: ContentSort, + val themeId: Long?, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelAudioTheme( + val themeId: Long, + val themeName: String +) + +data class CreatorChannelAudioContent( + 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 +) +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.creator.channel.audio.port.out + +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import java.time.LocalDateTime + +interface CreatorChannelAudioQueryPort { + fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelAudioCreatorRecord? + fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean + fun findActiveThemeId(themeId: Long): Long? + fun findAudioThemes(locale: String): List + fun countAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int + fun countPaidAudioContents(creatorId: Long, themeId: Long?, now: LocalDateTime, canViewAdultContent: Boolean): Int + fun countPurchasedAudioContents( + creatorId: Long, + viewerId: Long, + themeId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Int + fun findAudioContents( + creatorId: Long, + viewerId: Long, + themeId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean, + sort: ContentSort, + locale: String, + offset: Long, + limit: Int + ): List +} + +data class CreatorChannelAudioCreatorRecord( + val creatorId: Long, + val role: MemberRole, + val nickname: String +) + +data class CreatorChannelAudioThemeRecord( + val themeId: Long, + val themeName: String +) + +data class CreatorChannelAudioContentRecord( + val audioContentId: Long, + val title: String, + val duration: String?, + val imagePath: 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 +) +``` + +--- + +### Phase 1: 오디오 탭 정책과 domain 계약 + +- [ ] **Task 1.1: `CreatorChannelAudioQueryPolicy` fallback 정책 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicyTest.kt` + - RED: `createPage(-1, 10)`이 `page=0`, `size=20`을 반환하고, `createPage(2, 100)`이 `page=2`, `size=50`을 반환하며, `resolveSort(null)`과 `resolveSort("UNKNOWN")`이 `ContentSort.LATEST`를 반환하는 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest` + - Expected: `CreatorChannelAudioQueryPolicy` 미존재 컴파일 실패 + - GREEN: `resolveSort(sort: String?): ContentSort`, `createPage(page: Int?, size: Int?): CreatorChannelPage`, `limitItems`, `hasNext`, `purchaseRate`를 구현한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 라이브 탭의 `CreatorChannelLiveReplayQueryPolicy`는 변경하지 않는다. 오디오 탭만 fallback 정책을 가진다. + - 회귀 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicyTest` + - Expected: 기존 라이브 탭 정책 테스트가 있으면 통과한다. 테스트가 없으면 `No tests found`가 아닌 컴파일 실패가 없는지 확인한다. + +- [ ] **Task 1.2: 오디오 탭 domain model과 port 계약 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioTab.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt` + - RED: service 테스트 파일에 `CreatorChannelAudioTab`, `CreatorChannelAudioTheme`, `CreatorChannelAudioContent`, `CreatorChannelAudioQueryPort` import를 추가하고 아직 service가 없어서 컴파일 실패하는 상태를 만든다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` + - Expected: `CreatorChannelAudioQueryService` 또는 domain/port 미존재 컴파일 실패 + - GREEN: 위 "Domain / Port 초안"의 타입을 추가한다. `CreatorChannelPage`는 기존 `kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage`를 재사용한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: `rg -n "v2\\.api\\.creator\\.channel\\.audio" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio`로 domain/port가 API 조립 계층에 의존하지 않는지 확인한다. + +### Phase 2: 오디오 탭 service와 API DTO 변환 + +- [ ] **Task 2.1: `CreatorChannelAudioQueryService` orchestration 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryServiceTest.kt` + - RED: fake port 기반 service 테스트를 작성한다. + - `getAudioTab(creatorId=1, viewer, sort="UNKNOWN", themeId=999, page=-1, size=100)` 호출 시 실제 `sort=LATEST`, `themeId=null`, `page=0`, `size=50`, `offset=0`, `limit=51`이 port에 전달되어야 한다. + - `paidAudioContentCount=4`, `purchasedAudioContentCount=3`이면 `purchasedAudioContentRate=75.0`이어야 한다. + - `paidAudioContentCount=0`이면 `purchasedAudioContentRate=0.0`이어야 한다. + - `creator`가 없으면 `member.validation.user_not_found`, role이 `CREATOR`가 아니면 `member.validation.creator_not_found`, 차단 관계면 기존 차단 메시지 예외를 던져야 한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` + - Expected: `CreatorChannelAudioQueryService` 미존재 컴파일 실패 + - GREEN: 라이브 탭 service의 인증/차단/성인 콘텐츠 정책을 참고해 최소 구현한다. `LangContext.lang.code`를 theme/series 번역 조회 locale로 전달하고, `String?.toCdnUrl()`은 라이브 탭 service와 같은 규칙으로 구현한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: service가 QueryDSL/Q타입을 직접 import하지 않는지 확인한다. + - Run: `rg -n "Q[A-Z]|queryFactory|javax\\.persistence" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application` + - Expected: 검색 결과 없음 + +- [ ] **Task 2.2: 오디오 탭 API response DTO와 facade 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/dto/CreatorChannelAudioTabResponse.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacade.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/application/CreatorChannelAudioFacadeTest.kt` + - RED: facade가 service 결과를 `CreatorChannelAudioTabResponse`로 변환하고 `isOwned`, `isRented`, `hasNext`의 JSON property 의미를 보존하는 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest` + - Expected: facade/DTO 미존재 컴파일 실패 + - GREEN: 위 "Response data class 초안"에 맞춰 DTO를 추가하고 facade에서 `CreatorChannelAudioQueryService.getAudioTab(creatorId, viewer, sort, themeId, page, size, now)` 결과를 `CreatorChannelAudioTabResponse.from(tab)`으로 변환한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 기존 라이브 탭 DTO를 이동하거나 수정하지 않는다. + - Run: `rg -n "package kr\\.co\\.vividnext\\.sodalive\\.v2\\.api\\.creator\\.channel\\.live\\.dto" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/live/dto/CreatorChannelLiveTabResponse.kt` + - Expected: 기존 라이브 탭 DTO package 유지 + +### Phase 3: QueryDSL repository 구현 + +- [ ] **Task 3.1: repository skeleton과 creator/block/theme 조회 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/CreatorChannelAudioQueryRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt` + - RED: `@DataJpaTest(properties = ["spring.cache.type=none"])` 기반으로 `findCreator`, `existsBlockedBetween`, `findActiveThemeId`, `findAudioThemes(locale="en")` 테스트를 작성한다. `ContentThemeTranslation`이 있으면 번역명, 없으면 원문명을 반환해야 한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` + - Expected: repository 미존재 컴파일 실패 + - GREEN: 라이브 탭 repository의 `findCreator`, `existsBlockedBetween`을 오디오 패키지로 필요한 만큼 복사하고, `findActiveThemeId`, `findAudioThemes`를 QueryDSL로 구현한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: `ContentThemeTranslation.theme`이 blank인 경우 원문 fallback을 repository 또는 domain mapping 중 한 곳에서만 처리한다. + +- [ ] **Task 3.2: 오디오 콘텐츠 count와 소장률 count 구현** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt` + - RED: 아래 조건을 검증하는 repository 테스트를 추가한다. + - `countAudioContents`는 공개/활성/예약 공개/성인 콘텐츠 정책과 활성 `themeId` 필터를 적용한다. + - `countPaidAudioContents`는 같은 필터에서 `price > 0`만 계산한다. + - `countPurchasedAudioContents`는 유료 콘텐츠 중 `OrderType.KEEP` 또는 유효한 `OrderType.RENTAL` 주문을 가진 콘텐츠만 계산한다. + - 무료 콘텐츠는 구매 count와 소장률 count에서 제외한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` + - Expected: 신규 count method 미구현 실패 + - GREEN: 공통 `audioContentCondition(creatorId, themeId, now, canViewAdultContent)` private helper를 만들고 count query들이 같은 조건을 공유하게 구현한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 목록 query와 count query의 조건이 어긋나지 않도록 helper 사용 여부를 확인한다. + - Run: `rg -n "audioContentCondition|countAudioContents|countPaidAudioContents|countPurchasedAudioContents" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` + - Expected: 세 count method가 공통 조건 helper를 사용한다. + +- [ ] **Task 3.3: 오디오 콘텐츠 목록, 정렬, 시리즈 번역, 소장/대여 상태 구현** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt` + - RED: 아래 조건을 검증하는 repository 테스트를 추가한다. + - `findAudioContents`는 `size + 1`개 조회가 가능하도록 전달받은 `limit`을 그대로 사용한다. + - `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬이 PRD 기준으로 동작한다. + - `POPULAR`은 `orders.can` 합계 기준으로 정렬하고 비활성 주문은 제외한다. + - `OWNED`는 소장 또는 유효 대여 중인 콘텐츠를 먼저 노출한다. + - 시리즈에 속한 콘텐츠는 `SeriesTranslation(locale)`이 있으면 번역명을, 없으면 원문명을 `seriesName`으로 반환한다. + - `isFirstContent`는 테마 필터와 무관하게 크리에이터의 전체 공개 오디오 콘텐츠 중 첫 콘텐츠 기준이다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` + - Expected: 목록/정렬 method 미구현 실패 + - GREEN: 라이브 탭 repository의 `findLiveReplayAudioRows`, `audioSeriesByContentIds`, `orderStatesByContentIds`, `firstAudioContentId` 구조를 오디오 탭 범위에 맞춰 구현한다. `themeId == null`이면 전체 활성 테마를 대상으로 한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: QueryDSL 중복이 커지면 오디오 탭 repository 내부 private helper로만 정리하고, 라이브 탭 repository까지 건드리는 공용화는 이번 범위에서 하지 않는다. + +### Phase 4: Controller와 공개 API 계약 + +- [ ] **Task 4.1: `CreatorChannelAudioController` 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt` + - RED: MockMvc 테스트를 작성한다. + - 비회원 `GET /api/v2/creator-channels/1/audio`는 401을 반환한다. + - 인증 회원 기본 요청은 facade에 `sort=null`, `themeId=null`, `page=null`, `size=null`을 전달하고 성공 응답을 반환한다. + - `sort=INVALID&page=-1&size=100&themeId=999` 요청은 controller에서 400을 내지 않고 facade까지 원 요청값을 전달한다. + - 응답 JSON에는 `audioContentCount`, `paidAudioContentCount`, `purchasedAudioContentCount`, `purchasedAudioContentRate`, `themes`, `audioContents`, `sort`, `themeId`, `page`, `size`, `hasNext`, `audioContents[0].isOwned`, `audioContents[0].isRented`가 있어야 한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest` + - Expected: controller 미존재 컴파일 실패 + - GREEN: `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/audio")` controller를 추가한다. query parameter는 `@RequestParam(required = false) sort: String?`, `themeId: Long?`, `page: Int?`, `size: Int?`로 받는다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: 기존 `/live`, `/home` mapping과 충돌하지 않는지 확인한다. + - Run: `rg -n "@GetMapping\\(\"/\\{creatorId\\}/(home|live|audio)\"\\)" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel` + - Expected: home/live/audio 각각 1건 + +- [ ] **Task 4.2: 오디오 탭 통합 흐름 테스트 추가** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt` + - RED: `@SpringBootTest + MockMvc` 기반으로 인증 회원이 `GET /api/v2/creator-channels/{creatorId}/audio?sort=INVALID&page=-1&size=100&themeId=999`를 호출했을 때 200 성공과 fallback 적용 응답(`sort=LATEST`, `themeId=null`, `page=0`, `size=50`)을 받는 테스트를 작성한다. + - 실패 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest` + - Expected: endpoint 또는 fixture 미구현으로 실패 + - GREEN: 필요한 최소 fixture만 추가하고 controller, facade, service, repository wiring이 동작하도록 구현을 보완한다. + - 통과 확인: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioEndToEndTest` + - Expected: `BUILD SUCCESSFUL` + - REFACTOR: E2E fixture가 기존 테스트 데이터를 과도하게 공유하지 않는지 확인하고, 불필요한 데이터 생성 helper는 추가하지 않는다. + +### Phase 5: 회귀 검증과 문서 기록 + +- [ ] **Task 5.1: 관련 단위/슬라이스 테스트 회귀 실행** + - Files: + - Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md` + - TDD 예외 사유: 코드 구현이 아니라 구현 완료 후 검증 기록을 누적하는 문서 작업이다. + - 대체 검증 방법: + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.domain.CreatorChannelAudioQueryPolicyTest` + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.application.CreatorChannelAudioQueryServiceTest` + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.application.CreatorChannelAudioFacadeTest` + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.in.web.CreatorChannelAudioControllerTest` + - Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.audio.adapter.out.persistence.DefaultCreatorChannelAudioQueryRepositoryTest` + - Expected: 모두 `BUILD SUCCESSFUL` + - 검증 기록: 구현 완료 후 실행 명령, 결과, 실패 시 원인과 수정 내역을 이 task 아래에 누적한다. + +- [ ] **Task 5.2: 전체 회귀와 포맷 검증** + - Files: + - Verify: `docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md` + - TDD 예외 사유: 전체 회귀/포맷 검증 기록 task다. + - 대체 검증 방법: + - Run: `./gradlew test` + - Run: `./gradlew ktlintCheck` + - Run: `git diff --check` + - Run: `rg -n "미완성 표시|후속 처리 표시" docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio` + - Expected: Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없음, placeholder 검색은 의도하지 않은 결과 없음 + - 검증 기록: 구현 완료 후 전체 검증 결과를 이 task 아래와 문서 하단의 검증 기록에 누적한다. + +--- + +## 4. 구현 순서 요약 + +1. 오디오 탭 fallback/page/sort/rate 정책을 테스트로 고정한다. +2. domain model과 port 계약을 추가한다. +3. service orchestration을 fake port 테스트로 고정한다. +4. API DTO와 facade 변환을 고정한다. +5. QueryDSL repository를 creator/block/theme/count/list 순서로 구현한다. +6. controller 공개 계약을 MockMvc로 고정한다. +7. E2E 테스트와 전체 회귀 검증을 실행하고 결과를 이 문서에 누적한다. + +--- + +## 5. PRD 요구사항 추적 + +- API endpoint와 공개 API 패키지: Phase 4 Task 4.1 +- 재사용 가능한 조회 책임을 API 밖 도메인 패키지에 배치: Phase 1, Phase 2, Phase 3 +- `creatorId`, `sort`, `themeId`, `page`, `size` 요청 처리: Phase 1 Task 1.1, Phase 4 Task 4.1 +- invalid `sort` -> `LATEST` fallback: Phase 1 Task 1.1, Phase 4 Task 4.1, Phase 4 Task 4.2 +- page/size fallback: Phase 1 Task 1.1, Phase 2 Task 2.1, Phase 4 Task 4.2 +- 비활성/미존재 `themeId` 전체 조회 fallback: Phase 2 Task 2.1, Phase 3 Task 3.1, Phase 4 Task 4.2 +- 테마 다국어 목록: Phase 3 Task 3.1 +- 오디오/유료/구매 count와 퍼센트 소장률: Phase 2 Task 2.1, Phase 3 Task 3.2 +- 오디오 콘텐츠 목록과 `CreatorChannelAudioContentResponse` 의미 보존: Phase 2 Task 2.2, Phase 3 Task 3.3 +- 시리즈 이름 다국어 표시: Phase 3 Task 3.3 +- 정렬 정책: Phase 3 Task 3.3 +- 기존 API endpoint/응답 의미 보존: Phase 4 Task 4.1, Phase 5 Task 5.2 + +--- + +## 6. 검증 기록 + +- 2026-06-19: plan-task 문서 작성 단계. 구현 코드는 아직 변경하지 않았다. diff --git a/docs/20260619_크리에이터_채널_오디오_탭_API/prd.md b/docs/20260619_크리에이터_채널_오디오_탭_API/prd.md new file mode 100644 index 00000000..46fff135 --- /dev/null +++ b/docs/20260619_크리에이터_채널_오디오_탭_API/prd.md @@ -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, + val audioContents: List, + 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 응답 시간 +- 테마별 조회 건수 +- 정렬 기준별 조회 건수 +- 오디오 탭에서 콘텐츠 추가 로딩 요청 건수