diff --git a/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md b/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md new file mode 100644 index 00000000..8f56ca3a --- /dev/null +++ b/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md @@ -0,0 +1,596 @@ +# 메인 콘텐츠 전체 탭 API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. + +**Goal:** `GET /api/v2/audio/contents`로 메인 콘텐츠 전체 탭의 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 정렬/요일/페이징 조건에 맞춰 조회한다. + +**Architecture:** 공개 API controller/facade/response DTO는 `kr.co.vividnext.sodalive.v2.api.content.all` 조립 계층에 둔다. 전체 탭 조회 service, 요청 보정 정책, domain model, port, QueryDSL repository는 `kr.co.vividnext.sodalive.v2.content.all` 하위에 두고 `v2.api.*`에 의존하지 않는다. 기존 `ContentSort`, `SeriesPublishedDaysOfWeek`, 콘텐츠 추천/채널 오디오/채널 시리즈 조회 패턴을 재사용하되 공개 응답 DTO는 전체 탭 전용으로 최소 필드만 둔다. + +**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, Spring MVC, Spring Security, Spring Data JPA, QueryDSL, JUnit 5, MockMvc, Gradle Wrapper + +--- + +## 0. 구현 전 확정 사항 + +- API endpoint: `GET /api/v2/audio/contents` +- 인증 정책: 비회원 조회 가능. 인증 회원이면 `MemberContentPreferenceService`의 성인 콘텐츠 노출 가능 여부를 반영한다. +- 응답 wrapper: `ApiResponse.ok(...)` +- 요청 query parameter: + - `type`: `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`; 기본값 `AUDIO` + - `sort`: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`; 기본값 `LATEST` + - `dayOfWeek`: `type=SERIES`에서만 적용. `SeriesPublishedDaysOfWeek` 값 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM` + - `page`: 0부터 시작. 기본값 `0` + - `size`: 기본값 `20`, 최소 `20`, 최대 `50` +- `sort`가 invalid이거나 `OWNED`이면 `LATEST`로 fallback한다. +- `dayOfWeek`가 invalid이면 요일 조건을 적용하지 않고 `dayOfWeek = null`로 fallback한다. +- `type != SERIES`이면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서 `null`로 내려준다. +- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다. +- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다. +- `AUDIO`, `FREE`, `POINT`는 `audios`만 채우고 `series`는 빈 배열로 내려준다. +- `SERIES`, `ORIGINAL`은 `series`만 채우고 `audios`는 빈 배열로 내려준다. +- 공개 오디오 조건: `audioContent.isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터. +- 공개 시리즈 조건: `series.isActive == true`, 활성 크리에이터. 성인 콘텐츠 노출 불가이면 `series.isAdult == false`. +- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠/시리즈는 제외한다. +- 신규 Entity와 DDL은 작성하지 않는다. +- `SecurityConfig`에는 `GET /api/v2/audio/contents` permitAll 설정을 추가한다. + +--- + +## 1. 파일 구조 계획 + +### 신규 API 조립 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt` + +### 신규 도메인 조회 계층 +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt` + +### 기존 설정/회귀 +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/common/domain/ContentSort.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/Series.kt` +- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.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/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt` + +--- + +## 2. Response data class 초안 + +구현 시 `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`에 아래 DTO를 기준으로 추가한다. 필드명은 공개 API 계약이므로 변경이 필요하면 먼저 PRD와 이 문서를 갱신한다. + +```kotlin +package kr.co.vividnext.sodalive.v2.api.content.all.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType + +data class MainContentAllTabResponse( + val type: MainContentAllType, + val totalCount: Int, + val audios: List, + val series: List, + val sort: ContentSort, + val dayOfWeek: SeriesPublishedDaysOfWeek?, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(tab: MainContentAll): MainContentAllTabResponse { + return MainContentAllTabResponse( + type = tab.type, + totalCount = tab.totalCount, + audios = tab.audios.map(MainContentAudioResponse::from), + series = tab.series.map(MainContentSeriesResponse::from), + sort = tab.sort, + dayOfWeek = tab.dayOfWeek, + page = tab.page.page, + size = tab.page.size, + hasNext = tab.hasNext + ) + } + } +} + +data class MainContentAudioResponse( + val audioContentId: Long, + val title: String, + val imageUrl: String?, + val price: Int, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isPointAvailable") + val isPointAvailable: Boolean, + @JsonProperty("isFirstContent") + val isFirstContent: Boolean, + @JsonProperty("isOriginalSeries") + val isOriginalSeries: Boolean, + val creatorNickname: String +) { + companion object { + fun from(audio: MainContentAllAudio): MainContentAudioResponse { + return MainContentAudioResponse( + audioContentId = audio.audioContentId, + title = audio.title, + imageUrl = audio.imageUrl, + price = audio.price, + isAdult = audio.isAdult, + isPointAvailable = audio.isPointAvailable, + isFirstContent = audio.isFirstContent, + isOriginalSeries = audio.isOriginalSeries, + creatorNickname = audio.creatorNickname + ) + } + } +} + +data class MainContentSeriesResponse( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val creatorNickname: String, + @JsonProperty("isOriginal") + val isOriginal: Boolean, + @JsonProperty("isAdult") + val isAdult: Boolean +) { + companion object { + fun from(series: MainContentAllSeries): MainContentSeriesResponse { + return MainContentSeriesResponse( + seriesId = series.seriesId, + title = series.title, + coverImageUrl = series.coverImageUrl, + creatorNickname = series.creatorNickname, + isOriginal = series.isOriginal, + isAdult = series.isAdult + ) + } + } +} +``` + +--- + +## 3. Domain / Port 초안 + +```kotlin +package kr.co.vividnext.sodalive.v2.content.all.domain + +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort + +enum class MainContentAllType { + AUDIO, + SERIES, + ORIGINAL, + FREE, + POINT +} + +data class MainContentAll( + val type: MainContentAllType, + val totalCount: Int, + val audios: List, + val series: List, + val sort: ContentSort, + val dayOfWeek: SeriesPublishedDaysOfWeek?, + val page: MainContentPage, + val hasNext: Boolean +) + +data class MainContentAllAudio( + val audioContentId: Long, + val title: String, + val imageUrl: String?, + val price: Int, + val isAdult: Boolean, + val isPointAvailable: Boolean, + val isFirstContent: Boolean, + val isOriginalSeries: Boolean, + val creatorNickname: String +) + +data class MainContentAllSeries( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val creatorNickname: String, + val isOriginal: Boolean, + val isAdult: Boolean +) + +data class MainContentPage( + val page: Int, + val size: Int +) { + val offset: Long = page.toLong() * size +} +``` + +```kotlin +package kr.co.vividnext.sodalive.v2.content.all.port.out + +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries +import java.time.LocalDateTime + +interface MainContentAllQueryPort { + fun countAudios( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyFree: Boolean = false, + onlyPointAvailable: Boolean = false + ): Int + + fun findAudios( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyFree: Boolean = false, + onlyPointAvailable: Boolean = false + ): List + + fun countSeries( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyOriginal: Boolean = false, + dayOfWeek: SeriesPublishedDaysOfWeek? = null + ): Int + + fun findSeries( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyOriginal: Boolean = false, + dayOfWeek: SeriesPublishedDaysOfWeek? = null, + locale: String + ): List +} +``` + +--- + +### Phase 1: 요청 보정 정책과 도메인 모델 + +- [x] **Task 1.1: 전체 탭 타입, page, 요청 보정 policy 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllType.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentPage.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicy.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAllQueryPolicyTest.kt` + - RED: 다음 테스트를 먼저 작성한다. + ```kotlin + @Test + fun shouldResolveDefaultsAndFallbacks() { + val policy = MainContentAllQueryPolicy() + + assertEquals(MainContentAllType.AUDIO, policy.resolveType(null)) + assertEquals(MainContentAllType.AUDIO, policy.resolveType("UNKNOWN")) + assertEquals(ContentSort.LATEST, policy.resolveSort(null)) + assertEquals(ContentSort.LATEST, policy.resolveSort("UNKNOWN")) + assertEquals(ContentSort.LATEST, policy.resolveSort("OWNED")) + assertEquals(ContentSort.POPULAR, policy.resolveSort("POPULAR")) + assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = null, size = null)) + assertEquals(MainContentPage(page = 0, size = 20), policy.createPage(page = -1, size = 1)) + assertEquals(MainContentPage(page = 2, size = 50), policy.createPage(page = 2, size = 100)) + } + ``` + - RED: `type=SERIES`일 때만 요일이 적용되는 테스트를 작성한다. + ```kotlin + @Test + fun shouldResolveDayOfWeekOnlyForSeriesType() { + val policy = MainContentAllQueryPolicy() + + assertEquals(SeriesPublishedDaysOfWeek.MON, policy.resolveDayOfWeek(MainContentAllType.SERIES, "MON")) + assertEquals(SeriesPublishedDaysOfWeek.RANDOM, policy.resolveDayOfWeek(MainContentAllType.SERIES, "RANDOM")) + assertNull(policy.resolveDayOfWeek(MainContentAllType.SERIES, "INVALID")) + assertNull(policy.resolveDayOfWeek(MainContentAllType.ORIGINAL, "MON")) + assertNull(policy.resolveDayOfWeek(MainContentAllType.AUDIO, "MON")) + } + ``` + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` + - GREEN: `resolveType(sort: String?)`, `resolveSort(sort: String?)`, `resolveDayOfWeek(type, dayOfWeek)`, `createPage(page, size)`, `limitItems`, `hasNext`를 최소 구현한다. + - REFACTOR: `OWNED` fallback과 invalid `dayOfWeek` fallback이 400으로 흐르지 않도록 controller에서 enum 직접 binding을 사용하지 않는 설계를 확인한다. + - 기대 결과: 요청 보정 정책이 순수 단위 테스트로 고정된다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 실행 시 `MainContentAllQueryPolicy`, `MainContentAllType`, `MainContentPage` 미구현 컴파일 실패를 확인했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 성공으로 기본값/fallback/page/hasNext 정책을 확인했다. + +- [x] **Task 1.2: 전체 탭 domain model 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/domain/MainContentAll.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt` + - RED: `MainContentAllTabResponse.from(...)`이 최소 필드만 변환하는 테스트를 작성한다. + ```kotlin + @Test + fun shouldMapDomainToResponseWithMinimalFields() { + val response = MainContentAllTabResponse.from( + MainContentAll( + type = MainContentAllType.SERIES, + totalCount = 1, + audios = emptyList(), + series = listOf( + MainContentAllSeries( + seriesId = 10L, + title = "시리즈", + coverImageUrl = "https://cdn/series.jpg", + creatorNickname = "creator", + isOriginal = true, + isAdult = false + ) + ), + sort = ContentSort.LATEST, + dayOfWeek = SeriesPublishedDaysOfWeek.MON, + page = MainContentPage(0, 20), + hasNext = false + ) + ) + + assertEquals(MainContentAllType.SERIES, response.type) + assertEquals(1, response.totalCount) + assertTrue(response.audios.isEmpty()) + assertEquals("creator", response.series.first().creatorNickname) + assertEquals(SeriesPublishedDaysOfWeek.MON, response.dayOfWeek) + } + ``` + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` + - GREEN: `MainContentAll`, `MainContentAllAudio`, `MainContentAllSeries`, response DTO를 최소 구현한다. + - REFACTOR: `MainContentAudioResponse`에 `duration`, `MainContentSeriesResponse`에 `publishedDaysOfWeek`, `isProceeding`, `contentCount`, `paidContentCount`가 없는지 소스와 테스트에서 확인한다. + - 기대 결과: 공개 응답 계약이 PRD와 일치한다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 실행 시 `MainContentAllTabResponse`, `MainContentAll` 계열 도메인 모델 미구현 컴파일 실패를 확인했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 성공으로 도메인→응답 DTO 변환과 boolean `is*` JSON 필드명을 확인했다. + +### Phase 2: API 조립 계층 + +- [x] **Task 2.1: facade 작성** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacade.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/application/MainContentAllFacadeTest.kt` + - RED: facade가 문자열 query parameter를 그대로 query service에 넘기고 응답 DTO로 변환하는 테스트를 작성한다. + ```kotlin + @Test + fun shouldDelegateToQueryServiceAndMapResponse() { + val service = FakeMainContentAllQueryService() + val facade = MainContentAllFacade(service) + + val response = facade.getContents( + type = "FREE", + sort = "PRICE_LOW", + dayOfWeek = "MON", + page = 1, + size = 30, + member = null + ) + + assertEquals("FREE", service.requestedType) + assertEquals("PRICE_LOW", service.requestedSort) + assertEquals("MON", service.requestedDayOfWeek) + assertEquals(MainContentAllType.FREE, response.type) + } + ``` + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest` + - GREEN: facade는 query service 호출과 `MainContentAllTabResponse.from(...)` 변환만 담당한다. + - REFACTOR: facade에 정렬, 요일, DB 조회 정책이 들어가지 않도록 확인한다. + - 기대 결과: API 조립 계층과 도메인 조회 계층의 책임이 분리된다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllFacade`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다. + - GREEN: 동일 명령 성공으로 facade가 문자열 query parameter와 `Member?`를 query service에 그대로 전달하고 응답 DTO로 변환함을 확인했다. + +- [x] **Task 2.2: controller와 보안 설정 추가** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllController.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllControllerTest.kt` + - RED: `GET /api/v2/audio/contents`가 비회원에게 `200 OK`를 반환하고 `type` 기본값을 service까지 전달하는 MockMvc 테스트를 작성한다. + ```kotlin + @Test + fun shouldAllowAnonymousAndUseDefaultType() { + mockMvc.get("/api/v2/audio/contents") + .andExpect { + status { isOk() } + jsonPath("$.data.type") { value("AUDIO") } + jsonPath("$.data.sort") { value("LATEST") } + } + } + ``` + - RED: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=1&size=30`이 query parameter를 facade로 전달하는 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest` + - GREEN: `@RequestMapping("/api/v2/audio/contents")`, `@RequestParam type: String?`, `sort: String?`, `dayOfWeek: String?`, `page: Int?`, `size: Int?`, optional `member: Member?`로 controller를 구현한다. + - GREEN: `SecurityConfig`에 `antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll()`을 추가한다. + - REFACTOR: `ContentSort`와 `SeriesPublishedDaysOfWeek`를 controller parameter에 직접 binding하지 않는지 확인한다. + - 기대 결과: 공개 endpoint, 비회원 허용, invalid parameter fallback을 위한 controller 계약이 고정된다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllController` 미구현 컴파일 실패를 확인했다. + - GREEN: 동일 명령 성공으로 비회원 `GET /api/v2/audio/contents` 200 OK, query parameter/member 전달, `SecurityConfig` permitAll 설정을 확인했다. + +### Phase 3: 조회 service와 port + +- [x] **Task 3.1: query port와 service 분기 작성** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt` + - RED: `AUDIO`, `FREE`, `POINT` type이 audio count/list port를 올바른 필터로 호출하는 fake port 테스트를 작성한다. + ```kotlin + @Test + fun shouldQueryAudiosByType() { + val port = FakeMainContentAllQueryPort() + val service = createService(port) + + service.getContents(type = "FREE", sort = "LATEST", dayOfWeek = null, page = 0, size = 20, member = null) + + assertEquals("audio", port.lastListKind) + assertTrue(port.lastOnlyFree) + assertFalse(port.lastOnlyPointAvailable) + } + ``` + - RED: `SERIES` type이 `dayOfWeek=MON`을 series count/list port에 전달하고 `ORIGINAL` type은 `onlyOriginal=true`, `dayOfWeek=null`로 호출하는 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` + - GREEN: service는 policy로 type/sort/day/page를 보정하고, `type`에 따라 port 메서드를 호출한다. + - GREEN: `limit = page.size + 1`로 조회한 뒤 `policy.limitItems(...)`와 `policy.hasNext(...)`를 적용한다. + - REFACTOR: service에는 QueryDSL 조건식이나 response DTO 변환을 두지 않는다. + - 기대 결과: type별 조회 분기, 전체 개수, `hasNext`, fallback 정책이 service 단위 테스트로 고정된다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllQueryPort`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다. + - GREEN: 동일 명령 성공으로 `AUDIO/FREE/POINT` audio 분기, `SERIES/ORIGINAL` series 분기, `limit = size + 1`, `hasNext` 처리를 확인했다. + +- [x] **Task 3.2: 성인 콘텐츠 노출 정책 연결** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt` + - RED: 비회원이면 `canViewAdultContent=false`, 회원이면 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 port에 전달하는 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` + - GREEN: 기존 `AudioRecommendationQueryService`와 같은 방식으로 성인 콘텐츠 노출 가능 여부를 계산한다. + - REFACTOR: 회원 id는 `member?.id`만 port에 전달하고, port/repository에서 차단 관계 제외 조건을 처리하게 둔다. + - 기대 결과: 비회원/회원 성인 콘텐츠 정책이 기존 v2 추천 탭과 일치한다. + - 검증 기록: + - RED: service 테스트 추가 후 `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 포함 Phase 2-3 테스트 명령 성공으로 비회원 `canViewAdultContent=false`, 회원 `MemberContentPreferenceService.canViewAdultContent(member)` 결과 전달을 확인했다. + +### Phase 4: QueryDSL repository + +- [x] **Task 4.1: audio count/list repository 구현** + - Files: + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/MainContentAllQueryRepository.kt` + - Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt` + - RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다. + - RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다. + - RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` + - GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다. + - GREEN: 인기순은 `orders.isActive == true`인 주문의 `orders.can.sum().coalesce(0)`만 사용하고 `orders.point`는 더하지 않는다. + - GREEN: `isFirstContent`는 크리에이터별 전체 공개 오디오 중 가장 먼저 공개된 콘텐츠인지로 계산한다. + - GREEN: `isOriginalSeries`는 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 계산하고 시리즈 미소속이면 `false`로 내려준다. + - REFACTOR: CDN URL 변환은 `toCdnUrl(cloudFrontHost)` 패턴을 사용한다. + - 기대 결과: 오디오/무료/포인트 조회의 필터, count, 정렬, 카드 필드가 repository 테스트로 고정된다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 `DefaultMainContentAllQueryRepository` 미구현 컴파일 실패를 확인했다. + - GREEN: 동일 명령 성공으로 공개 오디오 조건, 성인/차단 제외, 무료/포인트 필터, 가격/인기 정렬, CDN URL, 첫 콘텐츠, 오리지널 시리즈 여부를 확인했다. + +- [x] **Task 4.2: series count/list repository 구현** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt` + - RED: `SERIES` 조회가 활성 시리즈와 활성 크리에이터만 반환하고, 비회원은 성인 시리즈를 제외하며, 차단 관계 크리에이터의 시리즈를 제외하는 테스트를 작성한다. + - RED: `dayOfWeek=MON`이면 `series.publishedDaysOfWeek`에 `MON`이 포함된 시리즈만 반환하고 `dayOfWeek=RANDOM`이면 `RANDOM` 포함 시리즈만 반환하는 테스트를 작성한다. + - RED: `ORIGINAL` 조회가 `series.isOriginal == true`만 반환하고 `dayOfWeek`는 적용하지 않는 테스트를 작성한다. + - RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 시리즈 정렬 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` + - GREEN: `DefaultCreatorChannelSeriesQueryRepository.findSeriesIds(...)` 패턴을 참고해 시리즈 id 선조회 후 row를 원래 정렬 순서대로 조립한다. + - GREEN: 시리즈 정렬 대표값은 공개 오디오 기준 `max(releaseDate)`, `max(price)`, `min(price)`, `orders.can.sum()`을 사용한다. + - GREEN: 시리즈 응답 필드는 `seriesId`, `title`, `coverImageUrl`, `creatorNickname`, `isOriginal`, `isAdult`만 조립한다. + - REFACTOR: `MainContentSeriesResponse`에서 제외된 연재 요일/연재 상태/콘텐츠 통계 필드를 조회 응답 조립용으로 불필요하게 projection하지 않는다. + - 기대 결과: 시리즈/오리지널 조회의 요일 필터, count, 정렬, 최소 응답 필드가 repository 테스트로 고정된다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 repository 미구현 컴파일 실패를 확인했다. + - GREEN: 동일 명령 성공으로 활성 시리즈/크리에이터 조건, 성인/차단 제외, 요일 필터, ORIGINAL 필터, 대표 공개 오디오 기준 정렬, 최소 시리즈 응답 필드를 확인했다. + +### Phase 5: 공개 API 통합 검증 + +- [ ] **Task 5.1: controller-to-repository 통합 테스트 작성** + - Files: + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt` + - RED: Spring context 기반으로 `GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20`가 `audios`, `totalCount`, `sort`, `page`, `size`, `hasNext`를 반환하고 `series`는 빈 배열인 테스트를 작성한다. + - RED: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=POPULAR&page=0&size=20`가 `series`, `dayOfWeek=MON`, `audios=[]`를 반환하는 테스트를 작성한다. + - RED: `GET /api/v2/audio/contents?type=ORIGINAL&dayOfWeek=MON`이 `dayOfWeek=null`로 응답하고 오리지널 시리즈만 반환하는 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` + - GREEN: 테스트 fixture에 공개/비공개/성인/무료/포인트/요일별 시리즈/오리지널 시리즈/차단 관계 데이터를 구성하고 end-to-end 응답을 통과시킨다. + - REFACTOR: controller, facade, service, repository 경계가 단방향 의존을 유지하는지 import를 확인한다. + - 기대 결과: 실제 HTTP 경로에서 PRD의 주요 응답 계약이 검증된다. + +- [ ] **Task 5.2: 회귀 테스트와 포맷 검증** + - Files: + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/**` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/**` + - Verify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt` + - Modify: `docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md` + - RED: 이 task는 신규 동작 추가가 아니라 전체 회귀 검증 task이므로 별도 실패 테스트를 만들지 않는다. + - TDD 예외 사유: 앞선 task에서 기능별 실패 테스트를 작성했고, 이 task는 전체 suite와 문서 검증 기록 누적이 목적이다. + - 대체 검증 방법: + - `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*'` + - `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` + - `./gradlew ktlintCheck` + - `git diff --check` + - `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` + - GREEN: 위 명령이 모두 성공하고, 응답 DTO에 제거 대상 필드가 남아 있지 않음을 확인한다. + - REFACTOR: 검증 결과를 이 문서 하단 `검증 기록`에 누적한다. + - 기대 결과: 신규 API 패키지 테스트와 포맷 검증이 완료된다. + +--- + +## 4. 실행 명령 + +- 정책 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` +- DTO 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` +- Facade 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest` +- Controller 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest` +- Service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` +- Repository 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` +- End-to-end 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` +- 전체 신규 패키지 테스트: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` +- 포맷 검증: `./gradlew ktlintCheck` +- 문서 변경 후 명령 유효성 확인: `./gradlew tasks --all` + +--- + +## 5. 검증 기록 + +- 2026-06-25 Phase 1-3 RED/GREEN 검증 + - RED: Phase 1 정책/DTO 테스트 추가 후 `MainContentAllQueryPolicy`, `MainContentAllType`, `MainContentPage`, `MainContentAllTabResponse`, `MainContentAll` 계열 모델 미구현 컴파일 실패를 확인했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicyTest` 성공. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest` 성공. + - RED: Phase 2-3 facade/controller/service 테스트 추가 후 `MainContentAllFacade`, `MainContentAllController`, `MainContentAllQueryPort`, `MainContentAllQueryService` 미구현 컴파일 실패를 확인했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.application.MainContentAllFacadeTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllControllerTest --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 성공. + - 보강: `MainContentAllQueryServiceTest`에서 `AUDIO`, `FREE`, `POINT` audio 분기를 각각 독립 테스트로 검증하도록 분리했다. + - 참고: Phase 4 repository 구현 전이므로 Spring 전체 context에서 `MainContentAllQueryPort` 실제 bean 연결은 아직 범위 밖이다. + - 참고: 실제 머지/배포 전에는 Phase 4 repository adapter bean과 Phase 5 end-to-end 테스트를 구현한 뒤 Spring 전체 context 검증을 다시 수행해야 한다. +- 2026-06-25 Phase 4 RED/GREEN 검증 + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 실행 시 `DefaultMainContentAllQueryRepository` 미구현 컴파일 실패를 확인했다. + - GREEN: 동일 명령 성공으로 audio/series count/list repository의 공개 조건, 성인/차단 제외, FREE/POINT/ORIGINAL/dayOfWeek 필터, 정렬, CDN URL, 최소 응답 필드를 확인했다. +- 2026-06-25 Phase 4 코드 리뷰 및 검증 + - 리뷰: `DefaultMainContentAllQueryRepository.findSeries(...)`가 `locale` 파라미터를 받지만 `SeriesTranslation`을 조회하지 않아, PRD의 언어코드 기반 시리즈 제목 fallback 요구사항을 충족하지 못하는 것을 확인했다. + - 리뷰: `ContentSort.LATEST`의 오디오/시리즈 정렬에 `price` 대표값이 보조 정렬로 포함되어 있어, PRD의 `releaseDate desc, id desc` 기준과 다른 순서가 나올 수 있음을 확인했다. + - RED: `shouldSortAudiosByLatestReleaseDateAndIdOnly` 추가 후 `expected: <[2, 1]> but was: <[1, 2]>` 실패로 audio `LATEST`가 같은 공개일에서 price desc를 우선하는 문제를 재현했다. + - RED: `shouldFindSeriesWithTranslatedTitleFallback` 추가 후 `expected: but was: ` 실패로 series locale 번역 미적용 문제를 재현했다. + - RED: `shouldSortSeriesByPublicAudioRepresentatives` 보강 후 `expected: <[6, 5, 4]> but was: <[5, 4, 6]>` 실패로 series `LATEST`가 같은 대표 공개일에서 highestPrice desc를 우선하는 문제를 재현했다. + - GREEN: `findSeries(...)`에 `SeriesTranslation` left join과 blank fallback을 추가하고, audio/series `LATEST` 보조 정렬에서 price 대표값을 제거했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` 성공. + - GREEN: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.*'` 성공. + - GREEN: `./gradlew ktlintCheck` 성공. + - GREEN: `git diff --check` 성공. + - 확인: `rg -n "duration|publishedDaysOfWeek|isProceeding|contentCount|paidContentCount" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all` 실행 시 공개 DTO 소스에는 제거 대상 필드가 없고, DTO 테스트의 부재 검증만 검색되었다. + - 확인: 위 리뷰 항목 2건은 보강 테스트와 구현 수정으로 해결했다. diff --git a/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md b/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md new file mode 100644 index 00000000..e57fae30 --- /dev/null +++ b/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md @@ -0,0 +1,333 @@ +# PRD: 메인 콘텐츠 전체 탭 API + +## 1. Overview +메인 콘텐츠 탭의 내부 전체 탭에서 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다. + +--- + +## 2. Problem +- 기존 메인 콘텐츠 추천 탭 API는 여러 추천 섹션을 한 번에 조립하지만, 전체 탭은 사용자가 선택한 구분별 전체 콘텐츠 목록과 전체 개수, 정렬 상태, 페이징 상태를 제공해야 한다. +- 기존 크리에이터 채널 오디오/시리즈 탭 API는 특정 크리에이터 기준 조회라서, 전체 탭처럼 차단 관계가 아닌 모든 크리에이터의 공개 콘텐츠를 대상으로 하기 어렵다. +- 정렬 기준은 기존 공용 `ContentSort` enum과 의미를 공유해야 하며, 인기순 매출 산식은 포인트 사용액을 제외한 `orders.can` 합계로 명확히 고정해야 한다. +- V2 패키지에는 API 조립 계층과 도메인 조회 계층 분리, 공통 오디오 카드 DTO, 차단/성인 콘텐츠/공개 콘텐츠 필터, 시리즈 정렬 패턴이 이미 있으므로 재사용 범위를 먼저 명시해야 한다. + +--- + +## 3. Goals +- 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다. +- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다. +- 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다. +- 공개된 콘텐츠만 조회한다. +- 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다. +- 비회원은 19금 콘텐츠를 노출하지 않는다. +- 인증 회원은 기존 콘텐츠 조회 설정에 따라 19금 콘텐츠 노출 가능 여부를 반영한다. +- 전체 콘텐츠 개수와 페이징 목록을 함께 응답한다. +- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다. +- `SERIES` 구분은 legacy 시리즈 메인 요일별 조회와 동일하게 요일 선택을 지원한다. +- PRD에 API endpoint와 Response data class 초안을 포함한다. + +--- + +## 4. Non-Goals +- 기존 `content.main.tab.*` legacy API 스키마를 변경하지 않는다. +- 기존 메인 콘텐츠 추천 탭 API와 랭킹 탭 API의 공개 스키마를 변경하지 않는다. +- 기존 크리에이터 채널 오디오/시리즈 탭 API의 endpoint, 응답 필드, 인증 정책을 변경하지 않는다. +- 신규 스냅샷 테이블이나 배치 집계는 이번 범위에 포함하지 않는다. +- 개인화 추천, 랜덤 노출, 운영자 고정/제외 기능은 포함하지 않는다. +- 구매, 대여, 소장, 포인트 결제 API는 포함하지 않는다. +- `ContentSort` enum에 신규 값을 추가하지 않는다. +- `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않는다. + +--- + +## 5. Target Users +- 회원: 메인 콘텐츠 전체 탭에서 원하는 구분의 공개 콘텐츠를 정렬해 탐색하는 사용자 +- 비회원: 인증 없이 조회 가능한 공개 콘텐츠를 탐색하는 사용자 +- 앱 클라이언트: 전체 탭의 구분, 전체 개수, 정렬 상태, 페이징 목록을 단일 계약으로 구성하려는 클라이언트 + +--- + +## 6. User Stories +- 사용자는 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다. +- 사용자는 선택한 요일의 시리즈 목록을 보고 싶다. +- 사용자는 오리지널 시리즈만 따로 보고 싶다. +- 사용자는 무료 오디오만 따로 보고 싶다. +- 사용자는 포인트를 사용할 수 있는 오디오만 따로 보고 싶다. +- 앱 클라이언트는 현재 적용된 구분, 정렬, page, size, hasNext를 응답에서 확인해 화면 상태와 서버 결과를 맞추고 싶다. + +--- + +## 7. Core Features + +### Feature A. 메인 콘텐츠 전체 탭 조회 API + +#### Requirements +- 신규 API endpoint는 `GET /api/v2/audio/contents`를 기본안으로 한다. +- 응답 wrapper는 기존 패턴과 동일하게 `ApiResponse.ok(...)`를 사용한다. +- 비회원 조회를 허용한다. +- 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다. +- 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다. +- `type` 값은 아래 enum으로 정의한다. + - `AUDIO`: 오디오 + - `SERIES`: 시리즈 + - `ORIGINAL`: 오리지널 + - `FREE`: 무료 + - `POINT`: 포인트 +- `type`을 보내지 않으면 `AUDIO`를 기본값으로 사용한다. +- `sort`를 보내지 않으면 `LATEST`를 기본값으로 사용한다. +- `sort` 값이 없거나 기존 `ContentSort` enum 값에 없으면 `LATEST`로 fallback한다. +- 전체 탭에서 지원하는 정렬 값은 `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW`다. +- `OWNED`가 들어오면 전체 탭 요구사항에 없는 정렬이므로 `LATEST`로 fallback한다. +- `dayOfWeek`는 `type=SERIES`일 때만 적용한다. +- `dayOfWeek` 값은 legacy `SeriesMainController.getDayOfWeekSeriesList(...)`와 동일하게 `SeriesPublishedDaysOfWeek` enum 값을 사용한다. +- `dayOfWeek` 지원 값은 `SUN`, `MON`, `TUE`, `WED`, `THU`, `FRI`, `SAT`, `RANDOM`이다. +- `dayOfWeek`를 보내지 않으면 전체 요일의 시리즈를 조회한다. +- `type`이 `SERIES`가 아니면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서는 `null`로 내려준다. +- `page`는 0부터 시작하는 page index로 처리한다. +- `page`를 보내지 않으면 기본값 `0`을 사용한다. +- `size`를 보내지 않으면 기본값 `20`을 사용한다. +- `page`가 0보다 작으면 `0`으로 fallback한다. +- `size`가 20보다 작으면 `20`으로 fallback한다. +- `size`가 50보다 크면 `50`으로 fallback한다. +- 응답에는 같은 필터 조건의 전체 콘텐츠 개수와 현재 page 목록을 포함한다. +- 다음 page 존재 여부는 `size + 1`개 조회 또는 동등한 방식으로 판단하되, 응답 목록에는 최대 `size`개만 내려준다. + +#### Edge Cases +- 공개된 콘텐츠가 없으면 `totalCount`는 `0`, 목록은 빈 배열, `hasNext`는 `false`로 내려준다. +- 요청한 page 범위에 콘텐츠가 없으면 목록은 빈 배열, `hasNext`는 `false`로 내려주되 `totalCount`는 전체 개수를 유지한다. +- 특정 구분에서 지원하지 않는 응답 목록 필드는 빈 배열로 내려준다. + +### Feature B. 공통 공개/차단/성인 콘텐츠 정책 + +#### Requirements +- 모든 구분은 공개 가능한 콘텐츠만 조회한다. +- 오디오 콘텐츠는 `isActive == true`, `duration != null`, `releaseDate != null`, `releaseDate <= now`, 활성 테마, 활성 크리에이터 조건을 만족해야 한다. +- 시리즈는 `isActive == true`, 활성 크리에이터 조건을 만족해야 한다. +- 시리즈의 콘텐츠 통계와 정렬 대표값은 공개 가능한 오디오 콘텐츠만 기준으로 계산한다. +- 회원이 차단했거나 회원을 차단한 크리에이터의 오디오/시리즈는 제외한다. +- 비회원은 19금 오디오/시리즈를 제외한다. +- 인증 회원은 `MemberContentPreferenceService`의 기존 성인 콘텐츠 노출 가능 여부를 반영한다. +- 이미지 경로는 기존 `v2.common.domain.CdnUrlExtensions`의 CDN URL 변환 패턴을 따른다. + +#### Edge Cases +- 차단 관계가 있는 크리에이터의 시리즈에 속한 오디오도 조회 대상에서 제외한다. +- 예약 공개 전 오디오는 모든 구분의 목록, 개수, 정렬 대표값, 매출 집계에서 제외한다. +- 비활성 크리에이터의 콘텐츠는 모든 구분에서 제외한다. + +### Feature C. 오디오 구분 + +#### Requirements +- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 공개 오디오 콘텐츠를 조회한다. +- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 오디오 콘텐츠 개수다. +- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. +- 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다. + +#### Edge Cases +- 시리즈에 속하지 않은 오디오도 목록에 포함한다. +- 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다. + +### Feature D. 시리즈 구분 + +#### Requirements +- `type=SERIES`는 차단 관계가 아닌 모든 크리에이터의 요일별 시리즈 콘텐츠를 조회한다. +- 활성 시리즈를 조회 대상으로 한다. +- `dayOfWeek`가 있으면 `series.publishedDaysOfWeek`에 해당 값이 포함된 시리즈만 조회한다. +- 요일 필터는 legacy `GET /audio-content/series/main/day-of-week`와 동일하게 query parameter 이름 `dayOfWeek`와 `SeriesPublishedDaysOfWeek` enum 값을 사용한다. +- `dayOfWeek`가 없으면 요일 조건 없이 전체 시리즈를 조회한다. +- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 시리즈 개수다. +- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다. +- 시리즈 제목은 호출 유저 언어코드에 맞는 번역값이 있으면 번역명을 사용하고, 없으면 원문 시리즈명을 사용한다. +- 응답 최상위 `dayOfWeek`에는 실제 적용된 요일 값을 내려준다. + +#### Edge Cases +- 콘텐츠가 없는 활성 시리즈는 시리즈 목록에 포함할 수 있다. +- `dayOfWeek=RANDOM` 요청은 legacy와 동일하게 `SeriesPublishedDaysOfWeek.RANDOM`이 포함된 시리즈만 조회한다. +- `dayOfWeek`가 지원 enum 값이 아니면 400 오류 대신 요일 조건을 적용하지 않는 fallback을 기본안으로 한다. + +### Feature E. 오리지널 구분 + +#### Requirements +- `type=ORIGINAL`은 차단 관계가 아닌 모든 크리에이터의 `isOriginal == true`인 시리즈를 조회한다. +- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `SERIES`와 동일하다. +- 단, `dayOfWeek` 요일 필터는 `type=ORIGINAL`에 적용하지 않는다. +- 응답 목록은 `series`에 내려주고 `audios`는 빈 배열로 내려준다. + +#### Edge Cases +- 오리지널 시리즈에 공개 가능한 오디오 콘텐츠가 없어도 활성 시리즈이면 목록에 포함한다. +- 19금 오리지널 시리즈는 조회자의 성인 콘텐츠 노출 가능 여부를 따른다. + +### Feature F. 무료 구분 + +#### Requirements +- `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다. +- 무료 오디오는 `price == 0`인 공개 오디오로 정의한다. +- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다. +- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. + +#### Edge Cases +- 무료 콘텐츠의 `PRICE_HIGH`와 `PRICE_LOW`는 가격이 모두 0일 수 있으므로 2차/3차 정렬인 `releaseDate desc`, `id desc`가 실제 순서를 결정할 수 있다. + +### Feature G. 포인트 구분 + +#### Requirements +- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다. +- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다. +- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다. +- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. + +#### Edge Cases +- 포인트 사용 가능 여부는 결제 가능 여부 필터일 뿐이며, 인기순 매출 산식에는 포인트 사용액을 포함하지 않는다. + +### Feature H. 콘텐츠 정렬 + +#### Requirements +- 정렬 순서는 기존 공용 `ContentSort` enum을 사용한다. +- 공개 요청/응답 값은 다음을 사용한다. + - `LATEST`: 최신순, 기본값 + - `POPULAR`: 인기순 + - `PRICE_HIGH`: 높은 가격순 + - `PRICE_LOW`: 낮은 가격순 +- `LATEST`는 `releaseDate desc`, `id desc` 순으로 정렬한다. +- `POPULAR`은 인기순 매출 내림차순, `releaseDate desc`, `id desc` 순으로 정렬한다. +- 인기순의 매출은 대여/소장 여부와 관계없이 해당 콘텐츠에 순수하게 결제된 캔 매출 합계(`orders.can`)를 기준으로 한다. +- 인기순 매출에는 포인트 사용액(`orders.point`)을 포함하지 않는다. +- 인기순 매출에는 `orders.isActive == true`인 주문만 포함한다. +- `PRICE_HIGH`는 `price desc`, `releaseDate desc`, `id desc` 순으로 정렬한다. +- `PRICE_LOW`는 `price asc`, `releaseDate desc`, `id desc` 순으로 정렬한다. +- 시리즈 정렬에서 `releaseDate`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 최근 `releaseDate`를 대표값으로 사용한다. +- 시리즈 정렬에서 `price desc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 높은 가격을 대표값으로 사용한다. +- 시리즈 정렬에서 `price asc`는 시리즈에 속한 공개 오디오 콘텐츠 중 가장 낮은 가격을 대표값으로 사용한다. +- 시리즈 인기순 매출은 시리즈에 속한 공개 오디오 콘텐츠의 `orders.can` 합계를 사용한다. + +#### Edge Cases +- 매출이 없는 오디오 또는 시리즈의 인기순 매출값은 0으로 처리한다. +- 콘텐츠가 없는 시리즈는 정렬 대표값이 없는 항목으로 처리해 같은 정렬 내 마지막에 노출한다. +- 가격이 같은 콘텐츠는 각 정렬의 2차/3차 기준을 따른다. + +--- + +## 8. API Endpoint + +```http +GET /api/v2/audio/contents?type=AUDIO&sort=LATEST&page=0&size=20 +Authorization: Bearer {accessToken} (optional) +``` + +- 비회원 조회를 허용한다. +- `SecurityConfig`에 `GET /api/v2/audio/contents` permitAll 설정을 추가한다. +- `type` 미지정 시 `AUDIO`를 기본값으로 사용한다. +- `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다. +- `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다. +- 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20` +- `page`, `size`는 기존 크리에이터 채널 오디오/시리즈 탭과 같은 보정 정책을 따른다. + +--- + +## 9. Response Data Class + +```kotlin +data class MainContentAllTabResponse( + val type: MainContentAllType, + val totalCount: Int, + val audios: List, + val series: List, + val sort: ContentSort, + val dayOfWeek: SeriesPublishedDaysOfWeek?, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) + +enum class MainContentAllType { + AUDIO, + SERIES, + ORIGINAL, + FREE, + POINT +} + +data class MainContentAudioResponse( + val audioContentId: Long, + val title: String, + val imageUrl: String?, + val price: Int, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isPointAvailable") + val isPointAvailable: Boolean, + @JsonProperty("isFirstContent") + val isFirstContent: Boolean, + @JsonProperty("isOriginalSeries") + val isOriginalSeries: Boolean, + val creatorNickname: String +) + +data class MainContentSeriesResponse( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val creatorNickname: String, + @JsonProperty("isOriginal") + val isOriginal: Boolean, + @JsonProperty("isAdult") + val isAdult: Boolean +) +``` + +--- + +## 10. Technical Constraints + +### 패키지 구조 +- 공개 API 조립 계층은 `kr.co.vividnext.sodalive.v2.api.content.all` 하위에 둔다. + - Controller: `...adapter.in.web` + - Facade: `...application` + - Response DTO: `...dto` +- 도메인 조회 계층은 `kr.co.vividnext.sodalive.v2.content.all` 하위에 둔다. + - Query service: `...application` + - 조회 정책/domain model: `...domain` + - 조회 port: `...port.out` + - QueryDSL/JPA 구현: `...adapter.out.persistence` +- 의존 방향은 `v2.api.content.all -> v2.content.all`만 허용한다. +- 도메인 패키지는 `kr.co.vividnext.sodalive.v2.api.*` 패키지에 의존하지 않는다. + +### V2 공통화/재사용 대상 +- `v2.common.domain.ContentSort`: 정렬 enum 재사용 +- `creator.admin.content.series.SeriesPublishedDaysOfWeek`: legacy와 같은 요일 query parameter enum 재사용 +- `content.series.main.SeriesMainController.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 API 계약 참고 +- `content.series.ContentSeriesService.getDayOfWeekSeriesList(...)`: legacy 요일별 시리즈 조회 service 흐름 참고 +- `v2.api.content.recommendation.adapter.in.web.AudioRecommendationController`: 비회원 허용 controller와 `ApiResponse.ok(...)` 패턴 +- `v2.api.content.recommendation.application.AudioRecommendationFacade`: API 조립 계층에서 domain 결과를 response DTO로 변환하는 패턴 +- `v2.content.recommendation.application.AudioRecommendationQueryService`: 회원 성인 콘텐츠 노출 가능 여부 계산과 전체 추천 조회 service 흐름 +- `v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepository`: 전체 공개 오디오 조회, 차단 크리에이터 제외, CDN URL 변환, `AudioCard` 조립 패턴 +- `v2.api.content.recommendation.dto.AudioCardResponse`: 오디오 카드 응답 필드와 `JsonProperty` 네이밍 패턴 +- `v2.api.creator.channel.series.dto.CreatorChannelSeriesResponse`: 시리즈 응답 필드와 `JsonProperty` 네이밍 패턴 참고 +- `v2.creator.channel.series.domain.CreatorChannelSeriesQueryPolicy`: `sort`, `page`, `size` fallback 정책 참고 +- `v2.creator.channel.series.adapter.out.persistence.DefaultCreatorChannelSeriesQueryRepository`: 시리즈 정렬 대표값, 시리즈 콘텐츠 통계, `orders.can` 매출 합산 패턴 참고 +- `v2.common.domain.CdnUrlExtensions`: 이미지 URL 변환 공통 함수 +- `MemberContentPreferenceService`: 성인 콘텐츠 노출 가능 여부 판단 +- `LangContext`: 시리즈 제목 다국어 처리 + +### 구현 주의사항 +- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다. +- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다. +- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다. +- 기존 채널 오디오/시리즈 탭의 `OWNED` 정렬은 전체 탭 요구사항에 포함하지 않으므로 전체 탭 policy에서 제외하거나 `LATEST`로 fallback한다. +- `POPULAR` 정렬은 기존 채널 탭 코드와 유사하되, 명시적으로 `orders.point`를 더하지 않고 `orders.can`만 집계한다. +- 오디오와 시리즈가 다른 응답 item 구조를 가지므로 최상위 응답은 `audios`와 `series`를 분리한다. +- 신규 Entity나 DDL은 필요하지 않다. + +--- + +## 11. Metrics +- 전체 탭 API 성공/실패 건수 +- 전체 탭 API 응답 시간 +- `type`별 조회 건수 +- `sort`별 조회 건수 +- 추가 로딩 요청 건수 + +--- + +## 12. Open Questions +- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.