From 561e181859469eb791ef6c416a29ec6666e612a8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 10 Jul 2026 10:16:27 +0900 Subject: [PATCH] =?UTF-8?q?docs(home):=20=EB=A9=94=EC=9D=B8=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=83=AD=20=EC=9C=A0=EB=A3=8C=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=EC=9D=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 57 ++++++++++++++++++- docs/20260624_메인_콘텐츠_전체_탭_API/prd.md | 26 ++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md b/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md index 0a65a823..9547423d 100644 --- a/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md +++ b/docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md @@ -2,7 +2,7 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. 각 단계는 체크박스(`- [ ]`)로 진행 상태를 갱신한다. -**Goal:** `GET /api/v2/audio/contents`로 메인 콘텐츠 전체 탭의 오디오, 시리즈, 오리지널, 무료, 포인트 목록을 정렬/요일/페이징 조건에 맞춰 조회한다. +**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는 전체 탭 전용으로 최소 필드만 둔다. @@ -25,6 +25,9 @@ - `dayOfWeek`가 invalid이면 요일 조건을 적용하지 않고 `dayOfWeek = null`로 fallback한다. - `type != SERIES`이면 `dayOfWeek`는 조회 조건에 적용하지 않고 응답에서 `null`로 내려준다. - `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다. +- `type=AUDIO`는 `price > 0`인 유료 공개 오디오만 조회한다. +- `type=FREE`는 `price == 0`인 무료 공개 오디오만 조회하며 `type=AUDIO` 결과와 겹치지 않는다. +- `type=POINT`는 `isPointAvailable == true` 조건을 유지하고 `type=AUDIO`의 유료 조건을 상속하지 않는다. - 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다. - `AUDIO`, `FREE`, `POINT`는 `audios`만 채우고 `series`는 빈 배열로 내려준다. - `SERIES`, `ORIGINAL`은 `series`만 채우고 `audios`는 빈 배열로 내려준다. @@ -243,6 +246,7 @@ interface MainContentAllQueryPort { memberId: Long?, canViewAdultContent: Boolean, now: LocalDateTime, + onlyPaid: Boolean = false, onlyFree: Boolean = false, onlyPointAvailable: Boolean = false ): Int @@ -254,6 +258,7 @@ interface MainContentAllQueryPort { sort: ContentSort, offset: Long, limit: Int, + onlyPaid: Boolean = false, onlyFree: Boolean = false, onlyPointAvailable: Boolean = false ): List @@ -559,6 +564,41 @@ interface MainContentAllQueryPort { - 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 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다. +### Phase 6: AUDIO 유료 오디오 필터 보강 + +- [x] **Task 6.1: service와 port 계약에 AUDIO 전용 유료 필터 추가** + - Files: + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt` + - 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: `type=AUDIO`가 audio count/list port를 `onlyPaid=true`, `onlyFree=false`, `onlyPointAvailable=false`로 호출하는 실패 테스트를 작성한다. + - RED: `type=FREE`는 `onlyFree=true`, `onlyPaid=false`를 유지하고, `type=POINT`는 `onlyPointAvailable=true`, `onlyPaid=false`를 유지하는 실패 테스트를 작성한다. + - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` + - GREEN: `MainContentAllQueryPort.countAudios(...)`, `findAudios(...)`에 `onlyPaid` 파라미터를 추가하고 `MainContentAllQueryService`의 `MainContentAllType.AUDIO` 분기에서만 `onlyPaid = true`를 전달한다. + - REFACTOR: `FREE`, `POINT` 분기에서 `AUDIO` 전용 유료 필터가 전파되지 않는지 fake port 검증 필드를 정리한다. + - 기대 결과: `resolvedType == MainContentAllType.AUDIO`일 때만 유료 필터가 port 계약에 명시된다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 실행 시 `MainContentAllQueryServiceTest.kt:37`의 `assertTrue(port.lastOnlyPaid)` 실패로 `AUDIO` 분기가 `onlyPaid=true`를 전달하지 않음을 확인했다. + - GREEN: 동일 명령 성공으로 `AUDIO`는 `onlyPaid=true`, `FREE`/`POINT`는 `onlyPaid=false`로 port에 전달됨을 확인했다. + +- [x] **Task 6.2: repository와 E2E에서 AUDIO 무료 제외 검증** + - 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` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt` + - RED: repository 테스트에 유료 오디오와 무료 오디오 fixture를 함께 만들고, `onlyPaid=true`인 `countAudios(...)`와 `findAudios(...)`가 `price == 0` 오디오를 제외하는 실패 테스트를 작성한다. + - RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다. + - RED: E2E 테스트에서 `GET /api/v2/audio/contents?type=AUDIO`와 type 미지정 기본 조회가 무료 오디오를 반환하지 않는 실패 테스트를 작성한다. + - 실패 확인: + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` + - `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` + - GREEN: repository의 오디오 조회 조건에 `onlyPaid=true`일 때만 `price > 0` 조건을 추가한다. + - REFACTOR: `onlyPaid`, `onlyFree`, `onlyPointAvailable` 조건이 각 type의 명시적 필터로만 적용되는지 테스트명과 fixture를 정리한다. + - 기대 결과: `AUDIO` 목록과 전체 개수에서 무료 콘텐츠가 제외되고, `FREE`/`POINT` 조회 계약은 기존 의미를 유지한다. + - 검증 기록: + - RED: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 실행 시 `DefaultMainContentAllQueryRepositoryTest.kt:70`, `MainContentAllEndToEndTest.kt:83` 실패로 `onlyPaid` 조건이 무료 오디오를 제외하지 못함을 확인했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 service, repository, E2E의 AUDIO 유료 필터를 확인했다. + --- ## 4. 실행 명령 @@ -570,6 +610,7 @@ interface MainContentAllQueryPort { - 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` +- AUDIO 유료 필터 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest --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` @@ -612,3 +653,17 @@ interface MainContentAllQueryPort { - 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 소스에는 제거 대상 필드가 없고, E2E fixture의 공개 조건 설정과 DTO 테스트의 부재 검증만 검색되었다. +- 2026-07-10 AUDIO 유료 콘텐츠 문서 선반영 + - 문서: `docs/20260624_메인_콘텐츠_전체_탭_API/prd.md`에 `type=AUDIO`는 `price > 0` 유료 공개 오디오만 조회하도록 요구사항을 보강했다. + - 문서: `docs/20260624_메인_콘텐츠_전체_탭_API/plan-task.md`에 `onlyPaid` port 계약 초안과 Phase 6 미완료 task를 추가했다. + - GREEN: `git diff --check` 성공. + - GREEN: `./gradlew tasks --all`은 최초 샌드박스 실행에서 `~/.gradle` lock 파일 접근 권한으로 실패했고, 권한 승인 후 재실행해 성공했다. + - 참고: 이번 단계는 사용자 요청에 따라 연관 문서만 수정했으며 production/test 코드는 변경하지 않았다. +- 2026-07-10 Phase 6 AUDIO 유료 필터 구현 검증 + - RED: service 테스트에서 `MainContentAllQueryServiceTest.kt:37`의 `assertTrue(port.lastOnlyPaid)` 실패로 `AUDIO` 분기의 `onlyPaid` 미전달을 확인했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest` 성공으로 `AUDIO`만 `onlyPaid=true`를 전달하고 `FREE`/`POINT`는 기존 필터 의미를 유지함을 확인했다. + - RED: repository/E2E 테스트에서 `DefaultMainContentAllQueryRepositoryTest.kt:70`, `MainContentAllEndToEndTest.kt:83` 실패로 `onlyPaid` 조건이 `price == 0` 무료 오디오를 제외하지 못함을 확인했다. + - GREEN: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.application.MainContentAllQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest` 성공으로 명시적 `type=AUDIO`와 기본 type 조회에서 무료 오디오가 제외됨을 확인했다. + - GREEN: `./gradlew test` 성공. + - GREEN: `./gradlew ktlintCheck` 성공. + - GREEN: `git diff --check` 성공. diff --git a/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md b/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md index e57fae30..ac416961 100644 --- a/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md +++ b/docs/20260624_메인_콘텐츠_전체_탭_API/prd.md @@ -1,7 +1,7 @@ # PRD: 메인 콘텐츠 전체 탭 API ## 1. Overview -메인 콘텐츠 탭의 내부 전체 탭에서 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다. +메인 콘텐츠 탭의 내부 전체 탭에서 유료 오디오, 시리즈, 오리지널, 무료, 포인트 구분별 공개 콘텐츠를 정렬과 페이징으로 조회하는 v2 API를 제공한다. --- @@ -17,6 +17,8 @@ - 메인 콘텐츠 전체 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다. - 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다. - 구분은 `AUDIO`, `SERIES`, `ORIGINAL`, `FREE`, `POINT`를 지원한다. +- `AUDIO` 구분은 유료 오디오 콘텐츠만 조회한다. +- `FREE` 구분은 무료 오디오 콘텐츠만 조회하며, `AUDIO` 구분 결과와 가격 조건이 겹치지 않는다. - 공개된 콘텐츠만 조회한다. - 회원이 차단했거나 회원을 차단한 크리에이터의 콘텐츠는 노출하지 않는다. - 비회원은 19금 콘텐츠를 노출하지 않는다. @@ -48,7 +50,7 @@ --- ## 6. User Stories -- 사용자는 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다. +- 사용자는 유료 오디오 콘텐츠 전체 목록을 최신순, 인기순, 가격순으로 보고 싶다. - 사용자는 선택한 요일의 시리즈 목록을 보고 싶다. - 사용자는 오리지널 시리즈만 따로 보고 싶다. - 사용자는 무료 오디오만 따로 보고 싶다. @@ -68,7 +70,7 @@ - 회원 조회 시 `@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?` 패턴을 사용한다. - 요청 query parameter는 `type`, `sort`, `dayOfWeek`, `page`, `size`를 사용한다. - `type` 값은 아래 enum으로 정의한다. - - `AUDIO`: 오디오 + - `AUDIO`: 유료 오디오 - `SERIES`: 시리즈 - `ORIGINAL`: 오리지널 - `FREE`: 무료 @@ -117,13 +119,15 @@ ### Feature C. 오디오 구분 #### Requirements -- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 공개 오디오 콘텐츠를 조회한다. -- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건을 적용한 오디오 콘텐츠 개수다. +- `type=AUDIO`는 차단 관계가 아닌 모든 크리에이터의 유료 공개 오디오 콘텐츠를 조회한다. +- 유료 오디오는 `price > 0`인 공개 오디오로 정의한다. +- 전체 개수는 같은 공개/차단/성인 콘텐츠 조건과 유료 조건을 적용한 오디오 콘텐츠 개수다. - 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. - 응답 item은 기존 추천 탭의 `AudioCardResponse` 필드 의미를 우선 재사용한다. #### Edge Cases -- 시리즈에 속하지 않은 오디오도 목록에 포함한다. +- 시리즈에 속하지 않은 유료 오디오도 목록에 포함한다. +- 무료 오디오는 `type=AUDIO` 목록과 개수에서 제외하고 `type=FREE`에서만 조회한다. - 오디오의 오리지널 여부는 기존 추천 탭과 동일하게 해당 오디오가 속한 시리즈의 `isOriginal` 기준으로 판단한다. ### Feature D. 시리즈 구분 @@ -161,7 +165,8 @@ #### Requirements - `type=FREE`는 차단 관계가 아닌 모든 크리에이터의 무료 오디오 콘텐츠를 조회한다. - 무료 오디오는 `price == 0`인 공개 오디오로 정의한다. -- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다. +- 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다. +- `type=FREE`는 `type=AUDIO`의 유료 조건을 상속하지 않는다. - 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. #### Edge Cases @@ -172,7 +177,8 @@ #### Requirements - `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다. - 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다. -- 정렬, 페이징, 전체 개수, 성인 콘텐츠 정책은 `AUDIO`와 동일하다. +- 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다. +- `type=POINT`는 `type=AUDIO`의 유료 조건을 상속하지 않고 `isPointAvailable == true` 조건만 추가한다. - 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. #### Edge Cases @@ -215,7 +221,7 @@ Authorization: Bearer {accessToken} (optional) - 비회원 조회를 허용한다. - `SecurityConfig`에 `GET /api/v2/audio/contents` permitAll 설정을 추가한다. -- `type` 미지정 시 `AUDIO`를 기본값으로 사용한다. +- `type` 미지정 시 `AUDIO`를 기본값으로 사용하며, 유료 오디오만 조회한다. - `sort` 미지정 또는 invalid 값은 `LATEST`로 fallback한다. - `type=SERIES`에서 요일 선택이 필요하면 `dayOfWeek`를 함께 보낸다. - 예: `GET /api/v2/audio/contents?type=SERIES&dayOfWeek=MON&sort=LATEST&page=0&size=20` @@ -310,6 +316,8 @@ data class MainContentSeriesResponse( - `LangContext`: 시리즈 제목 다국어 처리 ### 구현 주의사항 +- `type=AUDIO`는 `price > 0` 조건을 적용하고, `type=FREE`는 `price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다. +- `type=POINT`는 `isPointAvailable == true` 조건을 유지하며, `AUDIO` 전용 유료 필터를 암묵적으로 재사용하지 않는다. - 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다. - 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다. - 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.