test #426
519
docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md
Normal file
519
docs/20260619_크리에이터_채널_오디오_탭_API/plan-task.md
Normal file
@@ -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<CreatorChannelAudioThemeResponse>,
|
||||
val audioContents: List<CreatorChannelAudioContentResponse>,
|
||||
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<CreatorChannelAudioTheme>,
|
||||
val audioContents: List<CreatorChannelAudioContent>,
|
||||
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<CreatorChannelAudioThemeRecord>
|
||||
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<CreatorChannelAudioContentRecord>
|
||||
}
|
||||
|
||||
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 문서 작성 단계. 구현 코드는 아직 변경하지 않았다.
|
||||
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