diff --git a/docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md b/docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md new file mode 100644 index 00000000..741c9022 --- /dev/null +++ b/docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md @@ -0,0 +1,454 @@ +# 크리에이터 채널 시리즈 탭 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}/series`로 크리에이터 채널 시리즈 탭의 전체 시리즈 개수와 정렬/페이징된 시리즈 목록을 조회할 수 있게 한다. + +**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 조립 계층에 둔다. 시리즈 탭 조회 service, 순수 fallback/page/rate/day-of-week 정책, tab domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 오디오 탭의 `ContentSort`, `CreatorChannelPage`, 인증/차단/성인 콘텐츠 노출 정책 흐름을 재사용하되, 홈 API의 `CreatorChannelSeries`는 확장하지 않는다. + +**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}/series` +- 인증 정책: 인증 회원만 조회 가능. 비회원은 기존 Security 흐름과 `requireMember` 정책으로 거부한다. +- request: + - path variable: `creatorId` + - query parameter: `sort`, `required = false`, 기본값/fallback `LATEST` + - 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: + - `seriesCount`: sort-bar에 표시할 조회 가능한 전체 시리즈 개수 + - `series`: 시리즈 목록 + - `sort`: 실제 적용한 `ContentSort` + - `page`: fallback 보정 후 실제 적용된 page index + - `size`: fallback 보정 후 실제 적용된 page size + - `hasNext`: 다음 page 존재 여부 +- series item: + - `seriesId`, `title`, `coverImageUrl`, `publishedDaysOfWeek`, `isOriginal`, `isAdult`, `isProceeding`, `contentCount` + - 조회자가 해당 시리즈의 크리에이터가 아니면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산한다. + - 조회자가 해당 시리즈의 크리에이터이면 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`는 `null`이다. +- `purchasedPaidContentRate`: `Int?`, 비크리에이터 조회 시 `paidContentCount == 0`이면 `0`, 그 외 `(purchasedContentCount * 100) / paidContentCount`로 계산하고 소수점 이하는 버린다. +- 공개 콘텐츠 기준: `AudioContent.isActive == true`, `AudioContent.duration != null`, `AudioContent.releaseDate != null`, `AudioContent.releaseDate <= now`. +- 공개 시리즈 기준: `Series.isActive == true`, `Series.member.id == creatorId`. +- `coverImageUrl`은 `Series.coverImage`를 `String?.toCdnUrl(cloudFrontHost)`로 변환한 값이다. 커버 이미지 경로가 없거나 blank이면 `null`로 내려준다. +- 성인 콘텐츠는 조회자의 성인 콘텐츠 노출 정책이 false이면 목록과 count에서 제외한다. +- 시리즈명은 `LangContext.lang.code` 기준으로 `SeriesTranslation`을 우선하고, 없거나 빈 문자열이면 `Series.title` 원문으로 fallback한다. +- 연재 요일: + - `RANDOM`이 포함되면 다른 요일을 무시하고 랜덤 문구만 반환한다. + - 랜덤 문구: `ko=랜덤`, `en=Random`, `ja=ランダム` + - 7개 요일이 모두 있으면 `ko=매일`, `en=Every day`, `ja=毎日` + - 그 외 `ko=매주 월, 목, 토`, `en=Every Mon, Thu, Sat`, `ja=毎週 月, 木, 土` 형식 +- 정렬: + - `LATEST`: 대표 `releaseDate desc`, 대표 `price desc`, `series.id desc` + - `POPULAR`: 시리즈 콘텐츠의 `orders.can` 합계 desc, 대표 `releaseDate desc`, `series.id desc`; `orders.is_active = true`만 포함 + - `OWNED`: 조회자가 유효하게 소장/대여 중인 시리즈 콘텐츠 개수 desc, 대표 `releaseDate desc`, `series.id desc` + - `PRICE_HIGH`: 대표 `price desc`, 대표 `releaseDate desc`, `series.id desc` + - `PRICE_LOW`: 대표 `price asc`, 대표 `releaseDate desc`, `series.id desc` +- 대표값: + - 대표 `releaseDate`: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 최근 `releaseDate` + - `price desc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 높은 가격 + - `price asc` 대표값: 각 시리즈의 조회 가능한 공개 콘텐츠 중 가장 낮은 가격 + +--- + +## 1. 파일 구조 계획 + +### 시리즈 탭 신규 API 조립 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt` + +### 시리즈 탭 도메인 조회 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt` + +### 기존 파일 확인/재사용 +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/domain/CreatorChannelPage.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/domain/CreatorChannelAudioQueryPolicy.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/SeriesContent.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt` + +### 문서 산출물 +- Create: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md` +- Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md` + +--- + +## 2. Response data class 초안 + +구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다. + +```kotlin +package kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab + +data class CreatorChannelSeriesTabResponse( + val seriesCount: Int, + val series: List, + val sort: ContentSort, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(tab: CreatorChannelSeriesTab): CreatorChannelSeriesTabResponse { + return CreatorChannelSeriesTabResponse( + seriesCount = tab.seriesCount, + series = tab.series.map(CreatorChannelSeriesResponse::from), + sort = tab.sort, + page = tab.page.page, + size = tab.page.size, + hasNext = tab.hasNext + ) + } + } +} + +data class CreatorChannelSeriesResponse( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val publishedDaysOfWeek: String, + @JsonProperty("isOriginal") + val isOriginal: Boolean, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isProceeding") + val isProceeding: Boolean, + val contentCount: Int, + val purchasedContentCount: Int?, + val paidContentCount: Int?, + val purchasedPaidContentRate: Int? +) { + companion object { + fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse { + return CreatorChannelSeriesResponse( + seriesId = series.seriesId, + title = series.title, + coverImageUrl = series.coverImageUrl, + publishedDaysOfWeek = series.publishedDaysOfWeek, + isOriginal = series.isOriginal, + isAdult = series.isAdult, + isProceeding = series.isProceeding, + contentCount = series.contentCount, + purchasedContentCount = series.purchasedContentCount, + paidContentCount = series.paidContentCount, + purchasedPaidContentRate = series.purchasedPaidContentRate + ) + } + } +} +``` + +--- + +## 3. Domain / Port 초안 + +구현 시 아래 형태를 기준으로 추가한다. API DTO가 domain model을 참조하지만 domain/port는 API DTO를 참조하지 않는다. + +```kotlin +package kr.co.vividnext.sodalive.v2.creator.channel.series.domain + +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage + +data class CreatorChannelSeriesTab( + val seriesCount: Int, + val series: List, + val sort: ContentSort, + val page: CreatorChannelPage, + val hasNext: Boolean +) + +data class CreatorChannelSeries( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val publishedDaysOfWeek: String, + val isOriginal: Boolean, + val isAdult: Boolean, + val isProceeding: Boolean, + val contentCount: Int, + val purchasedContentCount: Int?, + val paidContentCount: Int?, + val purchasedPaidContentRate: Int? +) +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.creator.channel.series.port.out + +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesState +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import java.time.LocalDateTime + +interface CreatorChannelSeriesQueryPort { + fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelSeriesCreatorRecord? + fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean + fun countSeries(creatorId: Long, now: LocalDateTime, canViewAdultContent: Boolean): Int + fun findSeries( + creatorId: Long, + viewerId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + sort: ContentSort, + locale: String, + offset: Long, + limit: Int + ): List +} + +data class CreatorChannelSeriesCreatorRecord( + val creatorId: Long, + val role: MemberRole, + val nickname: String +) + +data class CreatorChannelSeriesRecord( + val seriesId: Long, + val title: String, + val coverImagePath: String?, + val publishedDaysOfWeek: Set, + val isOriginal: Boolean, + val isAdult: Boolean, + val state: SeriesState, + val contentCount: Int, + val purchasedContentCount: Int?, + val paidContentCount: Int? +) +``` + +--- + +## 4. 작업 계획 + +### Phase 1: 순수 정책과 도메인 모델 추가 + +- [ ] **Task 1.1: `CreatorChannelSeriesQueryPolicy` 테스트 작성** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicy.kt` + - RED: 아래 케이스를 테스트로 먼저 작성한다. + - `sort == null`, `UNKNOWN`은 `ContentSort.LATEST`로 fallback한다. + - `page = -1`, `size = 10`은 `page=0`, `size=20`, `fetchLimit=21`이 된다. + - `page = 2`, `size = 100`은 `page=2`, `size=50`, `offset=100`, `fetchLimit=51`이 된다. + - `limitItems`는 `size`만큼만 남기고 `hasNext`는 `fetched.size > size`로 계산한다. + - 구매율은 `paidContentCount == 0`이면 `0`, `paid=4`, `purchased=3`이면 `75`, `paid=3`, `purchased=2`이면 `66`이다. + - `publishedDaysOfWeek`는 `RANDOM` 포함 시 다른 요일을 무시하고 locale별 랜덤 문구를 반환한다. + - 7개 요일은 locale별 매일 문구를 반환한다. + - 일부 요일은 `SUN`부터 `SAT` 순서로 locale별 `매주/Every/毎週` 문구를 반환한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` + - GREEN: `CreatorChannelAudioQueryPolicy`와 같은 page/sort/list 정책을 구현하되 `purchaseRate`는 `Int`를 반환한다. `publishedDaysOfWeekText(days, locale)`는 `ko`, `en`, `ja` 명시 매핑으로 구현한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` + - REFACTOR: `CreatorChannelAudioQueryPolicy`를 수정하지 않는다. 중복 제거는 이번 범위에서 하지 않는다. + +- [ ] **Task 1.2: 시리즈 탭 domain model과 port record 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesTab.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/port/out/CreatorChannelSeriesQueryPort.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/domain/CreatorChannelSeriesQueryPolicyTest.kt` + - RED: Task 1.1 테스트 컴파일이 새 domain/port 타입 부재로 실패하는 상태를 확인한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` + - GREEN: 문서의 Domain / Port 초안 그대로 타입을 추가한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` + - REFACTOR: domain/port가 `kr.co.vividnext.sodalive.v2.api.*` 패키지를 import하지 않는지 확인한다. + +### Phase 2: API 조립 계층 추가 + +- [ ] **Task 2.1: 응답 DTO mapper 테스트와 DTO 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt` + - RED: facade 테스트 또는 DTO mapper 테스트에서 `CreatorChannelSeriesTabResponse.from` 결과가 `seriesCount`, `series`, `sort`, `page`, `size`, `hasNext`, `coverImageUrl`, `purchasedPaidContentRate: Int?`를 그대로 매핑하는지 기대한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` + - GREEN: Response data class 초안대로 DTO와 mapper를 추가한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` + - REFACTOR: Jackson boolean property는 `@JsonProperty("isOriginal")`, `@JsonProperty("isAdult")`, `@JsonProperty("isProceeding")`, `@JsonProperty("hasNext")`로 명시한다. + +- [ ] **Task 2.2: Facade 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt` + - RED: `CreatorChannelSeriesFacade.getSeriesTab(creatorId, viewer, sort, page, size, now)`가 query service 호출 결과를 `CreatorChannelSeriesTabResponse`로 변환하는 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` + - GREEN: `CreatorChannelAudioFacade`와 같은 형태로 read-only service를 추가한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.application.CreatorChannelSeriesFacadeTest` + - REFACTOR: facade는 HTTP 예외 처리와 repository 세부 사항을 알지 않도록 query service에 위임한다. + +- [ ] **Task 2.3: Controller 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesControllerTest.kt` + - RED: MockMvc 테스트를 작성한다. + - `GET /api/v2/creator-channels/{creatorId}/series?sort=POPULAR&page=1&size=20` 요청이 facade에 `sort="POPULAR"`, `page=1`, `size=20`을 전달한다. + - 응답 JSON에 `seriesCount`, `series[0].seriesId`, `series[0].coverImageUrl`, `series[0].publishedDaysOfWeek`, `series[0].purchasedPaidContentRate`, `sort`, `page`, `size`, `hasNext`가 있다. + - 비회원 요청은 `common.error.bad_credentials` 계열 오류를 반환한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` + - GREEN: `CreatorChannelAudioController`와 같은 `@RequestMapping("/api/v2/creator-channels")`, `@GetMapping("/{creatorId}/series")`, `requireMember` 구조로 controller를 추가한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` + - REFACTOR: `sort`는 `String?`으로 받고 `ContentSort` enum binding 오류가 발생하지 않게 한다. + +### Phase 3: 도메인 조회 서비스 추가 + +- [ ] **Task 3.1: QueryService 인증/차단/creator 검증 테스트 작성** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt` + - RED: 아래 서비스 테스트를 작성한다. + - `findCreator`가 `null`이면 `member.validation.user_not_found` 예외를 던진다. + - creator role이 `CREATOR`가 아니면 `member.validation.creator_not_found` 예외를 던진다. + - 차단 관계가 있으면 기존 크리에이터 채널과 같은 blocked access 예외를 던진다. + - 정상 조회 시 policy가 보정한 sort/page를 사용해 port를 호출한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` + - GREEN: `CreatorChannelAudioQueryService` 흐름을 기준으로 `ObjectProvider`, `MemberContentPreferenceService`, `SodaMessageSource`, `LangContext`, `cloud.aws.cloud-front.host`를 주입받는 service를 추가한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` + - REFACTOR: 서비스는 repository record의 `coverImagePath`를 `String?.toCdnUrl(cloudFrontHost)`로 변환해 domain의 `coverImageUrl`에 채운다. + +- [ ] **Task 3.2: QueryService 응답 조립 테스트 작성** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryServiceTest.kt` + - RED: 아래 조립 테스트를 추가한다. + - 조회자가 creator 본인이면 각 series item의 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`가 `null`이다. + - 조회자가 creator가 아니면 `paidContentCount`, `purchasedContentCount`로 `purchasedPaidContentRate` 정수값을 계산한다. + - `coverImagePath`가 상대 경로이면 `cloudFrontHost`가 붙은 `coverImageUrl`로 변환되고, blank이면 `coverImageUrl == null`이다. + - `fetched.size == size + 1`이면 `hasNext == true`이고 응답 목록은 `size`개만 남는다. + - `publishedDaysOfWeek`는 policy의 locale별 문자열로 변환된다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` + - GREEN: service에서 `countSeries`, `findSeries` 결과를 조립하고 creator 본인 여부에 따라 구매 통계 필드를 null 처리한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` + - REFACTOR: 구매율 계산은 service에 직접 두지 않고 `CreatorChannelSeriesQueryPolicy.purchaseRate`를 사용한다. + +### Phase 4: QueryDSL repository 추가 + +- [ ] **Task 4.1: Repository creator/차단/count 테스트 작성** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/CreatorChannelSeriesQueryRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt` + - RED: repository 테스트를 작성한다. + - active creator를 `CreatorChannelSeriesCreatorRecord`로 조회한다. + - viewer와 creator 사이 차단 관계가 있으면 `existsBlockedBetween == true`다. + - `countSeries`는 `series.isActive == true`, `series.member.id == creatorId`, 성인 콘텐츠 노출 정책을 반영한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` + - GREEN: 오디오 탭 repository의 creator/차단 조회 패턴을 복사해 series 패키지용 repository를 추가한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` + - REFACTOR: count 쿼리는 목록 쿼리와 같은 공개 시리즈/성인 정책을 공유하는 private condition을 사용한다. + +- [ ] **Task 4.2: Repository 목록 필드/번역/통계 테스트 작성** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt` + - RED: `findSeries` 테스트를 작성한다. + - locale에 맞는 `SeriesTranslation` title이 있으면 번역명을 반환하고, 없거나 빈 문자열이면 원문 title을 반환한다. + - `coverImagePath`는 `Series.coverImage` 값을 반환한다. + - `contentCount`는 공개 콘텐츠 기준으로 계산한다. + - `paidContentCount`는 공개 콘텐츠 중 `price > 0`만 계산한다. + - `purchasedContentCount`는 viewer의 active 소장 주문과 만료되지 않은 active 대여 주문을 중복 없이 계산한다. + - 예약 공개 전 콘텐츠와 `releaseDate == null` 콘텐츠는 통계에서 제외한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` + - GREEN: `seriesContent`와 `audioContent`를 기준으로 시리즈 목록을 조회하고, 목록의 series id 묶음에 대해 콘텐츠 통계를 bulk 조회해 record에 채운다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` + - REFACTOR: N+1 조회가 생기지 않도록 `seriesIds` 기반 bulk map을 사용한다. + +- [ ] **Task 4.3: Repository 정렬 테스트 작성** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt` + - RED: 각 정렬별 순서 테스트를 작성한다. + - `LATEST`: 시리즈별 `max(audioContent.releaseDate) desc`, `max(audioContent.price) desc`, `series.id desc` + - `POPULAR`: `sum(orders.can) desc`, `max(audioContent.releaseDate) desc`, `series.id desc`; inactive order 제외 + - `OWNED`: viewer의 유효 소장/대여 콘텐츠 개수 desc, `max(audioContent.releaseDate) desc`, `series.id desc` + - `PRICE_HIGH`: `max(audioContent.price) desc`, `max(audioContent.releaseDate) desc`, `series.id desc` + - `PRICE_LOW`: `min(audioContent.price) asc`, `max(audioContent.releaseDate) desc`, `series.id desc` + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` + - GREEN: `groupBy(series.id)` 기반 QueryDSL 정렬을 구현한다. 정렬 대표값은 공개 콘텐츠 조건을 적용한 조인 결과에서 계산한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` + - REFACTOR: 콘텐츠가 없는 시리즈는 대표값이 없는 항목으로 같은 정렬 내 마지막에 오도록 null 정렬 처리를 테스트와 구현에 고정한다. + +### Phase 5: API 통합 검증 + +- [ ] **Task 5.1: End-to-End 테스트 추가** + - Files: + - Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesController.kt` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` + - RED: 실제 Spring context 기반 테스트를 작성한다. + - `GET /api/v2/creator-channels/{creatorId}/series`가 성공하고 PRD의 전체 응답 필드를 반환한다. + - invalid `sort`, 음수 `page`, 작은 `size`가 fallback되어 응답의 `sort/page/size`에 반영된다. + - 비크리에이터 viewer는 구매 통계 정수 비율을 받는다. + - creator 본인은 구매 통계 필드가 `null`이다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` + - GREEN: controller, facade, service, repository wiring 누락을 보완한다. + - 통과 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` + - REFACTOR: API 응답 필드명이 PRD와 다르면 PRD 또는 코드를 먼저 맞춘 뒤 테스트를 갱신한다. + +- [ ] **Task 5.2: 회귀 검증과 문서 검증 기록** + - Files: + - Modify: `docs/20260620_크리에이터_채널_시리즈_탭_API/plan-task.md` + - Verify: `docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md` + - RED: 문서와 코드 계약 차이를 확인한다. + - `rg -n "CreatorChannelHome.kt에 있는 CreatorChannelSeries|purchasedPaidContentRate|GET /api/v2/creator-channels/.*/series|PRICE_LOW|RANDOM" docs/20260620_크리에이터_채널_시리즈_탭_API` + - 실패 확인: 문서와 구현 계약이 불일치하면 해당 task를 완료하지 않는다. + - GREEN: 단일 테스트와 관련 회귀 테스트를 실행한다. + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest` + - 통과 확인: `./gradlew test` + - REFACTOR: Kotlin 포맷 검증은 `./gradlew ktlintCheck`로 확인한다. + - 문서 기록: 구현 완료 시 각 task 아래에 실행 명령, 성공/실패 결과, 수정 내용을 한국어로 누적 기록한다. + +--- + +## 5. 전체 검증 명령 + +구현 완료 후 아래 순서로 실행한다. + +```bash +./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicyTest +./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryServiceTest +./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepositoryTest +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesControllerTest +./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.series.adapter.in.web.CreatorChannelSeriesEndToEndTest +./gradlew test +./gradlew ktlintCheck +``` + +--- + +## 6. 계획 자체 검토 + +- PRD의 endpoint, request, response data class, 커버 이미지 URL, 정렬, 페이징, 구매 통계, 연재 요일 다국어, creator 본인/비본인 분기 요구사항을 task에 반영했다. +- 공개 API 조립 계층과 도메인 조회 계층을 분리했다. +- 기존 홈 API의 `CreatorChannelSeries` 확장은 계획에 포함하지 않았다. +- `purchasedPaidContentRate`는 `Int?`로 고정했다. +- `RANDOM` 포함 시 다른 요일을 무시하는 정책을 테스트 task에 포함했다. +- 시리즈별 정렬 대표값은 `max(releaseDate)`, `max(price)`, `min(price)`로 명시했다. +- Open Questions는 PRD 기준 없음. diff --git a/docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md b/docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md new file mode 100644 index 00000000..c2ddbbc3 --- /dev/null +++ b/docs/20260620_크리에이터_채널_시리즈_탭_API/prd.md @@ -0,0 +1,262 @@ +# PRD: 크리에이터 채널 시리즈 탭 API + +## 1. Overview +크리에이터 채널의 시리즈 탭에서 정렬별 시리즈 개수와 시리즈 목록을 페이징 조회하는 API를 제공한다. + +--- + +## 2. Problem +- 크리에이터 채널 시리즈 탭은 전체 시리즈 개수, 정렬 상태, 시리즈 목록을 함께 표시해야 한다. +- 기존 홈 API의 `CreatorChannelSeries`는 홈 화면용 요약 모델이라 시리즈 탭에서 필요한 연재 요일, 연재 상태, 콘텐츠 개수, 구매/유료 콘텐츠 통계를 모두 표현하지 못한다. +- 클라이언트는 시리즈 탭 진입과 추가 로딩 시 별도 API 조합 없이 일관된 계약으로 시리즈 목록을 받아야 한다. +- 연재 요일 문구는 서버에서 조합하고, 호출 유저의 언어에 맞게 반환해야 한다. +- 기존 크리에이터 채널 홈/라이브/오디오 탭 API endpoint와 응답 필드의 의미는 변경하지 않아야 한다. + +--- + +## 3. Goals +- 크리에이터 채널 시리즈 탭 조회 API를 제공한다. +- 클라이언트에서 호출하는 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.creator.channel.series` 하위 조립 계층에 둔다. +- 시리즈 목록, 시리즈 개수, 구매/유료 콘텐츠 통계, 연재 요일 조합처럼 재사용 가능한 조회 책임은 API 패키지 밖의 도메인 패키지에 둔다. +- 기존 홈 API의 `CreatorChannelSeries`는 확장하지 않고, 시리즈 탭 전용 도메인 모델과 응답 DTO를 새로 둔다. +- 요청은 `creatorId`, 정렬 순서, 페이징 값을 받는다. +- 정렬 순서를 보내지 않으면 최신순을 기본값으로 사용한다. +- 페이징 동작은 크리에이터 채널 오디오 탭 API와 같은 방식으로 처리한다. +- 응답에는 전체 시리즈 개수, 시리즈 목록, 실제 적용된 정렬 순서, page, size, hasNext를 포함한다. +- 시리즈 목록 item에는 시리즈 id, 제목, 커버 이미지 URL, 연재 요일 문구, 오리지널 여부, 19금 여부, 연재 중 여부, 전체 콘텐츠 개수를 포함한다. +- 조회자가 해당 시리즈의 크리에이터가 아닌 경우에는 구매한 콘텐츠 개수, 유료 콘텐츠 개수, 유료 콘텐츠 중 구매한 콘텐츠 비율도 포함한다. +- 시리즈 제목과 연재 요일 문구는 호출 유저 언어코드에 맞게 반환한다. + +--- + +## 4. Non-Goals +- 이번 범위는 크리에이터 채널 `시리즈` 탭 조회 API만 포함한다. +- 기존 크리에이터 채널 홈 API, 라이브 탭 API, 오디오 탭 API endpoint와 응답 필드의 의미는 변경하지 않는다. +- 시리즈 상세 조회 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}/series`를 기본안으로 한다. +- `creatorId`는 path variable로 받는다. +- 정렬 순서는 query parameter로 받는다. +- 정렬 순서 query parameter 이름은 `sort`를 기본안으로 한다. +- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다. +- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다. +- 시리즈 추가 로딩을 위해 `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한다. +- `page`가 0보다 작거나 `size`가 허용 범위를 벗어나도 400 오류를 반환하지 않고 실제 적용값으로 보정한다. + +### Feature B. 응답 스키마 + +#### Requirements +- 응답 DTO는 구현 전에 명시하고 공개 API 계약으로 관리한다. +- 응답 최상위 DTO 이름은 `CreatorChannelSeriesTabResponse`를 기본안으로 한다. +- 응답에는 다음 값을 포함한다. + - `seriesCount`: 조회 가능한 전체 시리즈 개수 + - `series`: 시리즈 목록 + - `sort`: 시리즈 조회에 실제 적용한 정렬 순서 + - `page`: 현재 응답의 page index + - `size`: 현재 응답의 page size + - `hasNext`: 다음 page 존재 여부 +- `seriesCount`는 sort-bar에 표시할 전체 개수이며, 콘텐츠 개수가 아니라 시리즈 개수다. +- `sort`는 요청값이 없거나 알 수 없는 값이면 실제 적용값인 `LATEST`를 내려준다. +- `page`, `size`는 fallback 보정 이후 실제 적용된 값을 내려준다. +- `hasNext`는 같은 정렬 조건에서 다음 page에 노출할 시리즈가 있으면 `true`로 내려준다. +- 조회자가 해당 시리즈의 크리에이터인 경우 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`는 `null`로 내려준다. +- 조회자가 해당 시리즈의 크리에이터가 아닌 경우 `purchasedContentCount`, `paidContentCount`, `purchasedPaidContentRate`를 계산해 내려준다. +- `purchasedPaidContentRate`는 정수 퍼센트 값으로 내려준다. +- `purchasedPaidContentRate`는 `paidContentCount == 0`이면 `0`으로 내려준다. +- `purchasedPaidContentRate`는 `(purchasedContentCount * 100) / paidContentCount`를 기준으로 계산하고 소수점 이하는 버린다. +- 응답 스키마 예시는 다음과 같다. + +```kotlin +data class CreatorChannelSeriesTabResponse( + val seriesCount: Int, + val series: List, + val sort: ContentSort, + val page: Int, + val size: Int, + val hasNext: Boolean +) + +data class CreatorChannelSeriesResponse( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val publishedDaysOfWeek: String, + val isOriginal: Boolean, + val isAdult: Boolean, + val isProceeding: Boolean, + val contentCount: Int, + val purchasedContentCount: Int?, + val paidContentCount: Int?, + val purchasedPaidContentRate: Int? +) + +enum class ContentSort { + LATEST, + POPULAR, + OWNED, + PRICE_HIGH, + PRICE_LOW +} +``` + +#### Edge Cases +- 공개된 시리즈가 없으면 `seriesCount`는 `0`, `series`는 빈 배열, `hasNext`는 `false`로 내려준다. +- 요청한 page 범위에 시리즈가 없으면 `series`는 빈 배열, `hasNext`는 `false`로 내려주되 `seriesCount`는 전체 개수를 유지한다. +- 무료 콘텐츠만 포함한 시리즈는 비크리에이터 조회 시 `paidContentCount`를 `0`, `purchasedContentCount`를 `0`, `purchasedPaidContentRate`를 `0`으로 내려준다. + +### Feature C. 시리즈 목록과 필드 + +#### Requirements +- 조회 대상은 지정한 `creatorId`의 시리즈로 제한한다. +- 공개 가능한 활성 시리즈만 조회한다. +- 조회자의 성인 콘텐츠 노출 정책이 false이면 19금 시리즈는 목록과 개수에서 제외한다. +- 시리즈에 속한 콘텐츠 통계는 공개된 오디오 콘텐츠만 기준으로 계산한다. +- 예약 공개 전 콘텐츠는 콘텐츠 통계에서 제외한다. +- `releaseDate == null`인 오디오 콘텐츠는 삭제/미공개 데이터로 보고 콘텐츠 통계에서 제외한다. +- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다. +- `coverImageUrl`은 시리즈의 커버 이미지 경로를 CDN URL로 변환해 내려준다. +- 시리즈 커버 이미지 경로가 없거나 빈 문자열이면 `coverImageUrl`은 `null`로 내려준다. +- 호출 유저 언어코드는 기존 `LangContext.lang.code` 값을 사용한다. +- 지원 언어코드는 `ko`, `en`, `ja`를 기준으로 한다. +- `isProceeding`은 `SeriesState.PROCEEDING`이면 `true`, 그 외 상태이면 `false`로 내려준다. +- `contentCount`는 조회 가능한 공개 콘텐츠 개수다. +- `paidContentCount`는 같은 필터를 적용한 콘텐츠 중 `price > 0`인 콘텐츠 개수다. +- `purchasedContentCount`는 같은 필터를 적용한 유료 콘텐츠 중 조회자가 유효하게 소장하거나 대여 중인 콘텐츠 개수다. +- 대여 중인 콘텐츠는 구매한 콘텐츠 개수와 `purchasedPaidContentRate` 계산에 포함한다. +- 유효 구매/대여 조건은 기존 오디오 탭과 동일하게 `orders.is_active = true`이며, 대여는 만료되지 않은 주문만 포함한다. + +#### Edge Cases +- 제목 번역 데이터는 있지만 빈 문자열이면 원문 시리즈명을 fallback으로 사용한다. +- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함하되 `contentCount`, `paidContentCount`, `purchasedContentCount`를 `0`으로 계산한다. +- 일반적으로 동일 콘텐츠의 소장과 대여가 동시에 발생하지 않지만, 데이터상 동시에 유효한 소장/대여 주문이 있으면 콘텐츠 1개로 중복 없이 계산한다. + +### Feature D. 연재 요일 문구 + +#### Requirements +- `publishedDaysOfWeek`는 서버에서 조합한 문자열로 내려준다. +- 일요일부터 토요일까지 7개 요일이 모두 있으면 호출 유저 언어에 맞는 `매일` 문구를 내려준다. +- 7개 요일이 모두 있지 않으면 호출 유저 언어에 맞는 `매주 {요일 목록}` 문구를 내려준다. +- 요일 목록은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT` 순서로 정렬한다. +- 한국어 예시는 `매일`, `매주 월, 목, 토`다. +- 영어 예시는 `Every day`, `Every Mon, Thu, Sat`다. +- 일본어 예시는 `毎日`, `毎週 月, 木, 土`다. +- `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 경우에는 다른 요일 값을 모두 무시하고 호출 유저 언어에 맞는 랜덤 문구만 내려준다. +- 랜덤 문구도 다국어 처리한다. +- 랜덤 문구는 한국어 `랜덤`, 영어 `Random`, 일본어 `ランダム`을 기본안으로 한다. + +#### Edge Cases +- 연재 요일이 비어 있으면 빈 문자열 대신 호출 유저 언어에 맞는 랜덤 문구를 fallback으로 내려준다. +- `RANDOM`과 다른 요일이 동시에 저장된 데이터는 `RANDOM`을 우선해 다른 요일을 제거한 것과 같은 결과로 랜덤 문구만 내려준다. + +### Feature E. 시리즈 정렬 + +#### Requirements +- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다. +- 공개 요청/응답 값은 다음을 사용한다. + - `LATEST`: 최신순, 기본값 + - `POPULAR`: 인기순 + - `OWNED`: 소장순 + - `PRICE_HIGH`: 높은 가격순 + - `PRICE_LOW`: 낮은 가격순 +- `LATEST`는 시리즈에 속한 콘텐츠의 `releaseDate desc`를 1차 정렬로 사용한다. +- `LATEST`의 2차 정렬은 시리즈에 속한 콘텐츠의 `price desc`다. +- `LATEST`의 3차 정렬은 `series.id desc`다. +- `POPULAR`은 시리즈에 속한 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)가 높은 시리즈를 먼저 노출한다. +- `POPULAR`의 매출 합계에는 `orders.is_active = true`인 주문만 포함한다. +- `POPULAR`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다. +- `POPULAR`의 3차 정렬은 `series.id desc`다. +- `OWNED`는 조회자가 시리즈에 속한 콘텐츠 중 유효하게 소장하거나 대여 중인 콘텐츠 개수가 많은 시리즈를 먼저 노출한다. +- `OWNED`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다. +- `OWNED`의 3차 정렬은 `series.id desc`다. +- `PRICE_HIGH`는 시리즈에 속한 콘텐츠의 `price desc`를 1차 정렬로 사용한다. +- `PRICE_HIGH`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다. +- `PRICE_HIGH`의 3차 정렬은 `series.id desc`다. +- `PRICE_LOW`는 시리즈에 속한 콘텐츠의 `price asc`를 1차 정렬로 사용한다. +- `PRICE_LOW`의 2차 정렬은 시리즈에 속한 콘텐츠의 `releaseDate desc`다. +- `PRICE_LOW`의 3차 정렬은 `series.id desc`다. +- 시리즈에 여러 콘텐츠가 속한 경우 정렬은 시리즈 단위 집계 대표값을 사용한다. +- 정렬용 `releaseDate`는 항상 내림차순 정렬에만 사용하므로 각 시리즈에 속한 공개 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다. +- `price desc` 정렬에 사용하는 가격 대표값은 각 시리즈에 속한 공개 콘텐츠 중 가장 높은 가격이다. +- `price asc` 정렬에 사용하는 가격 대표값은 각 시리즈에 속한 공개 콘텐츠 중 가장 낮은 가격이다. +- 따라서 `LATEST`의 2차 정렬과 `PRICE_HIGH`의 1차 정렬은 시리즈별 최고 가격을 사용하고, `PRICE_LOW`의 1차 정렬은 시리즈별 최저 가격을 사용한다. + +#### Edge Cases +- 매출이 없는 시리즈의 인기순 매출값은 0으로 처리한다. +- 조회자가 유효하게 소장하거나 대여 중인 콘텐츠가 없으면 `OWNED` 정렬은 최신순 + `series.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.series` 하위에 둔다. +- API 조립 계층은 HTTP 계약과 공개 응답 변환만 담당한다. +- 도메인 조회 코드는 `kr.co.vividnext.sodalive.v2.creator.channel.series` 또는 재사용 범위가 더 넓은 기존 도메인 패키지 하위에 둔다. +- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다. +- 기존 홈 API의 `CreatorChannelSeries`는 홈 응답 전용 요약 모델로 유지하고, 시리즈 탭 API에서는 별도 `CreatorChannelSeriesTab`, `CreatorChannelSeries` 계열 모델을 둔다. +- 기존 `ContentSort` enum을 재사용하고, API binding, service 정책, 테스트에서 같은 타입을 사용한다. +- 기존 크리에이터 채널 홈/라이브/오디오 API의 인증, 예외, 성인 콘텐츠 노출, 차단 관계 정책은 재사용한다. +- 페이징 응답은 기존 오디오 탭 API와 같은 `page`, `size`, `hasNext` 패턴을 따른다. +- 시리즈명 다국어 처리는 기존 `SeriesTranslation` 구조를 따른다. +- 연재 요일 문구 다국어 처리는 서버 코드의 명시적 매핑으로 처리한다. + +--- + +## 9. Metrics +- 시리즈 탭 API 성공/실패 건수 +- 시리즈 탭 API 응답 시간 +- 정렬 기준별 조회 건수 +- 시리즈 탭에서 추가 로딩 요청 건수 + +--- + +## 10. Open Questions +- 없음.