test #426
377
docs/20260617_크리에이터_채널_라이브_API/plan-task.md
Normal file
377
docs/20260617_크리에이터_채널_라이브_API/plan-task.md
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
# 크리에이터 채널 라이브 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}/live`로 현재 진행 중인 라이브와 라이브 다시듣기 콘텐츠를 페이징/정렬 조회할 수 있게 한다.
|
||||||
|
|
||||||
|
**Architecture:** 기존 크리에이터 채널 홈 API 경계(`kr.co.vividnext.sodalive.v2.creator.channel`)를 유지하되, 라이브 탭 조회 책임은 별도 service/port/repository로 분리한다. 공용 콘텐츠 정렬 enum은 채널 전용 이름을 피하고 `kr.co.vividnext.sodalive.v2.common.domain.ContentSort`로 둔다. 기존 `CreatorChannelAudioContentResponse`에 `isOwned`, `isRented`를 추가해 라이브 다시듣기와 다음 범위의 오디오 콘텐츠 조회 API가 같은 응답 DTO를 재사용한다.
|
||||||
|
|
||||||
|
**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}/live`
|
||||||
|
- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다.
|
||||||
|
- request:
|
||||||
|
- path variable: `creatorId`
|
||||||
|
- query parameter: `sort`, 기본값 `LATEST`
|
||||||
|
- query parameter: `page`, 기본값 `0`, 0부터 시작
|
||||||
|
- query parameter: `size`, 기본값 `20`
|
||||||
|
- response:
|
||||||
|
- `liveReplayContentCount`: 같은 필터를 적용한 라이브 다시듣기 콘텐츠 전체 개수
|
||||||
|
- `currentLive`: 기존 `CreatorChannelLiveResponse`
|
||||||
|
- `liveReplayContents`: 기존 `CreatorChannelAudioContentResponse`
|
||||||
|
- `sort`: 실제 적용한 `ContentSort`
|
||||||
|
- `page`: 이번 요청에 적용된 page index
|
||||||
|
- `size`: 이번 요청에 적용된 page size
|
||||||
|
- `hasNext`: 다음 page 존재 여부
|
||||||
|
- `CreatorChannelAudioContentResponse`에는 `isOwned`, `isRented`를 추가한다.
|
||||||
|
- `isOwned`/`isRented` 판정은 주문 row를 각각 확인한다. 유효한 `KEEP` 주문이 있으면 `isOwned == true`, 유효한 `RENTAL` 주문이 있으면 `isRented == true`다.
|
||||||
|
- `isOwned == true`와 `isRented == true`가 동시에 발생할 가능성은 없지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
|
||||||
|
- 라이브 다시듣기 콘텐츠 기준: `AudioContentTheme.theme == "다시듣기"`이고 `AudioContentTheme.isActive == true`인 공개 오디오 콘텐츠.
|
||||||
|
- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`.
|
||||||
|
- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다.
|
||||||
|
- 현재 라이브 노출은 기존 홈 API의 `findCurrentLive` 정책을 재사용한다.
|
||||||
|
- 정렬:
|
||||||
|
- `LATEST`: `releaseDate desc`, `price desc`, random
|
||||||
|
- `POPULAR`: 구매 매출 합계 desc, `releaseDate desc`, random
|
||||||
|
- `OWNED`: 조회자 소장 여부 desc, `releaseDate desc`, random
|
||||||
|
- `PRICE_HIGH`: `price desc`, `releaseDate desc`, random
|
||||||
|
- `PRICE_LOW`: `price asc`, `releaseDate desc`, random
|
||||||
|
- 인기순 매출은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출인 `orders.can` 합계를 사용한다. `orders.point`는 포함하지 않고, `orders.is_active = true`인 주문만 포함한다. 환불/비활성 주문은 제외한다.
|
||||||
|
- page/size validation은 service에서 명시적으로 수행한다. `page < 0` 또는 `size < 1`이면 기존 `common.error.invalid_request` 계열 오류를 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 구조 계획
|
||||||
|
|
||||||
|
### 공용 정렬 enum
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSortTest.kt`
|
||||||
|
|
||||||
|
### 기존 크리에이터 채널 DTO/domain 확장
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
|
||||||
|
### 라이브 탭 신규 application/domain/port/repository/controller
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelLiveQueryService.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelLiveTab.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelPage.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelLiveReplayQueryPolicy.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelLiveQueryPort.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelLiveReplayQueryPolicyTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelLiveQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
|
||||||
|
### 문서 산출물
|
||||||
|
- Modify: `docs/20260617_크리에이터_채널_라이브_API/plan-task.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Response data class 초안
|
||||||
|
|
||||||
|
구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`에 아래 DTO를 기준으로 추가/수정한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
package kr.co.vividnext.sodalive.v2.creator.channel.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
|
import kr.co.vividnext.sodalive.v2.common.domain.ContentSort
|
||||||
|
import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLiveTab
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
|
data class CreatorChannelLiveTabResponse(
|
||||||
|
val liveReplayContentCount: Int,
|
||||||
|
val currentLive: CreatorChannelLiveResponse?,
|
||||||
|
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
@JsonProperty("hasNext")
|
||||||
|
val hasNext: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(tab: CreatorChannelLiveTab): CreatorChannelLiveTabResponse {
|
||||||
|
return CreatorChannelLiveTabResponse(
|
||||||
|
liveReplayContentCount = tab.liveReplayContentCount,
|
||||||
|
currentLive = tab.currentLive?.let(CreatorChannelLiveResponse::from),
|
||||||
|
liveReplayContents = tab.liveReplayContents.map(CreatorChannelAudioContentResponse::from),
|
||||||
|
sort = tab.sort,
|
||||||
|
page = tab.page.page,
|
||||||
|
size = tab.page.size,
|
||||||
|
hasNext = tab.hasNext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun LocalDateTime.toUtcIso(): String {
|
||||||
|
return atOffset(ZoneOffset.UTC).toInstant().toString()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 위 예시는 새/수정 필드만 보여준다. 기존 `CreatorChannelHomeResponse`, `CreatorChannelCreatorResponse`, `CreatorChannelLiveResponse` 등은 유지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: 공용 정렬 enum과 기존 오디오 응답 확장
|
||||||
|
|
||||||
|
- [ ] **Task 1.1: 공용 `ContentSort` enum 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSortTest.kt`
|
||||||
|
- RED: `ContentSortTest`를 먼저 추가해 `LATEST`, `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW` 값이 존재하는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
|
||||||
|
- GREEN: `ContentSort` enum을 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
|
||||||
|
- REFACTOR: enum 이름에 크리에이터 채널 전용 의미가 남아 있지 않은지 `rg -n "CreatorChannel.*Sort|Live.*Sort" src/main/kotlin/kr/co/vividnext/sodalive/v2`로 확인한다.
|
||||||
|
|
||||||
|
- [ ] **Task 1.2: `CreatorChannelAudioContentResponse`에 소장/대여 필드 추가**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHome.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||||
|
- RED: controller 테스트에서 `latestAudioContent.isOwned`, `latestAudioContent.isRented`, `audioContents[0].isOwned`, `audioContents[0].isRented` JSON 필드를 기대하도록 추가한다.
|
||||||
|
- RED: service 테스트에서 `CreatorChannelAudioContentRecord` → `CreatorChannelAudioContent` 변환 시 `isOwned`, `isRented`가 유지되는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- GREEN: domain model, record, response DTO, service 변환에 `isOwned`, `isRented`를 추가한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest`
|
||||||
|
- REFACTOR: 기존 홈 API 응답에 새 boolean 필드가 항상 존재하도록 null 불가능 `Boolean`으로 유지한다.
|
||||||
|
|
||||||
|
- [ ] **Task 1.3: 기존 홈 오디오 조회에 주문 상태 bulk 판정 추가**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelHomeQueryPort.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: repository 테스트에 조회자가 `KEEP` 주문한 콘텐츠와 유효한 `RENTAL` 주문한 콘텐츠를 넣고, `findLatestAudioContent`, `findAudioContents` 결과의 `isOwned`/`isRented`가 각각 맞는지 검증한다.
|
||||||
|
- RED: 같은 콘텐츠에 `KEEP`과 유효한 `RENTAL`이 함께 있으면 `isOwned == true`, `isRented == true`를 기대한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: `findLatestAudioContent`, `findAudioContents`에 `viewerId`를 전달하고, 조회된 content id 묶음으로 주문 상태를 bulk 조회해 `CreatorChannelAudioContentRecord`에 채운다. 유효 대여 조건은 기존 주문 정책과 같이 `order.isActive == true`, `order.type == RENTAL`, `order.endDate > now`를 사용한다. 소장 조건은 `order.isActive == true`, `order.type == KEEP`이다. 소장/대여 상태는 서로 배타적으로 보정하지 않는다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: 콘텐츠마다 `OrderRepository.isExistOrderedAndOrderType`를 반복 호출하지 않고 content id 목록 기반 bulk 조회를 유지한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 라이브 탭 domain/application 정책
|
||||||
|
|
||||||
|
- [ ] **Task 2.1: 라이브 탭 domain model과 page 정책 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelLiveTab.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelPage.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelLiveReplayQueryPolicy.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelLiveReplayQueryPolicyTest.kt`
|
||||||
|
- RED: `page=0,size=20`이면 offset `0`, fetch limit `21`, 응답 items limit `20`, `hasNext == true` 판정이 되는 테스트를 작성한다.
|
||||||
|
- RED: `page < 0`, `size < 1`이면 정책이 예외를 던지는 테스트를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLiveReplayQueryPolicyTest`
|
||||||
|
- GREEN: `CreatorChannelPage(page: Int, size: Int)`와 `CreatorChannelLiveReplayQueryPolicy`를 추가한다. `offset = page * size`, `fetchLimit = size + 1`, `items = fetched.take(size)`, `hasNext = fetched.size > size`를 제공한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLiveReplayQueryPolicyTest`
|
||||||
|
- REFACTOR: page/size 계산은 service나 repository에 중복 구현하지 않는다.
|
||||||
|
|
||||||
|
- [ ] **Task 2.2: `CreatorChannelLiveQueryService` 골격 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelLiveQueryService.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/port/out/CreatorChannelLiveQueryPort.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelLiveQueryServiceTest.kt`
|
||||||
|
- RED: service 테스트에서 `getLiveTab(creatorId, viewer, sort = ContentSort.LATEST, page = 0, size = 20)` 호출 시 port의 `findCreator`, `existsBlockedBetween`, `findCurrentLive`, `countLiveReplayAudioContents`, `findLiveReplayAudioContents`가 필요한 인자로 호출되는지 fake port로 검증한다.
|
||||||
|
- RED: 조회 대상이 없으면 `member.validation.user_not_found`, 크리에이터가 아니면 `member.validation.creator_not_found`, 차단 관계이면 기존 차단 메시지 예외를 기대한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelLiveQueryServiceTest`
|
||||||
|
- GREEN: 기존 `CreatorChannelHomeQueryService`의 인증 회원 기준 정책을 따라 creator 검증, 차단 검증, adult visibility, effective gender 계산을 구현한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelLiveQueryServiceTest`
|
||||||
|
- REFACTOR: `CreatorChannelHomeQueryService`와 중복되는 private 변환/검증 코드가 과도해지면 같은 phase 안에서는 복사 유지하고, 추후 별도 공용 validator 추출은 구현 범위 밖으로 둔다.
|
||||||
|
|
||||||
|
- [ ] **Task 2.3: 라이브 탭 service 응답 조립 완성**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelLiveQueryService.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelLiveTab.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelLiveQueryServiceTest.kt`
|
||||||
|
- RED: fake port가 `size + 1`개 콘텐츠를 반환하면 service 응답의 `liveReplayContents.size == size`, `hasNext == true`, `page == 0`, `size == 20`, `sort == LATEST`인지 검증한다.
|
||||||
|
- RED: `page` 범위에 콘텐츠가 없으면 `liveReplayContents`는 빈 배열이고 count는 port count 값을 유지하는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelLiveQueryServiceTest`
|
||||||
|
- GREEN: service에서 policy로 page를 검증하고, count와 `size + 1` 조회 결과를 조립해 `CreatorChannelLiveTab`을 반환한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelLiveQueryServiceTest`
|
||||||
|
- REFACTOR: `ContentSort` 기본값은 controller가 기본값을 넣되, service 테스트에서도 명시 인자를 사용해 정책 위치를 분리한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 라이브 다시듣기 persistence adapter
|
||||||
|
|
||||||
|
- [ ] **Task 3.1: 라이브 탭 repository 골격과 count 조회 추가**
|
||||||
|
- Files:
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/CreatorChannelLiveQueryRepository.kt`
|
||||||
|
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
|
- RED: fixture로 `다시듣기` 테마 콘텐츠 2개, 다른 테마 콘텐츠 1개, 예약 공개 콘텐츠 1개, 비활성 콘텐츠 1개를 만들고 count가 공개 `다시듣기` 콘텐츠만 세는지 검증한다.
|
||||||
|
- RED: 성인 노출 불가이면 성인 `다시듣기` 콘텐츠가 count에서 제외되는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- GREEN: `countLiveReplayAudioContents(creatorId, now, canViewAdultContent)`를 구현한다. 조건은 creator, active member, active content, active theme, `theme == "다시듣기"`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, adult filter다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- REFACTOR: `"다시듣기"` 문자열은 repository companion object의 `LIVE_REPLAY_THEME` 상수로 둔다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.2: 라이브 다시듣기 기본 목록과 페이징 조회 추가**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
|
- RED: `offset=20, limit=21` 인자를 전달하면 21개까지만 조회하고, 앞 page 콘텐츠가 제외되는지 검증한다.
|
||||||
|
- RED: `LATEST` 정렬에서 공개일 최신순, 같은 공개일이면 높은 가격순이 먼저인지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- GREEN: `findLiveReplayAudioContents(creatorId, viewerId, now, canViewAdultContent, sort, offset, limit)`를 구현한다. 우선 `LATEST`, `PRICE_HIGH`, `PRICE_LOW` 정렬을 QueryDSL order specifier로 처리한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- REFACTOR: content row 조회, first content id 조회, series summary 조회, order status 조회를 작은 private 함수로 분리한다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.3: `POPULAR` 정렬 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
|
- RED: 대여/소장 여부와 관계없이 `orders.can` 합계가 큰 콘텐츠가 먼저 나오고, 같은 매출이면 공개일 최신순이 먼저 나오는 테스트를 작성한다.
|
||||||
|
- RED: `orders.isActive == false` 주문과 `orders.point` 값은 매출 합계에서 제외되는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- GREEN: orders left join 또는 집계 subquery로 콘텐츠별 활성 주문의 `can` 합계를 계산하고 `POPULAR` 정렬에 반영한다. `point`는 더하지 않는다. 매출이 없으면 0으로 처리한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- REFACTOR: 인기순 집계가 기본 목록 count를 중복시키지 않도록 count query와 list query를 분리 유지한다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.4: `OWNED` 정렬과 소장/대여 응답 상태 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
|
- RED: `OWNED` 정렬에서 조회자가 `KEEP` 주문한 콘텐츠가 먼저 나오고, 나머지는 공개일 최신순으로 정렬되는지 검증한다.
|
||||||
|
- RED: 유효한 `RENTAL` 주문만 있는 콘텐츠는 `isRented == true`, `isOwned == false`인지 검증한다.
|
||||||
|
- RED: `KEEP`과 유효한 `RENTAL`이 모두 있으면 `isOwned == true`, `isRented == true`인지 검증한다.
|
||||||
|
- RED: 만료된 `RENTAL`은 `isRented == false`인지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- GREEN: 조회된 content id 목록 기준으로 주문 상태를 bulk 조회해 record에 채운다. `OWNED` 정렬은 `KEEP` 존재 여부를 1차 기준으로 삼는다. 응답의 `isOwned`, `isRented`는 각각의 주문 존재 여부를 그대로 반영하고 서로 배타적으로 보정하지 않는다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- REFACTOR: 응답 상태 판정과 `OWNED` 정렬 판정의 의미가 어긋나지 않도록 동일한 유효 주문 조건을 사용한다.
|
||||||
|
|
||||||
|
- [ ] **Task 3.5: 현재 라이브 조회 위임 구현**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
|
- RED: 현재 진행 중인 라이브 조회가 기존 홈 API와 같은 정책으로 성인 노출, 성별 제한, 크리에이터 입장 제한을 반영하는지 대표 케이스를 작성한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- GREEN: `DefaultCreatorChannelLiveQueryRepository.findCurrentLive`는 기존 `DefaultCreatorChannelHomeQueryRepository.findCurrentLive`와 같은 조건을 사용한다. 코드 중복이 과하면 private helper를 복사하되, 동작 보존을 우선한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- REFACTOR: 이후 공용 live query helper 추출은 이번 구현 범위에서 제외한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Controller와 공개 응답
|
||||||
|
|
||||||
|
- [ ] **Task 4.1: 라이브 탭 controller endpoint 추가**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt`
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/dto/CreatorChannelHomeResponse.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- RED: `GET /api/v2/creator-channels/1/live`가 인증 회원, `creatorId`, 기본 `sort=LATEST`, 기본 `page=0`, 기본 `size=20`을 service에 전달하는 MockMvc 테스트를 작성한다.
|
||||||
|
- RED: 응답 JSON에 `liveReplayContentCount`, `currentLive`, `liveReplayContents`, `sort`, `page`, `size`, `hasNext`, `liveReplayContents[0].isOwned`, `liveReplayContents[0].isRented`가 존재하는지 검증한다.
|
||||||
|
- RED: anonymous 요청은 기존 홈 API와 같이 unauthorized가 되는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- GREEN: 같은 controller에 `@GetMapping("/{creatorId}/live")`를 추가하고 `CreatorChannelLiveQueryService`를 주입한다. query parameter는 `@RequestParam(defaultValue = "LATEST") sort: ContentSort`, `@RequestParam(defaultValue = "0") page: Int`, `@RequestParam(defaultValue = "20") size: Int`로 받는다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- REFACTOR: controller 이름은 기존 `CreatorChannelHomeController`를 유지하되, 추후 채널 탭 API가 늘면 `CreatorChannelController`로 분리할지 별도 작업으로 판단한다.
|
||||||
|
|
||||||
|
- [ ] **Task 4.2: 잘못된 page/size validation 표면 확인**
|
||||||
|
- Files:
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelLiveQueryService.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelLiveQueryServiceTest.kt`
|
||||||
|
- RED: `page=-1` 또는 `size=0` 요청이 400 계열 오류로 처리되는지 controller/service 테스트를 추가한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelLiveQueryServiceTest`
|
||||||
|
- GREEN: service에서 `CreatorChannelLiveReplayQueryPolicy.validatePage(page, size)`를 호출하고 invalid request 예외를 던진다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelLiveQueryServiceTest`
|
||||||
|
- REFACTOR: Spring enum binding 실패(`sort=UNKNOWN`)는 별도 custom handling을 추가하지 않고 기존 MVC 오류 흐름을 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: 회귀 및 문서 동기화
|
||||||
|
|
||||||
|
- [ ] **Task 5.1: 기존 홈 API 회귀 테스트 보강**
|
||||||
|
- Files:
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||||
|
- RED: 기존 홈 API의 `latestAudioContent`와 `audioContents`에 새 `isOwned`, `isRented` 필드가 내려오는지 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- GREEN: Phase 1 구현이 빠뜨린 변환/fixture를 보정한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest`
|
||||||
|
- REFACTOR: test fixture의 `CreatorChannelAudioContent` 생성부가 반복되면 테스트 내부 helper만 추가하고 production abstraction은 만들지 않는다.
|
||||||
|
|
||||||
|
- [ ] **Task 5.2: 라이브 탭 통합 시나리오 검증**
|
||||||
|
- Files:
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||||
|
- RED: 실제 fixture 기반으로 현재 라이브 1개, 라이브 다시듣기 21개, 소장/대여/미구매 콘텐츠를 넣고 `page=0,size=20,sort=LATEST` 응답 표면을 검증한다.
|
||||||
|
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- GREEN: 누락된 mapping, count, hasNext, sort 응답을 보정한다.
|
||||||
|
- 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- REFACTOR: 통합 테스트는 하나의 대표 시나리오만 유지하고 세부 정렬/상태 케이스는 repository 단위 테스트로 둔다.
|
||||||
|
|
||||||
|
- [ ] **Task 5.3: 전체 회귀 검증과 문서 검증 기록 추가**
|
||||||
|
- Files:
|
||||||
|
- Modify: `docs/20260617_크리에이터_채널_라이브_API/plan-task.md`
|
||||||
|
- TDD 예외 사유: 문서 검증 기록 갱신 task로 production/test 코드 변경이 없다.
|
||||||
|
- 대체 검증 방법: 아래 명령 실행 결과를 이 task 아래와 문서 하단 검증 기록에 누적한다.
|
||||||
|
- 실행 명령:
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.ContentSortTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLiveReplayQueryPolicyTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelLiveQueryServiceTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelLiveQueryRepositoryTest`
|
||||||
|
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest`
|
||||||
|
- `./gradlew ktlintCheck`
|
||||||
|
- 기대 결과: 모든 명령이 성공한다.
|
||||||
|
- REFACTOR: 실패가 발생하면 실패 task로 돌아가 문서의 체크박스를 완료 처리하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 순서 요약
|
||||||
|
|
||||||
|
1. `ContentSort` 공용 enum을 먼저 추가한다.
|
||||||
|
2. 기존 `CreatorChannelAudioContentResponse`와 domain/record에 `isOwned`, `isRented`를 추가해 홈 API 컴파일/테스트를 먼저 복구한다.
|
||||||
|
3. 라이브 탭 page 정책과 service 골격을 만든다.
|
||||||
|
4. 라이브 다시듣기 count/list repository를 구현한다.
|
||||||
|
5. controller endpoint와 응답 DTO를 연결한다.
|
||||||
|
6. 기존 홈 API 회귀와 라이브 탭 통합 시나리오를 검증한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 검증 기록
|
||||||
|
|
||||||
|
- 아직 구현 전 계획 문서 작성 단계다. 구현 task 완료 후 각 task 아래에 무엇을, 왜, 어떻게 검증했는지 실행 명령과 결과를 누적 기록한다.
|
||||||
261
docs/20260617_크리에이터_채널_라이브_API/prd.md
Normal file
261
docs/20260617_크리에이터_채널_라이브_API/prd.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# PRD: 크리에이터 채널 라이브 API
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
크리에이터 채널의 라이브 탭에서 현재 진행 중인 라이브와 `다시듣기` 콘텐츠를 한 번에 조회하는 API를 제공한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Problem
|
||||||
|
- 크리에이터 채널 홈 API는 홈 화면에 필요한 요약 데이터를 제공하지만, 라이브 탭은 현재 라이브와 `다시듣기` 콘텐츠 목록/개수를 함께 조회해야 한다.
|
||||||
|
- 클라이언트는 라이브 탭 진입 시 현재 진행 중인 라이브, `다시듣기` 콘텐츠 목록, 전체 개수, 적용된 정렬 순서를 일관된 계약으로 받아야 한다.
|
||||||
|
- `다시듣기` 콘텐츠 정렬 기준이 여러 개이고 이후 오디오 콘텐츠, 시리즈, 화보 등 채널 내 다른 콘텐츠 목록에서도 같은 정렬 기준을 사용할 예정이므로 서버와 클라이언트가 공유할 명시적인 enum 계약이 필요하다.
|
||||||
|
- 응답 필드는 기존 `CreatorChannelLiveResponse`, `CreatorChannelAudioContentResponse`와 의미가 어긋나지 않아야 한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Goals
|
||||||
|
- 크리에이터 채널 라이브 탭 조회 API를 제공한다.
|
||||||
|
- 요청은 `creatorId`와 정렬 순서를 받는다.
|
||||||
|
- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다.
|
||||||
|
- 응답에는 `다시듣기` 콘텐츠 개수, 현재 진행 중인 라이브, `다시듣기` 콘텐츠 목록, 실제 적용된 정렬 순서를 포함한다.
|
||||||
|
- 현재 진행 중인 라이브 응답은 기존 `CreatorChannelLiveResponse`와 동일한 필드/의미를 사용한다.
|
||||||
|
- `다시듣기` 콘텐츠 응답은 기존 `CreatorChannelAudioContentResponse`에 유료 콘텐츠의 소장/대여 상태를 추가해 사용한다.
|
||||||
|
- `다시듣기` 콘텐츠는 기존 프로젝트에서 사용하는 `AudioContentTheme.theme == "다시듣기"` 기준을 따른다.
|
||||||
|
- 정렬 순서는 enum으로 정의해 공개 API 계약을 고정한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-Goals
|
||||||
|
- 이번 범위는 크리에이터 채널 `라이브` 탭 조회 API만 포함한다.
|
||||||
|
- 기존 크리에이터 채널 홈 API 응답 스키마는 변경하지 않는다.
|
||||||
|
- 라이브 생성, 예약, 입장, 종료 API는 포함하지 않는다.
|
||||||
|
- 오디오 콘텐츠 구매, 소장, 대여, 결제 API는 포함하지 않는다.
|
||||||
|
- `다시듣기` 테마명 관리 화면 또는 테마 마이그레이션은 포함하지 않는다.
|
||||||
|
- 앱 표시용 다국어 문구, 날짜 포맷, 가격 단위 표시는 서버에서 처리하지 않는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Target Users
|
||||||
|
- 회원: 크리에이터 채널 라이브 탭에서 현재 라이브와 다시듣기 콘텐츠를 탐색하는 사용자
|
||||||
|
- 앱 클라이언트: 라이브 탭 구성에 필요한 데이터를 단일 API 응답으로 표시하려는 클라이언트
|
||||||
|
- 크리에이터: 자신의 현재 라이브와 다시듣기 콘텐츠가 적절한 정렬로 노출되기를 원하는 사용자
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. User Stories
|
||||||
|
- 사용자는 크리에이터 채널 라이브 탭에 들어가면 현재 진행 중인 라이브가 있는지 바로 확인하고 싶다.
|
||||||
|
- 사용자는 크리에이터의 `다시듣기` 콘텐츠를 최신순으로 보고 싶다.
|
||||||
|
- 사용자는 인기순, 소장순, 높은 가격순, 낮은 가격순으로 `다시듣기` 콘텐츠를 바꿔 보고 싶다.
|
||||||
|
- 앱 클라이언트는 현재 적용된 정렬 순서를 응답에서 확인해 화면 상태와 서버 조회 결과를 맞추고 싶다.
|
||||||
|
- 앱 클라이언트는 `다시듣기` 콘텐츠 전체 개수를 받아 탭/헤더/빈 상태 UI에 표시하고 싶다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Core Features
|
||||||
|
|
||||||
|
### Feature A. 크리에이터 채널 라이브 조회 API
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 신규 API는 크리에이터 채널 전용 v2 API로 작성한다.
|
||||||
|
- 신규 코드 위치는 기존 크리에이터 채널 홈 API와 같은 `kr.co.vividnext.sodalive.v2.creator.channel` 하위 경계를 기본 후보로 한다.
|
||||||
|
- API endpoint는 `GET /api/v2/creator-channels/{creatorId}/live`를 기본안으로 한다.
|
||||||
|
- `creatorId`는 path variable로 받는다.
|
||||||
|
- 정렬 순서는 query parameter로 받는다.
|
||||||
|
- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다.
|
||||||
|
- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다.
|
||||||
|
- `다시듣기` 콘텐츠 추가 로딩을 위해 `page`, `size` query parameter를 받는다.
|
||||||
|
- `page`는 0부터 시작하는 page index로 처리한다.
|
||||||
|
- `size`를 보내지 않으면 기본값 `20`을 사용한다.
|
||||||
|
- `page`를 보내지 않으면 기본값 `0`을 사용한다.
|
||||||
|
- API는 인증 회원만 조회할 수 있어야 한다.
|
||||||
|
- 비회원이 조회하면 기존 인증 필요 API와 동일하게 `common.error.bad_credentials` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 존재하지 않으면 기존 정책과 동일하게 `member.validation.user_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회 대상 회원이 크리에이터가 아니면 기존 정책과 동일하게 `member.validation.creator_not_found` 계열 오류를 반환한다.
|
||||||
|
- 조회자와 크리에이터 사이에 차단 관계가 있으면 구버전 채널 접근 정책과 동일하게 접근 차단 오류를 반환한다.
|
||||||
|
- 현재 진행 중인 라이브가 없거나 `다시듣기` 콘텐츠가 없어도 전체 API는 성공 처리한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 조회자 본인이 크리에이터인 경우에도 같은 응답 스키마를 사용한다.
|
||||||
|
- 알 수 없는 `sort` 값은 Spring enum binding 실패 또는 기존 validation 오류 흐름에 맞춰 400 계열 오류로 처리한다.
|
||||||
|
- `page`가 0보다 작거나 `size`가 1보다 작으면 기존 validation 오류 흐름에 맞춰 400 계열 오류로 처리한다.
|
||||||
|
|
||||||
|
### Feature B. 응답 스키마
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다.
|
||||||
|
- 응답 최상위 DTO 이름은 `CreatorChannelLiveTabResponse`를 기본안으로 한다.
|
||||||
|
- 응답에는 다음 값을 포함한다.
|
||||||
|
- `liveReplayContentCount`: `다시듣기` 카테고리 콘텐츠 전체 개수
|
||||||
|
- `currentLive`: 현재 진행 중인 라이브, 없으면 `null`
|
||||||
|
- `liveReplayContents`: `다시듣기` 콘텐츠 목록
|
||||||
|
- `sort`: 콘텐츠 조회에 실제 적용한 정렬 순서
|
||||||
|
- `page`: 현재 응답의 page index
|
||||||
|
- `size`: 현재 응답의 page size
|
||||||
|
- `hasNext`: 다음 page 존재 여부
|
||||||
|
- `currentLive`는 기존 `CreatorChannelLiveResponse`와 동일한 필드/의미를 사용한다.
|
||||||
|
- `liveReplayContents`의 각 item은 기존 `CreatorChannelAudioContentResponse`를 사용한다.
|
||||||
|
- `CreatorChannelAudioContentResponse`에는 다음 범위의 오디오 콘텐츠 조회 API에서도 재사용할 수 있도록 `isOwned`, `isRented`를 추가한다.
|
||||||
|
- `sort`는 요청값이 없으면 기본값 `LATEST`를 내려준다.
|
||||||
|
- `page`, `size`는 실제 적용된 값을 내려준다.
|
||||||
|
- `hasNext`는 같은 필터/정렬 조건에서 다음 page에 노출할 `다시듣기` 콘텐츠가 있으면 `true`로 내려준다.
|
||||||
|
- 응답 스키마 예시는 다음과 같다.
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
data class CreatorChannelLiveTabResponse(
|
||||||
|
val liveReplayContentCount: Int,
|
||||||
|
val currentLive: CreatorChannelLiveResponse?,
|
||||||
|
val liveReplayContents: List<CreatorChannelAudioContentResponse>,
|
||||||
|
val sort: ContentSort,
|
||||||
|
val page: Int,
|
||||||
|
val size: Int,
|
||||||
|
val hasNext: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
- 현재 진행 중인 라이브가 없으면 `currentLive`는 `null`로 내려준다.
|
||||||
|
- `다시듣기` 콘텐츠가 없으면 `liveReplayContentCount`는 `0`, `liveReplayContents`는 빈 배열, `hasNext`는 `false`로 내려준다.
|
||||||
|
|
||||||
|
### Feature C. 현재 진행 중인 라이브
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 크리에이터가 현재 진행 중인 라이브를 내려준다.
|
||||||
|
- 현재 진행 중인 라이브는 기존 라이브 도메인의 `LiveRoomStatus.NOW` 의미와 동일하게 판단한다.
|
||||||
|
- 응답 필드는 기존 `CreatorChannelLiveResponse`와 동일하게 다음 값을 포함한다.
|
||||||
|
- `liveId`
|
||||||
|
- `title`
|
||||||
|
- `coverImageUrl`
|
||||||
|
- `beginDateTimeUtc`
|
||||||
|
- `price`
|
||||||
|
- `isAdult`
|
||||||
|
- 조회자의 성인 콘텐츠 노출 정책과 차단 정책을 반영한다.
|
||||||
|
- 현재 라이브 노출은 기존 라이브 목록 정책과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다.
|
||||||
|
- 성별 제한 판단에 사용하는 조회자 성별은 기존 라이브 목록과 동일하게 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 effective gender다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 현재 진행 중인 라이브 후보가 여러 개이면 기존 라이브 목록/홈 API의 현재 라이브 선택 정책을 따른다.
|
||||||
|
- 성인 콘텐츠 노출 정책상 볼 수 없는 라이브만 있으면 `currentLive`는 `null`로 내려준다.
|
||||||
|
|
||||||
|
### Feature D. `다시듣기` 콘텐츠 목록과 개수
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- `다시듣기` 콘텐츠는 `AudioContentTheme.theme == "다시듣기"`인 오디오 콘텐츠를 의미한다.
|
||||||
|
- `AudioContentTheme.isActive == true`인 테마만 대상으로 한다.
|
||||||
|
- 조회 대상은 지정한 `creatorId`의 콘텐츠로 제한한다.
|
||||||
|
- 공개된 콘텐츠만 조회한다.
|
||||||
|
- 예약 공개 전 콘텐츠는 포함하지 않는다.
|
||||||
|
- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 조회에서 제외한다.
|
||||||
|
- 응답 item 필드는 기존 `CreatorChannelAudioContentResponse`와 동일하게 다음 값을 포함한다.
|
||||||
|
- `audioContentId`
|
||||||
|
- `title`
|
||||||
|
- `duration`
|
||||||
|
- `imageUrl`
|
||||||
|
- `price`
|
||||||
|
- `isAdult`
|
||||||
|
- `isPointAvailable`
|
||||||
|
- `isFirstContent`
|
||||||
|
- `seriesName`
|
||||||
|
- `isOriginalSeries`
|
||||||
|
- `CreatorChannelAudioContentResponse`에는 유료 콘텐츠 상태 표시를 위해 다음 값을 추가한다.
|
||||||
|
- `isOwned`: 조회자가 해당 콘텐츠를 소장 중이면 `true`
|
||||||
|
- `isRented`: 조회자가 해당 콘텐츠를 대여 중이고 대여 기간이 유효하면 `true`
|
||||||
|
- 무료 콘텐츠 또는 조회자가 구매/대여하지 않은 콘텐츠는 `isOwned == false`, `isRented == false`로 내려준다.
|
||||||
|
- 일반적으로 `isOwned == true`와 `isRented == true`가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 둘 다 `true`로 내려준다.
|
||||||
|
- 콘텐츠 개수는 같은 필터를 적용한 `다시듣기` 콘텐츠 전체 개수로 계산한다.
|
||||||
|
- 콘텐츠 목록은 `page`, `size` 기준으로 페이징 조회한다.
|
||||||
|
- 기본 page size는 20개다.
|
||||||
|
- 클라이언트는 `hasNext == true`이면 같은 `creatorId`, `sort`, `size`와 다음 `page` 값으로 추가 로딩할 수 있어야 한다.
|
||||||
|
- 다음 page 존재 여부는 `size + 1`개를 조회하거나 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다.
|
||||||
|
- 목록 조회와 개수 조회는 성인 콘텐츠 노출 정책, 차단 정책, 공개 여부 필터가 서로 어긋나지 않아야 한다.
|
||||||
|
- 조회자의 성인 콘텐츠 노출 정책이 false이면 성인 콘텐츠는 목록과 개수에서 제외한다.
|
||||||
|
- `isFirstContent`, `seriesName`, `isOriginalSeries`, 구매/대여/포인트 사용 가능 여부의 의미는 기존 오디오 콘텐츠/시리즈 콘텐츠 응답과 동일하게 유지한다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 시리즈에 속하지 않은 콘텐츠는 `seriesName`, `isOriginalSeries`를 `null`로 내려준다.
|
||||||
|
- 공개된 `다시듣기` 콘텐츠가 없으면 빈 배열을 내려준다.
|
||||||
|
- 요청한 page 범위에 콘텐츠가 없으면 `liveReplayContents`는 빈 배열, `hasNext`는 `false`로 내려주되 `liveReplayContentCount`는 전체 개수를 유지한다.
|
||||||
|
|
||||||
|
### Feature E. 콘텐츠 정렬
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- 정렬 순서는 enum으로 처리한다.
|
||||||
|
- enum 이름은 `ContentSort`를 기본안으로 한다.
|
||||||
|
- `ContentSort`는 크리에이터 채널에 한정하지 않고, 서비스 전반에서 콘텐츠 목록 정렬이 필요할 때 재사용할 수 있는 공용 정렬 enum으로 둔다.
|
||||||
|
- `ContentSort` 파일 위치는 구현 시 `kr.co.vividnext.sodalive.v2.common.domain.ContentSort`를 기본 후보로 한다.
|
||||||
|
- `ContentSort`는 라이브 탭의 `다시듣기` 콘텐츠뿐 아니라 다음 범위의 오디오 콘텐츠, 시리즈, 화보 등 콘텐츠형 목록에서 같은 정렬 의미를 공유한다.
|
||||||
|
- 공개 요청/응답 값은 다음을 사용한다.
|
||||||
|
- `LATEST`: 최신순, 기본값
|
||||||
|
- `POPULAR`: 인기순
|
||||||
|
- `OWNED`: 소장순
|
||||||
|
- `PRICE_HIGH`: 높은 가격순
|
||||||
|
- `PRICE_LOW`: 낮은 가격순
|
||||||
|
- `LATEST`는 공개일 최신순을 1차 정렬로 사용한다.
|
||||||
|
- `LATEST`의 2차 정렬은 높은 가격순이다.
|
||||||
|
- `LATEST`의 3차 정렬은 랜덤이다.
|
||||||
|
- `POPULAR`은 매출이 많은 콘텐츠를 먼저 노출한다.
|
||||||
|
- `OWNED`는 조회자가 소장한 콘텐츠를 먼저 노출한다.
|
||||||
|
- `PRICE_HIGH`는 가격이 높은 콘텐츠를 먼저 노출한다.
|
||||||
|
- `PRICE_LOW`는 가격이 낮은 콘텐츠를 먼저 노출한다.
|
||||||
|
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 2차 정렬은 최신순이다.
|
||||||
|
- `POPULAR`, `OWNED`, `PRICE_HIGH`, `PRICE_LOW`의 3차 정렬은 랜덤이다.
|
||||||
|
- 최신순 기준에 사용하는 날짜는 기존 `CreatorChannelAudioContentResponse` 목록 정책과 동일하게 공개 시각(`releaseDate`)을 기준으로 한다.
|
||||||
|
- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 하며, 포인트 사용액은 매출 기준에 포함하지 않는다.
|
||||||
|
- 환불되었거나 비활성 처리된 구매 내역은 기존 콘텐츠 구매/매출 정책과 동일하게 제외한다.
|
||||||
|
- 소장순은 조회자가 해당 콘텐츠를 유효하게 소장 또는 구매한 상태를 기준으로 한다.
|
||||||
|
- 랜덤 정렬은 같은 1차/2차 정렬 값을 가진 항목 사이의 순서만 흔들 수 있다.
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
- 매출이 없는 콘텐츠의 인기순 매출값은 0으로 처리한다.
|
||||||
|
- 조회자가 소장한 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + 랜덤 보조 정렬과 같은 결과가 될 수 있다.
|
||||||
|
- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technical Constraints
|
||||||
|
- 빌드 도구는 Gradle Wrapper(`./gradlew`)를 사용한다.
|
||||||
|
- Kotlin + Spring Boot 2.7.14 기존 스타일을 따른다.
|
||||||
|
- 신규 공개 API 스키마는 구현 전에 PRD와 구현 계획/TASK 문서에 명시한다.
|
||||||
|
- 기존 `CreatorChannelLiveResponse`, `CreatorChannelAudioContentResponse` DTO를 재사용한다.
|
||||||
|
- `CreatorChannelAudioContentResponse`에는 `isOwned`, `isRented`를 추가하고, 라이브 다시듣기와 다음 범위의 오디오 콘텐츠 조회 API가 같은 응답 DTO를 사용하도록 한다.
|
||||||
|
- 기존 크리에이터 채널 홈 API의 패키지, 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책을 우선 재사용한다.
|
||||||
|
- 페이징 응답은 기존 v2 홈 추천 페이지 응답과 같은 `page`, `size`, `hasNext` 패턴을 따른다.
|
||||||
|
- `다시듣기` 테마명은 기존 코드의 문자열 상수와 중복되지 않도록 구현 시 공용 상수 또는 정책 객체로 관리하는 방안을 검토한다.
|
||||||
|
- `ContentSort`는 API binding, service 정책, 테스트에서 같은 타입을 사용한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Metrics
|
||||||
|
- 라이브 탭 API 성공/실패 건수
|
||||||
|
- 라이브 탭 API 응답 시간
|
||||||
|
- 정렬 순서별 요청 건수
|
||||||
|
- `currentLive`가 있는 응답 비율
|
||||||
|
- `다시듣기` 콘텐츠 개수와 실제 목록 노출 개수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Resolved Decisions
|
||||||
|
- 인기순 매출 기준은 대여/소장 여부와 관계없이 순수하게 결제된 캔 매출만 사용한다.
|
||||||
|
- `isOwned == true`와 `isRented == true`가 동시에 발생할 가능성은 없지만, 만약 그런 상황이 발생하면 둘 다 `true`로 내려준다.
|
||||||
Reference in New Issue
Block a user