Compare commits

..

10 Commits

54 changed files with 3113 additions and 146 deletions

View File

@@ -36,7 +36,7 @@
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%. - 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
- 최근 댓글 많은 오디오 스냅샷 저장 정책: 같은 크리에이터의 오디오 후보가 여러 개이면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다. - 최근 댓글 많은 오디오 스냅샷 저장 정책: 같은 크리에이터의 오디오 후보가 여러 개이면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다.
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다. - 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다. - 무료 오디오와 포인트 오디오는 가격 조건으로 분리하며, 무료/추천 또는 포인트/추천 섹션 사이의 중복만 허용한다.
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다. - `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다. - 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
@@ -357,7 +357,7 @@ interface AudioRecommendationQueryPort {
- Files: - Files:
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다. - RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다. 이 문장은 2026-06-23 당시 기준이며 2026-07-31 후속 요구사항 정정에서 두 섹션을 가격 조건으로 분리한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다. - GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다.
- REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다. - REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다.
@@ -721,3 +721,10 @@ interface AudioRecommendationQueryPort {
- 2026-07-12 문서 전용 후속 정책 기록: `mostCommentedAudios` 스냅샷 저장 시 같은 크리에이터의 오디오 후보는 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장하도록 PRD Feature H와 조회 정책, plan-task 확정 정책과 Phase 9 후속 구현 task를 갱신했다. 이번 작업은 문서만 작성했으므로 source code와 test code는 변경하지 않았고, 관련 RED/GREEN/REFACTOR 구현은 Task 9.1 미완료 상태로 남겼다. - 2026-07-12 문서 전용 후속 정책 기록: `mostCommentedAudios` 스냅샷 저장 시 같은 크리에이터의 오디오 후보는 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장하도록 PRD Feature H와 조회 정책, plan-task 확정 정책과 Phase 9 후속 구현 task를 갱신했다. 이번 작업은 문서만 작성했으므로 source code와 test code는 변경하지 않았고, 관련 RED/GREEN/REFACTOR 구현은 Task 9.1 미완료 상태로 남겼다.
- 2026-07-12 Phase 9 선행 구현 및 Phase 8 회귀 검증: 최근 댓글 많은 오디오 스냅샷 후보에서 같은 크리에이터의 여러 오디오 중 최고 점수 1개만 남기도록 repository dedupe를 구현했다. RED에서 같은 크리에이터 낮은 점수 후보가 함께 반환되는 실패를 확인했고, GREEN 후 Phase 9 focused test, Phase 8 관련 test/ktlint/tasks 검증이 모두 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-12 Phase 9 선행 구현 및 Phase 8 회귀 검증: 최근 댓글 많은 오디오 스냅샷 후보에서 같은 크리에이터의 여러 오디오 중 최고 점수 1개만 남기도록 repository dedupe를 구현했다. RED에서 같은 크리에이터 낮은 점수 후보가 함께 반환되는 실패를 확인했고, GREEN 후 Phase 9 focused test, Phase 8 관련 test/ktlint/tasks 검증이 모두 `BUILD SUCCESSFUL`로 통과했다.
## 2026-07-31 후속 요구사항 정정
- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다.
- 포인트 오디오를 `isPointAvailable == true`만으로 조회하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다.
- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다.
- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md``P1-T1`, `P2-T1`에서 추적한다.

View File

@@ -72,7 +72,7 @@
- `mostCommentedAudios`: 최대 5개 - `mostCommentedAudios`: 최대 5개
- `recommendedAudios`: 최대 20개 - `recommendedAudios`: 최대 20개
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다. - 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다. - 무료 오디오와 포인트 오디오는 가격 조건상 중복 노출하지 않는다. 무료/추천 또는 포인트/추천 섹션 사이의 중복은 허용한다.
#### Edge Cases #### Edge Cases
- 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다. - 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
@@ -134,7 +134,7 @@
#### Requirements #### Requirements
- 포인트 사용 가능 오디오 중 랜덤으로 최대 15개 조회한다. - 포인트 사용 가능 오디오 중 랜덤으로 최대 15개 조회한다.
- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다. - 포인트 오디오는 `isPointAvailable = true`이면서 `price > 0`인 공개 오디오로 정의한다.
- Response는 공통 오디오 카드 응답을 사용한다. - Response는 공통 오디오 카드 응답을 사용한다.
### Feature H. 최근 댓글이 많은 오디오 ### Feature H. 최근 댓글이 많은 오디오
@@ -285,7 +285,8 @@ data class CommentedAudioResponse(
- 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다. - 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다. - New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다. - 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다. - 무료 오디오와 포인트 오디오는 각각 `price == 0`, `isPointAvailable == true && price > 0` 조건으로 분리한다.
- 무료/추천 또는 포인트/추천처럼 그 밖의 추천 섹션 간 중복은 서버에서 제거하지 않는다.
- `mostCommentedAudios` 스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면 `findMostCommentedSnapshots`의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots`는 그 결과만 저장한다. - `mostCommentedAudios` 스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면 `findMostCommentedSnapshots`의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots`는 그 결과만 저장한다.
--- ---
@@ -302,3 +303,14 @@ data class CommentedAudioResponse(
## 12. Open Questions ## 12. Open Questions
- 없음 - 없음
---
## 13. 후속 요구사항 정정
| 날짜 | 상태 | 정정 내용 | 기준 문서 |
|---|---|---|---|
| 2026-07-31 | 확정 | 무료 콘텐츠와 포인트 결제 가능 콘텐츠를 분리하고 `pointAudios`에서 `price == 0`을 제외한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
- 공통 `AudioCardResponse.isPointAvailable`은 저장값을 그대로 노출하지 않고 `storedIsPointAvailable && price > 0`으로 응답한다.
- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다.

View File

@@ -27,7 +27,7 @@
- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다. - `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다.
- `type=AUDIO``price > 0`인 유료 공개 오디오만 조회한다. - `type=AUDIO``price > 0`인 유료 공개 오디오만 조회한다.
- `type=FREE``price == 0`인 무료 공개 오디오만 조회하며 `type=AUDIO` 결과와 겹치지 않는다. - `type=FREE``price == 0`인 무료 공개 오디오만 조회하며 `type=AUDIO` 결과와 겹치지 않는다.
- `type=POINT``isPointAvailable == true` 조건을 유지하고 `type=AUDIO`의 유료 조건을 상속하지 않는다. - `type=POINT``isPointAvailable == true && price > 0` 조건을 사용하고 목록과 count에 동일하게 적용한다.
- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다. - 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다.
- `AUDIO`, `FREE`, `POINT``audios`만 채우고 `series`는 빈 배열로 내려준다. - `AUDIO`, `FREE`, `POINT``audios`만 채우고 `series`는 빈 배열로 내려준다.
- `SERIES`, `ORIGINAL``series`만 채우고 `audios`는 빈 배열로 내려준다. - `SERIES`, `ORIGINAL``series`만 채우고 `audios`는 빈 배열로 내려준다.
@@ -493,7 +493,7 @@ interface MainContentAllQueryPort {
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.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` - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
- RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다. - RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다.
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다. - RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다. 이 문장은 2026-06-25 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다.
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다. - RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다.
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest` - 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
- GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다. - GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다.
@@ -587,7 +587,7 @@ interface MainContentAllQueryPort {
- 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/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.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: repository 테스트에 유료 오디오와 무료 오디오 fixture를 함께 만들고, `onlyPaid=true`인 `countAudios(...)`와 `findAudios(...)`가 `price == 0` 오디오를 제외하는 실패 테스트를 작성한다.
- RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다. - RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다. 이 문장은 2026-07-10 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다.
- RED: E2E 테스트에서 `GET /api/v2/audio/contents?type=AUDIO`와 type 미지정 기본 조회가 무료 오디오를 반환하지 않는 실패 테스트를 작성한다. - 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.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
@@ -667,3 +667,11 @@ interface MainContentAllQueryPort {
- GREEN: `./gradlew test` 성공. - GREEN: `./gradlew test` 성공.
- GREEN: `./gradlew ktlintCheck` 성공. - GREEN: `./gradlew ktlintCheck` 성공.
- GREEN: `git diff --check` 성공. - GREEN: `git diff --check` 성공.
## 2026-07-31 후속 요구사항 정정
- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다.
- `type=POINT`가 `isPointAvailable == true`만 사용하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다.
- POINT 목록, count와 `hasNext` 후보는 같은 보정 조건을 사용하고 무료 오디오는 제외한다.
- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다.
- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`의 `P1-T1`, `P2-T1`에서 추적한다.

View File

@@ -176,9 +176,9 @@
#### Requirements #### Requirements
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다. - `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다. - 포인트 오디오는 `isPointAvailable == true`이면서 `price > 0`인 공개 오디오로 정의한다.
- 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다. - 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다.
- `type=POINT``type=AUDIO`의 유료 조건을 상속하지 않고 `isPointAvailable == true` 조건만 추가한다. - `type=POINT``isPointAvailable == true && price > 0` 조건을 사용하고 목록과 전체 개수에 동일하게 적용한다.
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다. - 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
#### Edge Cases #### Edge Cases
@@ -317,7 +317,7 @@ data class MainContentSeriesResponse(
### 구현 주의사항 ### 구현 주의사항
- `type=AUDIO``price > 0` 조건을 적용하고, `type=FREE``price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다. - `type=AUDIO``price > 0` 조건을 적용하고, `type=FREE``price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다.
- `type=POINT``isPointAvailable == true` 조건을 유지하며, `AUDIO` 전용 유료 필터를 암묵적으로 재사용하지 않는다. - `type=POINT``isPointAvailable == true && price > 0` 조건을 사용하며, 목록과 count가 같은 조건 함수를 공유한다.
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다. - 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다. - 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다. - 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.
@@ -339,3 +339,14 @@ data class MainContentSeriesResponse(
## 12. Open Questions ## 12. Open Questions
- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다. - 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.
---
## 13. 후속 요구사항 정정
| 날짜 | 상태 | 정정 내용 | 기준 문서 |
|---|---|---|---|
| 2026-07-31 | 확정 | `type=POINT`에서 무료 콘텐츠를 제외하고 목록·`totalCount`·`hasNext` 후보에 같은 조건을 적용한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
- `MainContentAudioResponse.isPointAvailable``storedIsPointAvailable && price > 0`으로 응답한다.
- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다.

View File

@@ -4,10 +4,10 @@
| 항목 | 내용 | | 항목 | 내용 |
|---|---| |---|---|
| 상태 | 3차 Phase별 리뷰 완료 | | 상태 | Jenkins 회귀 수정 완료 |
| 확정일 | 2026-07-31 | | 확정일 | 2026-08-03 |
| 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 | | 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 |
| 현재 Phase | Phase 1~7 3차 리뷰 완료 | | 현재 Phase | Phase 1~7 `P5-R2` 완료 |
| 현재 활성 Goal | 없음 | | 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 | | 다음 Goal | 없음 |
@@ -318,6 +318,20 @@
- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다. - [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다.
- [x] **REFACTOR/GATE:** sleep 없이 `RecommendationSnapshotFallbackServiceTest` focused test와 `git diff --check`를 실행해 결과를 누적한다. - [x] **REFACTOR/GATE:** sleep 없이 `RecommendationSnapshotFallbackServiceTest` focused test와 `git diff --check`를 실행해 결과를 누적한다.
#### Task R5.2 Jenkins single-flight 테스트 scheduling race 제거
**Goal 실행 `P5-R2`:** 두 동시 요청이 pending refresh future를 공유하는 계약을 worker 완료 순서에 의존하지 않고 검증한다.
- **시작 조건:** Jenkins에서 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests`의 두 번째 request `Future.get(...)``TimeoutException`으로 실패하고, `REV-P5-002`가 테스트 scheduling race로 확정됨.
- **완료 증거:** Jenkins 실패 원인 기록, 결정적 테스트 최소 수정, fallback focused test 반복 통과, 직접 영향 회귀·format·문서 명령·diff 검증 결과 누적.
- **범위 밖:** `RecommendationSnapshotFallbackService` production 동작, worker 수, 300ms lock 대기와 1,500ms 홈 대기, 다른 fallback 섹션 변경.
- [x] **RED:** Jenkins 실패의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 focused test 통과를 함께 기록해 worker scheduling에 따라 결과가 달라지는 기존 테스트를 확인한다.
- [x] **RED 확인:** `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'`의 로컬 통과와 Jenkins 실패를 대조해 비결정성을 확인한다.
- [x] **GREEN:** 수동 `CapturingExecutor.runNext()` 완료 경쟁 대신 refresh worker를 latch로 pending 상태에 유지한다. 두 요청이 홈 대기 timeout으로 반환할 때까지 refresh를 완료하지 않고 `refreshCheerCreatorSnapshots(...)` 호출이 1회인지 검증한다.
- [x] **GREEN 확인:** 같은 focused test를 반복 실행하고 `RecommendationSnapshotFallbackServiceTest` 전체를 실행해 single-flight, timeout 후 worker 지속, lock 내부 double-check가 모두 통과하는지 확인한다.
- [x] **REFACTOR/GATE:** 사용하지 않는 test helper/import만 제거하고 `ktlintCheck`, `./gradlew tasks --all`, `git diff --check` 결과를 전체 검증 기록에 누적한다.
--- ---
### Phase 6: API 회귀와 최종 검증 ### Phase 6: API 회귀와 최종 검증
@@ -440,6 +454,8 @@ git diff --check
- Non-Goals: Task 4.1, Task 6.1, Task 6.3, Task 7.1에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 16명 밖 후보 보충 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다. - Non-Goals: Task 4.1, Task 6.1, Task 6.3, Task 7.1에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 16명 밖 후보 보충 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다.
## 전체 검증 기록 ## 전체 검증 기록
- 2026-08-03: `P5-R2` GREEN/GATE로 수동 `CapturingExecutor` 완료 경쟁을 제거하고, 실제 worker를 latch로 pending 상태에 유지한 채 두 요청의 worker task 제출과 `refreshCheerCreatorSnapshots(...)` 호출이 각각 1회인지 검증하도록 수정했다. focused test는 최초 1회와 연속 10회 모두 통과했고, `RecommendationSnapshotFallbackServiceTest` 전체, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `./gradlew cleanTest test`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. 전체 테스트는 7분 14초 소요됐다. Oracle 고강도 리뷰도 correctness·결정성·thread cleanup·production 무변경 판단에 blocker 없이 승인했다.
- 2026-08-03: `P5-R2` RED 확인으로 Jenkins의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'` `BUILD SUCCESSFUL`을 대조했다. 같은 코드가 worker scheduling에 따라 실패·성공하여 기존 테스트의 `taskSubmitted` latch가 두 요청의 동일 future 대기를 보장하지 않는 비결정성을 확인했다.
- 2026-07-31: Phase 1~7 3차 리뷰로 PRD·plan-task·현재 코드·테스트를 정적으로 대조했다. 이전 리뷰에서 보완한 문서 정합성, 종료 라이브 데뷔 이력, `CHEER_CREATOR` refresh 실패 로그, fallback single-flight·double-check, 공개 응답 3개 필드, 본인·활성 팔로우 제외가 현재 구현과 회귀 테스트에 유지됨을 확인했다. 각 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 3차 리뷰로 별도 누적했다. 추가 확정 발견 사항이 없어 신규 회귀 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 뒤 승인된 동일 명령에서 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-31: Phase 1~7 3차 리뷰로 PRD·plan-task·현재 코드·테스트를 정적으로 대조했다. 이전 리뷰에서 보완한 문서 정합성, 종료 라이브 데뷔 이력, `CHEER_CREATOR` refresh 실패 로그, fallback single-flight·double-check, 공개 응답 3개 필드, 본인·활성 팔로우 제외가 현재 구현과 회귀 테스트에 유지됨을 확인했다. 각 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 3차 리뷰로 별도 누적했다. 추가 확정 발견 사항이 없어 신규 회귀 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 뒤 승인된 동일 명령에서 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-31: `P3-R2``CHEER_CREATOR` 데뷔 CTE가 채널명이 있는 종료 라이브를 데뷔 이력으로 인정하도록 복구했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut``AssertionFailedError`로 실패하는 것을 확인했다. 이후 `findCheerCreatorSnapshots(...)` 라이브 branch에서 `lr.is_active = true`만 제거했고, 같은 repository focused test는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다. - 2026-07-31: `P3-R2``CHEER_CREATOR` 데뷔 CTE가 채널명이 있는 종료 라이브를 데뷔 이력으로 인정하도록 복구했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut``AssertionFailedError`로 실패하는 것을 확인했다. 이후 `findCheerCreatorSnapshots(...)` 라이브 branch에서 `lr.is_active = true`만 제거했고, 같은 repository focused test는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다.
- 2026-07-31: `P1-R2`로 상단 후속 변경 상태를 현재 PRD 전체, Phase 1~7 판정, 실제 미완료 Goal `P3-R2` 기준으로 정리했다. 문서 정합성 복구만 수행해 TDD 예외로 처리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all``BUILD SUCCESSFUL`로 통과했다. - 2026-07-31: `P1-R2`로 상단 후속 변경 상태를 현재 PRD 전체, Phase 1~7 판정, 실제 미완료 Goal `P3-R2` 기준으로 정리했다. 문서 정합성 복구만 수행해 TDD 예외로 처리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all``BUILD SUCCESSFUL`로 통과했다.

View File

@@ -32,6 +32,7 @@
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal | | ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| `REV-P5-001` | Low | 보완 완료 | 동일 섹션 single-flight와 lock 내부 double-check의 직접 회귀 테스트가 없다 | Task R5.1 | `P5-R1` | | `REV-P5-001` | Low | 보완 완료 | 동일 섹션 single-flight와 lock 내부 double-check의 직접 회귀 테스트가 없다 | Task R5.1 | `P5-R1` |
| `REV-P5-002` | Medium | 보완 완료 | single-flight 테스트가 worker 완료 순서에 의존해 Jenkins에서 timeout된다 | Task R5.2 | `P5-R2` |
## 6. 발견 사항 상세 ## 6. 발견 사항 상세
@@ -102,3 +103,16 @@ sleep 대신 latch와 결정적 fake를 사용해 두 경쟁 조건을 고정하
- **plan·goal 전환:** 전환 항목 없음. - **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음. - **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음. - **남은 항목:** 없음.
---
## 11. 4차 리뷰 기록 — 2026-08-03
- **리뷰 대상:** Phase 5 / Task R5.1의 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests` Jenkins 실패.
- **검토 근거:** Jenkins `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`, `RecommendationSnapshotFallbackService.kt:141-149`, 테스트의 `CapturingExecutor`와 latch 흐름, 로컬 `cleanTest` focused test 실행, JDK 17 `CompletableFuture`·`CountDownLatch` 계약.
- **확정 발견 사항:** `REV-P5-002`. `taskSubmitted` latch는 worker task가 executor에 전달됐다는 사실만 보장하며, refresh future가 `refreshFutures`에 게시됐거나 두 요청이 같은 future에서 대기 중임을 보장하지 않는다. Jenkins에서 첫 worker가 먼저 완료되면 reference가 제거되고 두 번째 요청은 실행되지 않는 새 captured task를 기다려 바깥 `get(1s)`에서 timeout된다.
- **판정:** production single-flight 결함 근거는 없다. timeout 확대는 경쟁을 숨기므로 제외하고, refresh를 latch로 pending 상태에 유지한 채 두 요청의 refresh 호출 횟수 1회를 검증하도록 테스트만 수정한다.
- **plan·goal 전환:** `plan-task.md` Phase 5에 Task R5.2 / `P5-R2`를 추가했다.
- **검증 결과:** focused test 최초 1회와 연속 10회, `RecommendationSnapshotFallbackServiceTest` 전체, `ktlintCheck`, `tasks --all`, 전체 `cleanTest test`, `git diff --check`가 모두 통과했다. Oracle 고강도 리뷰도 blocker 없이 승인했다.
- **최종 결론:** `P5-R2` 보완 완료.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,410 @@
# 무료 콘텐츠 포인트 결제 불가 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 리뷰 후속 작업 완료 |
| 작성일 | 2026-07-31 |
| 요구사항 기준 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
| API 기준 | PRD `8. API 계약` |
| 현재 Phase | Phase 1 회귀 수정 완료 |
| 현재 활성 Goal | 없음 |
## 목표
무료 오디오 콘텐츠가 소비자 조회에서 포인트 결제 가능으로 노출되거나 POINT 전용 목록에 포함되지 않게 한다.
## 구현 방식
- 응답 DTO의 공개 필드와 내부 조회 record는 변경하지 않는다.
- 각 소비자 응답 조립 지점에서 `isPointAvailable && price > 0`을 적용한다.
- 추천과 전체 탭 POINT repository 조건에 `price > 0`을 추가한다.
- 관리자 mapper와 저장 로직은 수정하지 않는다.
- 단일 식 적용을 위한 새 공통 abstraction이나 dependency는 만들지 않는다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1. 소비자 응답 정규화 | 완료 | `3/3` | 없음 | 없음 |
| 2. POINT 조회 조건 보정 | 완료 | `1/1` | 없음 | 없음 |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
- goal에는 token budget을 설정하지 않는다.
## 범위
### 포함
- legacy 콘텐츠 상세 `isAvailableUsePoint` 보정
- v2 콘텐츠 overview·전체 탭·추천 탭 응답 보정
- v2 홈 추천 첫 오디오 응답 보정
- v2 크리에이터 채널 홈·오디오·라이브의 공통 오디오 응답 보정
- 추천 `pointAudios`와 전체 탭 `type=POINT`의 유료 조건 보강
- 전체 탭 POINT count·pagination 회귀 검증
- AI 캐릭터 관리자 조회 원본값 유지 회귀 검증
### 제외
- DB 저장값과 기존 데이터 변경
- 콘텐츠 생성·수정 validation 변경
- 관리자 콘텐츠 mapper·service 변경
- legacy 콘텐츠 상세 이외의 legacy 조회 API 변경
- 결제·주문·포인트 차감 로직 변경
- API schema와 dependency 변경
## 기술적 제약
- 기술 스택: Kotlin, Java 17, Spring Boot 2.7.14, JUnit 5, QueryDSL/JPA.
- 기존 package와 mapper/repository 책임을 유지한다.
- 공개 API 필드명 `isAvailableUsePoint`, `isPointAvailable`을 유지한다.
- `price == 0`은 무료, 실질 포인트 가능 여부는 `configured && price > 0`으로 고정한다.
- POINT 조회의 목록과 count는 같은 repository 조건을 사용한다.
- 모든 구현 Task는 `RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR` 순서로 실행한다.
- focused test부터 실행하고 최종 Gate에서 영향 범위 회귀와 전체 `test`를 실행한다.
## Phase 1: 소비자 응답 정규화
**Phase 결과:** 무료·저장값 true인 콘텐츠가 대상 소비자 응답에서는 false로 보이지만 관리자 조회에서는 true를 유지한다.
**선행조건:** PRD `POINT-001~004`, `POINT-007~008` 확정.
**Phase 완료 조건:** `P1-T1``P1-GATE` 완료, 검증 기록 누적.
**후속 리뷰 완료 조건:** `P1-R1` 완료, `P1-GATE` 재검증과 수정 후 검증 기록 누적.
### Task 1.1 소비자 응답의 실질 포인트 가능 여부 적용
**Goal 실행 `P1-T1`:** legacy 상세와 모든 대상 v2 응답 조립 경계에서 가격을 반영한 포인트 가능 여부를 반환한다.
- **시작 조건:** PRD `DEC-001`, `DEC-003` 확인.
- **완료 증거:** RED/GREEN 체크박스, 대상 mapper 테스트, 관리자 원본값 회귀 테스트와 Progress 기록.
- **범위 밖:** POINT 전용 repository 필터와 콘텐츠 저장값 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt`
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt`
**Interfaces:**
- Consumes: 각 mapper가 이미 받는 `price: Int`와 저장된 Boolean 필드.
- Produces: schema를 바꾸지 않고 `configured && price > 0`으로 보정된 소비자 응답 Boolean.
- Preserves: AI 캐릭터 관리자 목록·상세의 원본 Boolean.
- [x] **RED:** 각 응답 경계에 무료·저장값 true fixture를 추가하고 소비자 응답은 false, 유료·저장값 true 응답은 true로 기대한다. 관리자 상세에는 무료·저장값 true가 true로 유지되는 회귀 assertion을 추가한다.
- [x] **RED 확인:** 아래 focused test를 실행해 소비자 응답이 현재 true를 전달하여 발생하는 assertion 실패를 확인하고, 관리자 회귀 assertion은 기존 동작으로 통과하는지 구분해 기록한다.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'
```
- [x] **GREEN:** 소비자 응답 조립 지점의 Boolean 대입을 `storedValue && price > 0`으로 바꾼다. `AiCharacterAdminAudioContentMapper`와 관리자 service는 수정하지 않는다.
- [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 무료 true → false, 유료 true → true, 관리자 무료 true → true가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 동일한 한 줄 식을 유지하고 새 helper나 구조 변경을 추가하지 않는다. 변경 파일에 `./gradlew ktlintCheck`를 실행하고 결과를 Progress에 기록한다.
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 대상 소비자 응답 계약과 관리자 제외 계약을 최종 판정한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 아래 명령 성공, 공개 필드명 유지 확인과 Progress 기록.
- **범위 밖:** POINT repository 필터 구현과 관련 없는 응답 리팩터링.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.*' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' \
--tests 'kr.co.vividnext.sodalive.v2.api.home.*'
./gradlew ktlintCheck
```
**Expected:** 대상 소비자 응답에서 무료 콘텐츠가 포인트 가능으로 노출되지 않고 기존 JSON 필드 집합이 유지된다.
### Task 1.2 소비자·관리자 포인트 가능 계약의 회귀 증거 보강
**Goal 실행 `P1-R1`:** 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다.
- **시작 조건:** `REV-P1-001`, `REV-P1-002` 확정과 기존 `P1-GATE` 완료.
- **완료 증거:** 대상 소비자 경계의 `price > 0, storedIsPointAvailable == false` assertion, 관리자 목록·상세의
`price == 0, storedIsPointAvailable == true` assertion, focused test와 `P1-GATE` 재검증 기록.
- **범위 밖:** production 코드 변경, 관리자 응답 보정, 공개 API schema 변경.
- **TDD 예외 사유:** production 구현은 정적 검토상 계약을 이미 충족하며, 누락된 것은 완료 근거인 회귀 assertion이다.
의도적인 production 결함을 만들어 RED를 재현하지 않는다.
- **대체 검증 방법:** 기존 fixture가 누락한 진리표 조건을 추가하고 focused test와 `P1-GATE`를 통과시킨 뒤 mapper 식과
관리자 원본 전달 식을 다시 대조한다.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt`
- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`
- [x] 대상 소비자 응답 경계마다 `price > 0, storedIsPointAvailable == false`가 false로 유지되는 assertion을 추가한다.
- [x] AI 캐릭터 관리자 목록·상세 fixture를 `price == 0, storedIsPointAvailable == true`로 구성하고 저장값 true가 그대로
반환되는지 확인한다.
- [x] Task 1.1 focused test와 `P1-GATE`를 재실행하고 결과를 Progress에 누적한다.
- [x] `REV-P1-001`, `REV-P1-002`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다.
### Task 1.3 legacy 상세의 유료 포인트 가능 positive 회귀 증거 보강
**Goal 실행 `P1-R2`:** legacy 상세에서 `price > 0, storedIsPointAvailable == true`가 true를 반환하는 계약을 자동 회귀 테스트로 증명한다.
- **시작 조건:** `REV-P1-003` 확정과 `P1-R1` 완료.
- **완료 증거:** legacy 상세의 무료·저장값 true → false, 유료·저장값 false → false,
유료·저장값 true → true assertion, focused test와 `P1-GATE` 재검증 기록.
- **범위 밖:** production 코드, v2 응답 test, 관리자 mapper/service 변경.
- **TDD 예외 사유:** production 구현은 정적 검토상 positive 계약을 이미 충족하며, 누락된 것은 회귀 assertion이다.
의도적인 production 결함을 만들어 RED를 재현하지 않는다.
- **대체 검증 방법:** 기존 legacy 상세 test의 진리표를 완성하고 focused test와 `P1-GATE`를 통과시킨다.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`
- [x] legacy 상세 test에 `price > 0, storedIsPointAvailable == true` 응답이 true인 assertion을 추가한다.
- [x] 하나의 test가 세 계약 조건을 드러내도록 `DisplayName`과 test 함수명을 맞춘다.
- [x] `AudioContentServiceTest`, `P1-GATE`, `ktlintCheck`를 실행하고 결과를 Progress에 누적한다.
- [x] `REV-P1-003`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다.
## Phase 2: POINT 조회 조건 보정
**Phase 결과:** 추천과 전체 탭 POINT 목록, count와 pagination 후보에서 무료 콘텐츠가 제외된다.
**선행조건:** `P1-GATE` 완료와 PRD `POINT-005~006` 확정.
**Phase 완료 조건:** `P2-T1``P2-GATE` 완료, 검증 기록 누적.
### Task 2.1 추천·전체 탭 POINT 조회에 유료 조건 적용
**Goal 실행 `P2-T1`:** 두 POINT 조회가 `isPointAvailable == true && price > 0` 조건을 공통으로 사용한다.
- **시작 조건:** `P1-GATE` 완료, PRD `DEC-002` 확인.
- **완료 증거:** repository RED/GREEN, endpoint E2E와 Progress 기록.
- **범위 밖:** FREE/AUDIO/추천 점수·랜덤 정렬·공개/성인/차단 조건 변경.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
**Interfaces:**
- Consumes: 기존 `audioContent.isPointAvailable``audioContent.price` QueryDSL 필드.
- Produces: 추천 `pointAudios`, 전체 탭 POINT 목록과 count에 공통 적용되는 `isPointAvailable.isTrue.and(price.gt(0))` 조건.
- Preserves: FREE 목록은 `price.eq(0)`, AUDIO 목록은 `price.gt(0)`인 기존 조건.
- [x] **RED:** 두 repository fixture에 `price = 0, isPointAvailable = true``price > 0, isPointAvailable = true`를 함께 두고 POINT 결과에는 유료 항목만 포함되도록 기대한다. 전체 탭 E2E는 `totalCount`, `audios`, `hasNext`가 같은 후보 집합을 반영하도록 기대한다.
- [x] **RED 확인:** 아래 focused test를 실행해 무료 true 콘텐츠가 POINT 결과에 포함되어 발생하는 목록 또는 count assertion 실패를 확인한다.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' \
--tests 'kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest'
```
- [x] **GREEN:** `findPointAudios` 조건과 `optionalAudioPointCondition``price.gt(0)`을 결합한다. 전체 탭 count와 목록은 기존 `audioCondition`을 계속 공유한다.
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 POINT 결과에서 무료가 제외되고 유료 true는 유지되며 `totalCount``hasNext`가 일치하는지 확인한다.
- [x] **REFACTOR:** FREE/AUDIO 조건, 랜덤/가격/인기 정렬과 공통 visibility 조건이 바뀌지 않았는지 직접 영향 회귀와 `ktlintCheck`로 확인한다.
### Phase 2 Gate
**Goal 실행 `P2-GATE`:** 소비자 응답과 POINT 조회의 전체 요구사항을 최종 판정한다.
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** focused·전체 회귀·lint 성공, 문서 Progress와 최종 검증 기록.
- **범위 밖:** 실패를 숨기기 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' \
--tests 'kr.co.vividnext.sodalive.v2.content.all.*' \
--tests 'kr.co.vividnext.sodalive.v2.api.content.*'
./gradlew test
./gradlew ktlintCheck
```
**Expected:** 모든 테스트와 lint가 성공하고 무료 콘텐츠가 어떤 대상 소비자 응답이나 POINT 전용 결과에서도 포인트 결제 가능으로 취급되지 않는다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 없음 | 아니요 | 실패 응답 경계와 fixture를 다시 대조 |
| 2 | `P1-GATE` | `P1-T1` | 아니요 | 실패 소유 mapper의 회귀 수정 goal 추가 |
| 3 | `P2-T1` | `P1-GATE` | 아니요 | 목록·count 조건 공유 여부 확인 |
| 4 | `P2-GATE` | `P2-T1` | 아니요 | 실패 소유 repository 또는 mapper로 되돌림 |
| 5 | `P1-R1` | Phase별 review 판정 완료 | 아니요 | 누락된 계약 fixture와 assertion 범위를 다시 대조 |
| 6 | `P1-R2` | `P1-R1` | 아니요 | legacy 상세의 세 계약 조건을 다시 대조 |
```text
P1-T1 → P1-GATE → P2-T1 → P2-GATE
```
리뷰 후속 실행 순서:
```text
P1-R1 → P1-R2
```
## 변경 금지 항목
- 관리자 조회와 DB 저장값을 보정하지 않는다.
- 공개 DTO 필드를 추가·삭제·이름 변경하지 않는다.
- POINT 조건 보강을 FREE/AUDIO/추천 점수·정렬 변경으로 확장하지 않는다.
- 새 dependency, schema migration과 공통 abstraction을 추가하지 않는다.
- 기존 완료 문서의 체크박스와 검증 기록을 삭제하거나 덮어쓰지 않는다.
- test를 삭제·skip·완화해 Gate를 통과시키지 않는다.
## Progress
실제 구현 시 기존 기록을 삭제하거나 덮어쓰지 않고 Goal 실행 결과를 차수별로 누적한다.
### 문서 작성 검증 — 2026-07-31
- 상태: 완료
- 무엇을: 확정 요구사항을 새 PRD와 goal 실행형 계획에 기록하고 기존 추천·전체 탭 문서의 충돌 계약을 정정했다.
- 왜: 구현 전에 단일 기준 문서, 범위, 제외 조건과 완료 증거를 확정하기 위해서다.
- 어떻게:
- `rg` placeholder·요구사항/Goal 추적 검색 — placeholder 없음, `POINT-001~008``P1-T1`·`P2-T1` 연결 확인.
- 계획에 기록된 production 파일 존재 확인 — 누락 없음.
- `git diff --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- 구현 test: 문서만 변경했으므로 실행하지 않았다.
- 남은 항목: `P1-T1`부터 구현 실행.
- 다음 행동: 사용자가 구현을 요청하면 `P1-T1`의 RED부터 시작한다.
### 구현 검증 — 2026-07-31
- 상태: 완료
- 무엇을: 무료 콘텐츠 포인트 결제 불가 정책을 대상 소비자 응답과 POINT 전용 조회 조건에 적용했다.
- 왜: `price == 0` 콘텐츠가 포인트 결제 가능 상태와 동시에 노출되거나 POINT 목록·개수·페이징 후보에 포함되지 않도록 하기 위해서다.
- 어떻게:
- P1 RED focused test — 소비자 응답 경계에서 무료·저장값 true가 기존 true로 전달되어 assertion 실패 확인.
- P1 GREEN focused test — `BUILD SUCCESSFUL`, 무료 true → false, 유료 true → true, 관리자 상세 원본 true 유지 확인.
- P1-GATE — 원 wildcard 명령은 600초 제한 초과로 분할 실행했고, content 범위 `BUILD SUCCESSFUL`(9m31s), creator/home 범위 `BUILD SUCCESSFUL`(4m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(1m02s).
- P2 RED focused test — recommendation/main-all repository와 E2E에서 무료·저장값 true가 POINT 후보에 포함되어 4개 assertion 실패 확인.
- P2 GREEN focused test — `BUILD SUCCESSFUL`, 추천 `pointAudios`와 전체 탭 POINT 목록·`totalCount`·`hasNext`가 유료 포인트 후보만 반영함을 확인.
- P2-GATE 영향 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*'` `BUILD SUCCESSFUL`(1m48s).
- 최종 Gate — `./gradlew test` `BUILD SUCCESSFUL`(13m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(40s).
- 결정: 관리자 mapper/service, DB 저장값, 공개 DTO 필드명과 구조는 변경하지 않았다.
- 남은 항목: 없음.
### Phase별 리뷰 — 2026-07-31
- 상태: Phase 1 수정 goal 필요, Phase 2 확정 발견 사항 없음.
- 무엇을: PRD, 구현 계획, staged production/test diff와 관련 호출 경계를 Phase별로 대조했다.
- 왜: 완료 체크와 실제 계약 증거가 일치하는지 판정하기 위해서다.
- 어떻게:
- `git diff --cached --check` — 출력 없음.
- `rg`로 대상 응답 mapper, POINT repository 조건과 관련 test assertion을 대조했다.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- 사용자 지시에 따라 compile과 test는 다시 실행하지 않았으며 기존 구현 검증 기록을 근거로만 확인했다.
- 후속: `REV-P1-001`, `REV-P1-002``P1-R1`로 전환했다.
- 리뷰 문서:
- `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md`
- `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md`
### 리뷰 후속 검증 — 2026-07-31
- 상태: 완료
- 무엇을: `REV-P1-001`, `REV-P1-002`의 누락된 회귀 증거를 `P1-R1`로 보강했다.
- 왜: 소비자 응답의 저장값 false 조건과 AI 캐릭터 관리자 무료 원본값 유지 조건을 자동 테스트로 고정하기 위해서다.
- 어떻게:
- P1-R1 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'` `BUILD SUCCESSFUL`(3m11s).
- P1-GATE — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(12m15s).
- `./gradlew ktlintCheck``BUILD SUCCESSFUL`(47s).
- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다.
- 남은 항목: 없음.
### 2차 Phase 1 리뷰 — 2026-07-31
- 상태: `REV-P1-001`, `REV-P1-002` 수정 확인, 추가 수정 goal 필요.
- 무엇을: `P1-R1` test diff, Phase 1 production mapper, 관리자 제외 경계와 완료 기록을 다시 대조했다.
- 왜: 기존 확정 발견 사항이 실제로 수정됐는지, 계약 진리표에 다른 누락은 없는지 판정하기 위해서다.
- 어떻게:
- staged diff와 `rg`로 6개 소비자 조립 경계의 유료·저장값 false assertion을 확인했다.
- 관리자 목록·상세의 `price == 0, stored == true` fixture와 true assertion을 확인했다.
- `git diff --check`, `git diff --cached --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- 사용자 지시에 따라 compile과 test는 다시 실행하지 않았다.
- 후속: legacy 상세의 유료·저장값 true positive assertion 누락을 `REV-P1-003`, `P1-R2`로 전환했다.
### 리뷰 후속 검증 2차 — 2026-07-31
- 상태: 완료
- 무엇을: `REV-P1-003`의 누락된 legacy 상세 positive 회귀 증거를 `P1-R2`로 보강했다.
- 왜: legacy 상세에서 무료·저장값 true, 유료·저장값 false, 유료·저장값 true의 포인트 사용 가능 계약을 하나의 test로 고정하기 위해서다.
- 어떻게:
- P1-R2 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest'` `BUILD SUCCESSFUL`(14s).
- P1-GATE 단위 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(2m 26s).
- `./gradlew ktlintCheck``BUILD SUCCESSFUL`(14s).
- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다.
- 전체 테스트: 사용자 지시에 따라 실행하지 않았다.
- 남은 항목: 없음.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-07-31 | `PLAN-DEC-001` | 확정 | 응답 조립 지점에서 `configured && price > 0`을 직접 적용하고 새 abstraction을 만들지 않는다 | 기존 mapper 책임과 최소 변경 원칙 | `P1-T1` |
| 2026-07-31 | `PLAN-DEC-002` | 확정 | POINT repository 조건에 `price > 0`을 결합하고 전체 탭 목록과 count의 기존 조건 공유 구조를 유지한다 | 목록·count 일관성 | `P2-T1` |
| 2026-07-31 | `PLAN-DEC-003` | 확정 | 여러 소비자 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다 | 영향 범위 회귀 증거 필요 | `P2-GATE` |
## 발견된 문제
초기 구현 시 발견된 문제 없음.
### 2026-07-31 Phase별 리뷰
- `REV-P1-001` — 소비자 응답 테스트에 `price > 0, storedIsPointAvailable == false` 계약 증거가 없다.
- `REV-P1-002` — 관리자 회귀 테스트가 무료 fixture로 저장값 유지 계약을 검증하지 않는다.
- `REV-P1-003` — legacy 상세 test가 유료·저장값 true의 positive 계약을 검증하지 않는다.
- 후속 Goal: `P1-R1`
- 처리: 2026-07-31 `P1-R1` 완료.
- 추가 후속 Goal: `P1-R2`
- 처리: 2026-07-31 `P1-R2` 완료.
- Phase 2: 확정 발견 사항 없음.
## 최종 보고 형식
```markdown
구현 결과: 무료 콘텐츠 포인트 결제 불가 정책을 소비자 응답과 POINT 조회에 적용
- 변경: 대상 응답 mapper와 추천·전체 탭 POINT 조회 조건
- 결정: 관리자·DB 원본 유지, 공개 schema 유지
- 검증: focused test, 영향 범위 회귀, 전체 test, ktlintCheck 결과
- 남은 항목: 없음 또는 실패·외부 조건
- 문서: PRD와 이 plan-task.md의 Progress·검증 기록
```

View File

@@ -0,0 +1,171 @@
# PRD: 무료 콘텐츠 포인트 결제 불가
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-07-31 |
| 최종 수정일 | 2026-07-31 |
| 대상 제품 | 소비자용 오디오 콘텐츠 조회 API |
| 작성자·결정권자 | 사용자 |
| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 |
| 관련 구현 계획 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md` |
| 관련 review | `reviews/phase-1-review.md`, `reviews/phase-2-review.md` |
## 1. Overview
무료 오디오 콘텐츠와 포인트 결제 가능 상태가 소비자 화면에서 동시에 노출되지 않도록 조회 계약을 보정한다.
저장된 포인트 결제 가능 설정은 유지하되, 소비자용 응답과 포인트 전용 목록에서는 가격을 함께 반영한 실질 상태를 사용한다.
## 2. Problem Statement
- 현재 일부 조회 응답은 `price == 0`이면서 저장된 `isPointAvailable == true`인 콘텐츠를 그대로 포인트 결제 가능으로 노출한다.
- 포인트 추천과 전체 탭 POINT 조회는 저장된 `isPointAvailable`만 필터링해 무료 콘텐츠가 포함될 수 있다.
- 무료와 포인트 결제 가능 상태가 함께 노출되면 클라이언트의 가격 표시와 결제 진입 판단이 서로 모순될 수 있다.
문제를 해결했다는 판단은 소비자용 모든 대상 응답에서 무료 콘텐츠의 포인트 결제 가능 여부가 `false`이고,
POINT 전용 목록과 개수에서 무료 콘텐츠가 제외되는 것으로 한다.
## 3. Goals
- 무료 콘텐츠의 소비자용 포인트 결제 가능 여부를 항상 `false`로 응답한다.
- 유료이면서 저장된 포인트 결제 가능 설정이 `true`인 콘텐츠는 기존처럼 `true`로 응답한다.
- 포인트 전용 목록, 전체 개수와 페이징 판단에서 무료 콘텐츠를 제외한다.
- 기존 공개 API 필드명, 응답 구조와 DB 저장값을 변경하지 않는다.
## 4. Non-Goals
- 콘텐츠 생성·수정 시 `isPointAvailable` 저장값을 강제로 변경하지 않는다.
- 기존 데이터의 일괄 수정이나 DB migration을 수행하지 않는다.
- `/api/v2/admin/ai-characters/**/audio-contents` 관리자 목록·상세의 저장값 표현을 변경하지 않는다.
- `/audio-content/{id}` 상세를 제외한 legacy 목록·추천·랭킹 API는 변경하지 않는다.
- 콘텐츠 구매·대여·소장·포인트 차감 로직은 변경하지 않는다.
- 공개 DTO의 필드 추가·삭제·이름 변경을 수행하지 않는다.
## 5. Target Users and Permissions
| 사용자 | 목표 | 주요 작업 | 적용 범위 |
|---|---|---|---|
| 소비자 | 무료 콘텐츠를 포인트 결제 대상으로 오인하지 않는다 | 콘텐츠 상세·목록·추천 조회 | 대상 소비자용 API |
| 관리자 | 저장된 콘텐츠 설정을 그대로 확인한다 | AI 캐릭터 콘텐츠 목록·상세 조회 | 변경 제외 |
기존 endpoint별 인증·성인 노출·차단 관계·공개 상태 정책은 변경하지 않는다.
## 6. 핵심 정책
### 6.1 가격과 포인트 결제 가능 여부
- 무료 콘텐츠는 `price == 0`으로 정의한다.
- 소비자에게 노출하는 실질 포인트 결제 가능 여부는 다음 식으로 정의한다.
```text
effectivePointAvailable = storedIsPointAvailable && price > 0
```
- `price == 0`이고 저장값이 `true`이면 소비자 응답은 `false`다.
- `price > 0`이고 저장값이 `true`이면 소비자 응답은 `true`다.
- 저장값이 `false`이면 가격과 관계없이 소비자 응답은 `false`다.
- 이 정책은 응답 조립 시 적용하며 엔티티의 저장값은 변경하지 않는다.
### 6.2 포인트 전용 조회
- 포인트 전용 콘텐츠는 `isPointAvailable == true && price > 0`인 공개 오디오로 정의한다.
- `GET /api/v2/audio/recommendations``pointAudios`는 이 조건을 사용한다.
- `GET /api/v2/audio/contents?type=POINT`의 목록과 `totalCount`는 동일한 조건을 사용한다.
- `hasNext`는 보정된 목록 조건으로 조회한 `size + 1` 결과를 기준으로 기존 방식대로 계산한다.
- 무료 콘텐츠는 `isPointAvailable == true`로 저장되어 있어도 POINT 목록, 개수와 페이징 후보에서 제외한다.
## 7. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 |
|---|---|---|---|---|
| `POINT-001` | 확정 | 무료 기준은 `price == 0`이다 | 무료 fixture가 가격 0으로 판정된다 | `P1-T1`, `P2-T1` |
| `POINT-002` | 확정 | 소비자용 실질 포인트 가능 여부는 `storedIsPointAvailable && price > 0`이다 | 무료·저장값 true 응답이 false이고 유료·저장값 true 응답이 true다 | `P1-T1` |
| `POINT-003` | 확정 | legacy 콘텐츠 상세의 `isAvailableUsePoint`에 실질 상태를 적용한다 | `GET /audio-content/{id}` 응답 회귀 테스트가 통과한다 | `P1-T1` |
| `POINT-004` | 확정 | 대상 v2 소비자 응답의 `isPointAvailable`에 실질 상태를 적용한다 | 각 응답 변환 테스트가 무료 true 저장값을 false로 보정한다 | `P1-T1` |
| `POINT-005` | 확정 | 추천 `pointAudios`에서 무료 콘텐츠를 제외한다 | 추천 repository·E2E 테스트에서 가격 0 항목이 없다 | `P2-T1` |
| `POINT-006` | 확정 | 전체 탭 POINT 목록·`totalCount`·`hasNext`가 같은 유료 포인트 조건을 사용한다 | repository·E2E 테스트의 목록과 페이징 메타데이터가 일치한다 | `P2-T1` |
| `POINT-007` | 확정 | AI 캐릭터 관리자 콘텐츠 조회는 저장값을 그대로 반환한다 | 무료·저장값 true인 관리자 상세가 true를 유지한다 | `P1-T1` |
| `POINT-008` | 확정 | 공개 API 스키마와 DB 저장값을 유지한다 | DTO 필드 집합과 관리자 저장값 회귀 테스트가 통과한다 | `P1-GATE`, `P2-GATE` |
## 8. API 계약
### 8.1 응답 보정 대상
| Method | Path | 응답 경계 | 보정 필드 |
|---|---|---|---|
| GET | `/audio-content/{id}` | `GetAudioContentDetailResponse` | `isAvailableUsePoint` |
| GET | `/api/v2/contents` | `ContentOverviewItemResponse` | `isPointAvailable` |
| GET | `/api/v2/audio/contents` | `MainContentAudioResponse` | `isPointAvailable` |
| GET | `/api/v2/audio/recommendations` | `AudioCardResponse` | `isPointAvailable` |
| GET | `/api/v2/home/recommendations` | `HomeFirstAudioContentItem` | `isPointAvailable` |
| GET | `/api/v2/creator-channels/{creatorId}/home` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
| GET | `/api/v2/creator-channels/{creatorId}/audio` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
| GET | `/api/v2/creator-channels/{creatorId}/live` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
- `GET /api/v2/creator-channels/{creatorId}/series`는 콘텐츠 가격과 포인트 가능 필드를 반환하지 않아 코드 변경 대상이 아니다.
- 현재 v2 소비자용 API에 동일 필드를 반환하는 새 경로가 발견되면 같은 식을 적용하고 계획 범위를 먼저 갱신한다.
### 8.2 포인트 전용 조회 조건 보정 대상
| Method | Path/section | 변경 전 | 변경 후 |
|---|---|---|---|
| GET | `/api/v2/audio/recommendations``pointAudios` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` |
| GET | `/api/v2/audio/contents?type=POINT` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` |
### 8.3 변경 제외 관리자 계약
| Method | Path | 정책 |
|---|---|---|
| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | 저장된 `isPointAvailable`을 그대로 반환 |
| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 저장된 값을 `isAvailableUsePoint`에 그대로 반환 |
## 9. 기술적 제약
- Kotlin, Java 17, Spring Boot 2.7.14와 현재 QueryDSL/JPA 구조를 유지한다.
- 새 dependency, DB schema, API endpoint와 DTO를 추가하지 않는다.
- 응답 변환 경계에서는 `configured && price > 0` 식을 직접 사용해 현재 파일 책임 안에서 최소 변경한다.
- POINT 조회 조건은 기존 repository 조건 함수에 `price > 0`을 결합해 목록과 count가 같은 조건을 공유하게 한다.
- 관리자 mapper와 관리자 조회 service는 변경하지 않는다.
- 관련 없는 콘텐츠 가격·결제·추천 점수·정렬·성인·차단 정책은 변경하지 않는다.
## 10. 테스트와 품질 요구사항
- TDD 순서로 무료·저장값 true fixture의 실패 테스트를 먼저 작성하고 실패 원인이 기존 원본 전달임을 확인한다.
- 소비자 응답 경계별로 무료 true → false와 유료 true → true를 검증한다.
- 추천 POINT와 전체 탭 POINT에 무료 true fixture를 추가해 목록 제외를 검증한다.
- 전체 탭은 POINT `totalCount``hasNext`가 목록 조건과 일치하는지 검증한다.
- 관리자 상세는 무료 true 저장값을 그대로 true로 응답하는 회귀 테스트를 유지한다.
- focused test 후 직접 영향받는 v2 콘텐츠·홈·크리에이터 채널 회귀와 `ktlintCheck`를 실행한다.
- 여러 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다.
## 11. 성공 기준
- [ ] `price == 0`, 저장값 `true`인 콘텐츠가 모든 대상 소비자 응답에서 `false`다. (`POINT-002~004`)
- [ ] `price > 0`, 저장값 `true`인 콘텐츠가 대상 소비자 응답에서 `true`다. (`POINT-002`)
- [ ] 저장값 `false`인 콘텐츠는 가격과 관계없이 `false`다. (`POINT-002`)
- [ ] 무료·저장값 true 콘텐츠가 추천 `pointAudios`에서 제외된다. (`POINT-005`)
- [ ] 무료·저장값 true 콘텐츠가 전체 탭 POINT 목록·`totalCount`·`hasNext` 후보에서 제외된다. (`POINT-006`)
- [ ] 관리자 목록·상세와 DB 저장값은 변경되지 않는다. (`POINT-007~008`)
- [ ] 공개 응답 필드명과 구조가 변경되지 않는다. (`POINT-008`)
## 12. Open Questions
없음.
## 13. 요구사항 추적표
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|---|---:|---|---|
| `POINT-001~004`, `POINT-007~008` | 1 | `P1-T1`, `P1-GATE` | legacy 상세·v2 응답 mapper·관리자 회귀 테스트 |
| `POINT-005~006`, `POINT-008` | 2 | `P2-T1`, `P2-GATE` | 추천/전체 탭 repository·E2E 테스트 |
## 14. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-07-31 | `DEC-001` | 확정 | 무료 기준을 `price == 0`으로 고정하고 소비자용 포인트 가능 여부를 `storedIsPointAvailable && price > 0`으로 계산한다 | 사용자 인터뷰 | `POINT-001~004`, `P1-T1` |
| 2026-07-31 | `DEC-002` | 확정 | 추천과 전체 탭 POINT 조회에서 무료 콘텐츠를 제외한다 | 사용자 선택 A | `POINT-005~006`, `P2-T1` |
| 2026-07-31 | `DEC-003` | 확정 | AI 캐릭터 관리자 조회와 DB 저장값은 변경하지 않는다 | 사용자 선택 A | `POINT-007~008`, `P1-T1` |
| 2026-07-31 | `DEC-004` | 확정 | 새 통합 문서를 기준으로 만들고 충돌하는 기존 추천·전체 탭 문서에는 정정 기록을 누적한다 | 사용자 승인 | 관련 문서 전체 |

View File

@@ -0,0 +1,229 @@
# Phase 1 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-GATE` |
| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `POINT-001~004`, `POINT-007~008` 구현과 완료 증거가 일치하는지 확인한다.
- 대상 소비자 응답과 변경 제외 관리자 응답이 각각 확정 계약을 유지하는지 확인한다.
### 포함 범위
- 코드: Phase 1에서 변경한 consumer response mapper와 `AudioContentService`
- 테스트: Phase 1의 unit/facade test와 AI 캐릭터 관리자 controller test
- 문서: PRD Phase 1 요구사항, `P1-T1`, `P1-GATE`, Progress
### 제외 범위
- Phase 2 POINT 전용 repository 조건
- legacy 상세 외 legacy 목록·추천·랭킹 API
- compile과 test 재실행
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
| High | 확정 요구사항 또는 공개 API 계약 위반 |
| Medium | 제한된 조건의 기능 회귀 또는 핵심 계약의 자동 검증 누락 |
| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `POINT-001~004`, `POINT-007~008`
- 계획: `P1-T1`, `P1-GATE`
- 코드:
- `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt:970`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt:49`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt:63`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt:72`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt:35`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt:270`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt:69`
- 테스트:
- `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt:286`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt:495`
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `git diff --cached --check` | 성공 | 출력 없음 |
| staged diff와 `rg` 기반 호출·assertion 대조 | 성공 | 6개 소비자 조립 경계는 모두 `stored && price > 0` 적용 |
| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 |
| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 |
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P1-001` | Medium | 수정 완료 | 소비자 테스트가 저장값 false 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` |
| `REV-P1-002` | Low | 수정 완료 | 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` |
| `REV-P1-003` | Medium | 수정 완료 | legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음 | `Task 1.3` | `P1-R2` |
## 6. 발견 사항 상세
### REV-P1-001 — 소비자 테스트가 저장값 false 조건을 증명하지 않음
- **심각도:** Medium
- **상태:** 수정 완료
- **관련 요구사항:** `POINT-002`, `POINT-004`
- **소유 Task:** `Task 1.2`, `P1-R1`
**관찰 내용**
변경된 소비자 테스트는 주로 `price == 0, stored == true → false`
`price > 0, stored == true → true`만 검증한다. 따라서 구현이 실수로 `price > 0`만 반환해도 해당 두 조건은 통과한다.
PRD 성공 기준인 `stored == false → false`를 자동으로 구분할 수 없다.
**근거**
- 코드: 각 소비자 조립 경계는 현재 `stored && price > 0`으로 올바르게 구현되어 있다.
- 테스트: Phase 1 변경 test에는 각 변경 경계의 `price > 0, stored == false` assertion이 없다.
- 문서: PRD `POINT-002`와 성공 기준은 저장값 false가 가격과 무관하게 false일 것을 요구한다.
**영향**
현재 production 동작 결함은 확인되지 않았다. 다만 저장값 조건이 제거되는 회귀가 발생해도 Phase 1 test가 탐지하지 못한다.
**권장 조치**
각 변경 경계의 기존 test fixture에 `price > 0, stored == false` 사례를 최소 추가하고 `P1-GATE`를 재검증한다.
**판정 기록**
- 2026-07-31 — staged 구현식은 정상이나 확정 계약의 자동 검증 누락으로 판정해 `P1-R1`로 전환했다.
- 2026-07-31 — `P1-R1`에서 대상 소비자 경계에 `price > 0, stored == false` assertion을 추가하고 focused test, `P1-GATE`, `ktlintCheck` 성공을 확인했다.
### REV-P1-002 — 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음
- **심각도:** Low
- **상태:** 수정 완료
- **관련 요구사항:** `POINT-007`, `POINT-008`
- **소유 Task:** `Task 1.2`, `P1-R1`
**관찰 내용**
계획과 Progress는 관리자 상세의 “무료·저장값 true → true” 확인을 완료 증거로 기록했지만,
`AiCharacterAdminAudioContentControllerTest`의 공통 fixture는 `price = 100`이다. 상세 assertion은 저장값 true 전달만 검증하며
무료 조건에서 소비자 보정이 관리자 경계로 번지지 않았는지는 증명하지 않는다. 관리자 목록의 동일 조건 assertion도 없다.
**근거**
- 코드: `AiCharacterAdminAudioContentMapper`는 현재 `content.isPointAvailable`을 그대로 전달한다.
- 테스트: 관리자 helper의 `price = 100`, 상세의 `isAvailableUsePoint == true` assertion.
- 문서: `P1-T1`과 구현 Progress는 무료 관리자 원본값 유지 확인을 완료 증거로 기록한다.
**영향**
현재 관리자 mapper의 기능 결함은 확인되지 않았다. 그러나 완료 기록과 실제 test fixture가 불일치하며 관리자 제외 계약의
핵심 경계가 회귀 test로 고정되지 않았다.
**권장 조치**
관리자 목록·상세 fixture를 `price == 0, stored == true`로 구성해 목록 `isPointAvailable`과 상세
`isAvailableUsePoint`가 true를 유지하는지 검증한다.
**판정 기록**
- 2026-07-31 — 구현은 정상이나 완료 증거 불일치가 확인되어 `P1-R1`로 전환했다.
- 2026-07-31 — `P1-R1`에서 관리자 목록·상세 fixture를 `price == 0, stored == true`로 보강하고 원본 true 유지 assertion 통과를 확인했다.
### REV-P1-003 — legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음
- **심각도:** Medium
- **상태:** 수정 완료
- **관련 요구사항:** `POINT-002`, `POINT-003`
- **소유 Task:** `Task 1.3`, `P1-R2`
**관찰 내용**
`AudioContentServiceTest` 상세 회귀 test는 무료·저장값 true와 유료·저장값 false가 false인 것만 검증한다.
`price > 0, stored == true → true` assertion이 없어 legacy 상세 구현이 항상 false로 회귀해도 해당 test가 통과한다.
**근거**
- 코드: `AudioContentService` 상세 응답은 현재 `audioContent.isPointAvailable && audioContent.price > 0`으로 올바르게 구현되어 있다.
- 테스트: `AudioContentServiceTest` 상세 포인트 assertion은 false 사례 2건만 포함한다.
- 문서: `P1-T1`은 각 응답 경계의 유료·저장값 true 유지를 완료 증거로 요구한다.
**영향**
현재 production 동작 결함은 확인되지 않았다. 다만 legacy 상세의 positive 계약이 회귀 test로 고정되지 않았다.
**권장 조치**
기존 상세 test에 유료·저장값 true 응답 assertion을 추가해 세 계약 조건을 완성한다.
**판정 기록**
- 2026-07-31 — staged production 구현은 정상이나 legacy 상세 positive 회귀 증거 누락으로 판정해 `P1-R2`로 전환했다.
- 2026-07-31 — `P1-R2`에서 legacy 상세 test에 유료·저장값 true assertion을 추가해 세 계약 조건을 고정하고 focused test, P1-GATE 단위 범위, `ktlintCheck` 성공을 확인했다.
## 7. 확정 항목의 plan·goal 전환
- 신규 회귀 수정 Task: `plan-task.md``Task 1.2`
- 후속 goal: `P1-R1`
- objective: 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다.
- 추가 회귀 수정 Task: `plan-task.md``Task 1.3`
- 추가 후속 goal: `P1-R2`
- objective: legacy 상세의 유료·저장값 true positive 계약을 자동 회귀 테스트로 증명한다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 1 production/test/document diff와 관련 mapper 확인 |
| 후보 항목 판정 완료 | 충족 | 3건 모두 판정 완료 |
| 확정 항목 plan 반영 | 충족 | `Task 1.2`, `P1-R1`, `Task 1.3`, `P1-R2` |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 |
**최종 결론:** 수정 완료
**남은 항목:** 없음
## 9. 수정 후 검증 기록
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
### 1차 수정 검증 — 2026-07-31
- 무엇을: `REV-P1-001`, `REV-P1-002`의 수정 내용과 회귀 증거를 재검토했다.
- 왜: 소비자 저장값 false 계약과 관리자 무료 원본값 유지 계약의 자동 검증 누락을 해소했는지 확인하기 위해서다.
- 어떻게:
- staged diff와 `rg` — 6개 소비자 조립 경계의 유료·저장값 false assertion 확인.
- staged diff — 관리자 목록·상세의 무료·저장값 true fixture와 true assertion 확인.
- `git diff --check`, `git diff --cached --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- compile/test — 사용자 지시에 따라 재실행하지 않고 `plan-task.md`의 기존 성공 기록만 확인.
- 판정: `REV-P1-001`, `REV-P1-002` 수정 완료.
- 남은 항목: `REV-P1-003`, `P1-R2`.
### 2차 수정 검증 — 2026-07-31
- 무엇을: `REV-P1-003`의 수정 내용과 회귀 증거를 재검토했다.
- 왜: legacy 상세의 유료·저장값 true positive 계약이 자동 검증으로 고정됐는지 확인하기 위해서다.
- 어떻게:
- `AudioContentServiceTest` — 무료·저장값 true → false, 유료·저장값 false → false, 유료·저장값 true → true assertion 확인.
- `AudioContentService` — 응답식 `audioContent.isPointAvailable && audioContent.price > 0`과 세 assertion 대조.
- `plan-task.md`의 기존 완료 기록 — focused test `BUILD SUCCESSFUL`(14s), P1-GATE 단위 범위
`BUILD SUCCESSFUL`(2m 26s), `ktlintCheck` `BUILD SUCCESSFUL`(14s) 확인.
- `git diff --check`, `git diff --cached --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음.
- 판정: `REV-P1-003` 수정 완료.
- 남은 항목: 없음.

View File

@@ -0,0 +1,111 @@
# Phase 2 코드 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / `P2-T1`, `P2-GATE` |
| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `POINT-005~006`, `POINT-008` 구현과 완료 증거가 일치하는지 확인한다.
- 추천과 전체 탭 POINT 조회가 무료 콘텐츠를 목록·count·pagination 후보에서 제외하는지 확인한다.
### 포함 범위
- 코드: 추천·전체 탭 QueryDSL repository 변경
- 테스트: 두 repository test와 추천·전체 탭 E2E test
- 문서: 관련 PRD, 기존 추천·전체 탭 후속 정정, `P2-T1`, `P2-GATE`, Progress
### 제외 범위
- Phase 1 응답 mapper
- FREE/AUDIO/정렬 정책의 신규 변경
- compile과 test 재실행
## 3. 판정 기준
| 심각도 | 기준 |
|---|---|
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
| High | 확정 요구사항 또는 공개 API 계약 위반 |
| Medium | 제한된 조건의 목록·count·pagination 불일치 |
| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 |
## 4. 검토한 근거
### 문서와 코드
- 요구사항: `POINT-005~006`, `POINT-008`
- 계획: `P2-T1`, `P2-GATE`
- 코드:
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt:135`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:35`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:409`
- 테스트:
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt:87`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt:43`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt:42`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt:97`
### 실행한 검증
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| `git diff --cached --check` | 성공 | 출력 없음 |
| staged diff와 `rg` 기반 조건·호출 대조 | 성공 | 추천은 유료 POINT 조건, 전체 탭 list/count는 같은 `audioCondition` 공유 |
| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 |
| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 |
## 5. 발견 사항 요약
확정 발견 사항 없음.
## 6. 검토 결과
- 추천 `findPointAudios``isPointAvailable.isTrue.and(price.gt(0))`을 조회 전에 적용해 limit 후보에서도 무료 콘텐츠를 제외한다.
- 전체 탭 `countAudios``findAudios`는 동일한 `audioCondition``optionalAudioPointCondition`을 사용한다.
- 전체 탭 E2E는 유료 1건과 무료·저장값 true 1건에서 `size=1`로 조회해 `totalCount=1`, 목록 1건,
`hasNext=false`를 함께 검증한다.
- repository test는 유료·저장값 false, 유료·저장값 true, 무료·저장값 true를 구분한다.
- 기존 추천·전체 탭 문서는 2026-07-31 후속 요구사항 정정을 누적해 현재 PRD와 일치한다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | Phase 2 production/test/document diff와 관련 호출 경계 확인 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 |
**최종 결론:** 확정 발견 사항 없음
**남은 항목:** 없음
## 9. 후속 상태 확인
### 1차 재확인 — 2026-07-31
- 무엇을: Phase 1의 `P1-R2` 수정이 Phase 2 POINT 조회 계약에 영향을 주지 않았는지 재검토했다.
- 왜: 후속 test 변경 뒤에도 POINT 목록·count·pagination 조건과 기존 Phase 2 판정이 유효한지 확인하기 위해서다.
- 어떻게:
- production diff — `P1-R2`에 따른 Phase 2 repository 변경 없음 확인.
- repository와 E2E test 정적 대조 — 추천 유료 POINT 조건과 전체 탭 list/count 공통 조건 유지 확인.
- `git diff --check`, `git diff --cached --check` — 출력 없음.
- `./gradlew --no-daemon tasks --all``BUILD SUCCESSFUL`, exit code 0.
- compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음.
- 판정: 기존 Phase 2 판정 유지.
- 남은 항목: 없음.

View File

@@ -0,0 +1,170 @@
# 관리자 정산 크리에이터 번호 적용 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-04 |
| 요구사항 기준 | `docs/20260804_관리자정산크리에이터번호적용/prd.md` |
| 현재 Phase | Phase 1 완료 |
| 현재 활성 Goal | 없음 |
## 목표
이메일이 없는 크리에이터도 관리자 크리에이터별 정산에서 오류 없이 조회되고 `creatorId`로 구분된다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 | 없음 |
## 범위
### 포함
- 관리자 라이브·콘텐츠·커뮤니티 크리에이터별 정산 QueryDSL projection 변경
- 정산 query/response DTO의 `email` 제거 및 `creatorId` 추가
- 크리에이터별 정산 엑셀 이메일 컬럼 제거 및 크리에이터 번호 컬럼 추가
- null 이메일 크리에이터 QueryDSL 회귀 테스트와 엑셀 출력 테스트
### 제외
- 에이전트·채널후원 정산 변경
- `Member.email` 정책 변경
- 정산 공식·집계·필터·정렬·페이지네이션 변경
- dependency 추가와 관련 없는 리팩터링
## 기술적 제약
- Kotlin, Spring Boot 2.7.14, QueryDSL 5.0.0, JUnit 5의 기존 패턴을 유지한다.
- `@QueryProjection` 생성자 변경은 Gradle KAPT가 생성 코드를 갱신하도록 하고 생성 파일은 직접 수정하지 않는다.
- 구현은 RED → GREEN → REFACTOR 순서로 진행한다.
- Gradle 명령은 QueryDSL 생성 코드 충돌을 피하기 위해 순차 실행한다.
### Phase 1: 정산 projection 및 출력 변경
**Phase 결과:** null 이메일 크리에이터 정산 조회와 creatorId 기반 API·엑셀 출력이 동작한다.
**선행조건:** PRD와 본 계획 문서 작성 완료.
#### Task 1.1 QueryDSL 정산 응답에 creatorId 적용
**Goal 실행 `P1-T1`:** null 이메일 크리에이터의 세 정산 조회가 creatorId를 반환하도록 한다.
- **시작 조건:** `CALC-001`, `CALC-002`, `CALC-004` 확정.
- **완료 증거:** RED/GREEN 결과와 focused test 기록.
- **범위 밖:** 엑셀 출력 변경은 `P1-T2`에서 처리한다.
**Files:**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepositoryTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`
- [x] **RED:** email이 null인 크리에이터의 라이브·콘텐츠·커뮤니티 정산 조회가 `creatorId`를 반환하는 통합 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest`를 실행해 기존 projection의 null email 생성 오류 또는 미구현 `creatorId` 계약 실패를 확인한다.
- [x] **GREEN:** 두 DTO에서 `email``creatorId: Long`으로 교체하고 세 QueryDSL projection에서 `member.id`를 선택한다.
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 정산 계산 회귀 테스트를 실행한다.
#### Task 1.2 엑셀 크리에이터 번호 적용
**Goal 실행 `P1-T2`:** 크리에이터별 정산 엑셀에서 이메일 대신 크리에이터 번호를 출력한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 엑셀 헤더·값 테스트와 focused test 기록.
- **범위 밖:** 다른 정산 엑셀 형식 변경.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt`
- [x] **RED:** 크리에이터별 정산 엑셀의 첫 헤더와 값이 크리에이터 번호인지 검증하는 테스트를 작성한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest`를 실행해 남아 있는 `item.email` 계약 때문에 컴파일이 실패하는지 확인한다.
- [x] **GREEN:** 엑셀 첫 헤더를 `크리에이터 번호`로 교체하고 `creatorId`를 기록한다.
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
- [x] **REFACTOR:** 나머지 엑셀 컬럼과 계산 결과가 유지되는지 회귀 확인한다.
### 완료 조건
- [x] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 충족됐다.
- [x] 문서와 구현의 차이가 없다.
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 변경 범위의 기능·컴파일·포맷을 최종 판정한다.
```bash
./gradlew compileKotlin
./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest
./gradlew ktlintCheck
./gradlew tasks --all
git diff --check
```
**Expected:** 모든 Gradle 명령이 `BUILD SUCCESSFUL`, 테스트가 모두 통과하고 `git diff --check` 출력이 없다.
전체 테스트는 관리자 크리에이터별 정산의 projection/DTO/엑셀 경계로 변경이 제한되고 focused test와 컴파일로 세 쿼리 호출부를 검증하므로 생략한다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 문서 작성 완료 | 아니요 | RED 실패 원인 재확인 |
| 2 | `P1-T2` | `P1-T1` 완료 | 아니요 | 엑셀 계약 재확인 |
| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task에 회귀 수정 기록 |
## 변경 금지 항목
- QueryDSL 생성 파일을 직접 수정하지 않는다.
- 정산 계산식과 쿼리 집계 조건을 변경하지 않는다.
- 테스트를 삭제·skip·완화하지 않는다.
- 요청 범위 밖의 정산 API를 변경하지 않는다.
## Progress
### `P1-T1` 1차 실행 — 2026-08-04
- 상태: 진행 중
- 무엇을: PRD와 구현 계획을 작성하고 QueryDSL 통합 테스트 seam을 확정했다.
- 왜: production 변경 전에 요구사항과 TDD 완료 기준을 고정하기 위해서다.
- 어떻게:
- `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md` 확인 — 완료
- `AdminCalculateQueryRepository`와 관련 DTO 호출 경로 확인 — 완료
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest` — RED 확인, `creatorId` 미구현으로 `compileTestKotlin` 실패
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — RED 확인, 남아 있는 `item.email` 참조로 `compileKotlin` 실패
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — GREEN, `BUILD SUCCESSFUL`
- 남은 항목: Phase Gate 검증.
- 다음 행동: 컴파일·focused 회귀·ktlint·문서 검증을 순차 실행한다.
### `P1-GATE` 1차 실행 — 2026-08-04
- 상태: 완료
- 무엇을: creatorId projection·응답·엑셀 변경과 null 이메일 회귀 방지를 검증했다.
- 왜: `CALC-001`~`CALC-004`의 수용 기준과 기존 계산 불변성을 최종 판정하기 위해서다.
- 어떻게:
- `./gradlew compileKotlin``BUILD SUCCESSFUL`
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` — 11 tests, failures 0, errors 0
- `./gradlew ktlintCheck``BUILD SUCCESSFUL`
- `./gradlew build -x test``BUILD SUCCESSFUL`
- `git diff --check` — 출력 없음
- 독립 Oracle 리뷰 — `APPROVED`, 차단 이슈 없음
- 수동 검증: QueryDSL H2 통합 테스트로 null 이메일 세 정산 조회를 실행하고, 실제 XLSX workbook의 헤더와 numeric creatorId 셀을 확인했다.
- 전체 테스트 생략: 변경이 관리자 크리에이터별 정산의 공용 projection/DTO/엑셀 경계에 한정되어 focused 통합·서비스·계산 테스트와 전체 빌드로 직접 영향 범위를 검증했다.
- 남은 항목: 없음.
- 다음 행동: 없음.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-08-04 | `DEC-001` | 확정 | email을 제거하고 creatorId를 projection·응답에 추가한다. | PRD `DEC-001` | `P1-T1` |
| 2026-08-04 | `DEC-002` | 확정 | 엑셀 이메일 컬럼을 크리에이터 번호로 교체한다. | PRD `DEC-002` | `P1-T2` |
## 발견된 문제
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|---|---|---|---|---|---|
| `ISSUE-001` | High | 확정 | nullable `Member.email`이 non-null QueryProjection 생성자로 전달되어 정산 조회가 실패한다. | `P1-T1` | creatorId로 projection 계약 교체 |

View File

@@ -0,0 +1,73 @@
# 관리자 정산 크리에이터 번호 적용 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-08-04 |
| 최종 수정일 | 2026-08-04 |
| 대상 제품 | 관리자 크리에이터별 정산 조회 및 엑셀 |
| 작성자·결정권자 | 사용자 |
| 관련 구현 계획 | `docs/20260804_관리자정산크리에이터번호적용/plan-task.md` |
## 1. Overview
관리자 크리에이터별 정산에서 nullable인 `Member.email`을 필수 QueryDSL projection 값으로 사용해 발생하는 조회 오류를 제거한다. 정산 대상 식별값은 이메일 대신 non-null PK인 `creatorId`를 사용한다.
## 2. Problem Statement
- `Member.email`은 nullable이지만 `GetCalculateByCreatorQueryData.email`은 non-null `String`이다.
- 이메일이 없는 크리에이터가 정산 결과에 포함되면 QueryDSL이 DTO 생성 중 `ExpressionException`을 발생시킨다.
- 이메일은 정산 계산식에 사용되지 않으므로 필수 projection 값으로 유지할 이유가 없다.
문제 해결 여부는 이메일이 null인 크리에이터의 정산 조회가 성공하고 `creatorId`를 반환하는지로 판단한다.
## 3. Goals
- 라이브·콘텐츠·커뮤니티 크리에이터별 정산 projection에서 `email`을 제거한다.
- 정산 응답에 `creatorId`를 추가한다.
- 크리에이터별 정산 엑셀에서 이메일을 제거하고 크리에이터 번호를 제공한다.
- 기존 정산 금액 계산, 집계, 필터, 정렬과 페이지네이션을 유지한다.
## 4. Non-Goals
- `Member.email`의 nullable 정책을 변경하지 않는다.
- 이메일이 없는 회원의 다른 기능을 수정하지 않는다.
- 에이전트 정산과 채널후원 정산 API를 변경하지 않는다.
- 정산 공식 또는 정산 비율을 변경하지 않는다.
## 5. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `CALC-001` | 확정 | 크리에이터별 정산 조회는 이메일 대신 크리에이터 번호를 반환한다. | 응답 item에 `creatorId: Long`이 있고 `email`이 없다. | `P1-T1` |
| `CALC-002` | 확정 | 이메일이 없는 크리에이터도 정산 조회 대상에 포함된다. | null 이메일 크리에이터의 라이브·콘텐츠·커뮤니티 정산 QueryDSL 조회가 예외 없이 성공한다. | `P1-T1` |
| `CALC-003` | 확정 | 크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 제공한다. | 첫 헤더가 `크리에이터 번호`이고 데이터 셀에 `creatorId`가 기록된다. | `P1-T2` |
| `CALC-004` | 확정 | 기존 정산 계산 결과를 유지한다. | `totalCan`, 원화, 결제수수료, 정산금액, 원천세와 입금액 계산 회귀 테스트가 통과한다. | `P1-T1`, `P1-GATE` |
## 6. API 계약
대상 endpoint는 다음과 같다.
- `GET /admin/calculate/live-by-creator`
- `GET /admin/calculate/content-by-creator`
- `GET /admin/calculate/community-by-creator`
- 위 세 endpoint의 `/excel` 다운로드
조회 응답 item의 `email: String`을 제거하고 `creatorId: Long`을 추가한다. 요청 파라미터와 응답의 나머지 필드는 변경하지 않는다.
## 7. 성공 기준
- [x] null 이메일 크리에이터에 대한 세 종류의 크리에이터별 정산 조회가 성공한다.
- [x] 조회 응답은 `creatorId`를 포함하고 `email`을 포함하지 않는다.
- [x] 엑셀은 `크리에이터 번호` 컬럼을 포함하고 이메일 컬럼을 포함하지 않는다.
- [x] 기존 정산 계산 및 관련 회귀 테스트가 통과한다.
## 8. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-04 | `DEC-001` | 확정 | 정산에서 email을 제거하고 creatorId를 사용한다. | email은 계산에 사용되지 않고 nullable이라 projection 오류를 발생시킨다. | `CALC-001`, `CALC-002`, `P1-T1` |
| 2026-08-04 | `DEC-002` | 확정 | 엑셀의 이메일 컬럼을 크리에이터 번호 컬럼으로 교체한다. | 사용자가 이메일 제거와 creatorId 기반 구분을 확정했다. | `CALC-003`, `P1-T2` |
| 2026-08-04 | `DEC-003` | 확정 | 이메일 제거에 따른 추가 식별력 보완은 하지 않는다. | 사용자가 식별력 저하는 문제가 되지 않는다고 확정했다. | Non-Goals |

View File

@@ -0,0 +1,90 @@
# 추천 탭 배너 조회 조건 보정 Implementation Plan
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-05 |
| 요구사항 기준 | `docs/20260805_추천탭_배너_조회조건/prd.md` |
| API 기준 | 기존 공개 API 계약 유지 |
| 현재 Phase | Phase 1: 배너 조회 조건 보정 |
| 현재 활성 Goal | 없음 |
## 목표
메인 홈 추천과 메인 콘텐츠 추천에서 화면별 탭과 회원의 성인 콘텐츠 조회 가능 여부에 맞는 배너만 조회한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `2/2` | 없음 | 없음 |
## 범위
### 포함
- 홈 추천 배너의 `tab_id IS NULL` 조건 유지 및 성인 배너 필터 추가
- 콘텐츠 추천 배너의 `tab_id = 2` 조건과 성인 배너 필터 추가
- application에서 계산한 성인 콘텐츠 조회 가능 여부를 persistence port까지 전달
- 관련 service/facade/Repository focused test와 직접 영향 범위 회귀
### 제외
- 공개 API endpoint와 응답 DTO 변경
- 배너 언어/활성/차단/대상 유효성/정렬/limit 정책 변경
- DB 데이터 또는 스키마 변경
## 기술적 제약
- Kotlin, Spring Boot 2.7.14, QueryDSL 기존 패턴을 유지한다.
- 성인 콘텐츠 조회 가능 여부는 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 재사용한다.
- 조회 가능 여부가 `false`이면 `audioContentBanner.isAdult.isFalse`, `true`이면 성인 조건을 추가하지 않는다.
- 신규 공통 추상화나 의존성을 추가하지 않는다.
### Phase 1: 배너 조회 조건 보정
- [x] **Task 1.1: 추천 API별 탭 및 성인 배너 조회 조건 구현 (`P1-T1`)**
- Objective: 두 추천 API의 배너가 확정된 탭과 성인 콘텐츠 조회 정책에 따라 반환된다.
- 시작 조건: PRD `BANNER-001~004`가 확정되어 있다.
- 완료 증거: 신규/보강 테스트가 RED 후 GREEN이고 관련 production/test 코드가 컴파일된다.
- 범위 밖: 언어 필터와 공개 응답 스키마 변경.
- Modify:
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
- RED: 홈 성인 조회 플래그 전달, 홈 비성인 필터, 콘텐츠 `tab_id = 2` 및 비성인 필터를 검증하는 테스트를 먼저 작성하고 실패를 확인한다.
- GREEN: 기존 QueryDSL 조건에 필요한 탭/성인 조건만 추가하고 홈 application 경로에 플래그를 전달한다.
- REFACTOR: 중복되지 않는 기존 조건 helper 패턴을 따르고 불필요한 변경이 없는지 확인한다.
- Verify:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- 검증 기록:
- RED: 두 Repository 테스트를 실행해 `shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible`, `shouldFindBannersForContentRecommendationTabWithAdultVisibility` 두 건의 기대값 실패를 확인했다.
- RED: 홈 service 테스트는 `includeAdultBanners` 미구현 컴파일 실패, Facade 테스트는 플래그 미전달 assertion 실패를 각각 확인했다.
- GREEN: 위 4개 focused test class를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
- [x] **Task 1.2: 영향 범위 회귀 및 문서 검증 (`P1-GATE`)**
- Objective: 추천 배너 변경이 기존 API 계약과 코드 품질 규칙을 깨지 않았음을 확인한다.
- 시작 조건: `P1-T1`이 완료되어 있다.
- 완료 증거: focused test, `ktlintCheck`, `tasks --all`, `git diff --check`가 통과하고 결과가 기록되어 있다.
- 범위 밖: 전체 회귀 테스트. 변경이 배너 조회 경로 두 곳에 한정되어 targeted test로 직접 영향 범위를 판단할 수 있으므로 생략한다.
- Verify:
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
- `./gradlew ktlintCheck`
- `./gradlew tasks --all`
- `git diff --check`
- 검증 기록:
- `HomeRecommendationControllerTest`, `AudioRecommendationControllerTest`, `AudioRecommendationEndToEndTest`를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
- `./gradlew tasks --all`: `BUILD SUCCESSFUL`.
- `git diff --check`: 출력 없음.
## 검증 기록
- 최초 Gradle 실행은 sandbox의 `~/.gradle` wrapper lock 접근 제한으로 실패했으며, 승인된 동일 명령을 재실행해 검증을 완료했다.
- 전체 회귀 테스트는 실행하지 않았다. 변경이 두 배너 조회 조건과 홈 플래그 전달에 한정되어 focused Repository/application 테스트와 두 API의 controller/E2E 회귀로 직접 영향 범위를 검증했다.

View File

@@ -0,0 +1,71 @@
# PRD: 추천 탭 배너 조회 조건 보정
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-08-05 |
| 최종 수정일 | 2026-08-05 |
| 대상 제품 | 메인 홈 추천 탭, 메인 콘텐츠 추천 탭 |
| 작성자·결정권자 | 사용자 |
| 관련 구현 계획 | `docs/20260805_추천탭_배너_조회조건/plan-task.md` |
| 관련 기존 문서 | `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` |
## 1. Overview
메인 홈 추천 API와 메인 콘텐츠 추천 API의 배너 조회에 회원의 성인 콘텐츠 조회 가능 여부를 반영하고, 두 화면이 서로 다른 탭의 배너를 조회하도록 조건을 보정한다.
## 2. Problem Statement
- 두 추천 API가 모두 `content_banner.tab_id IS NULL`인 같은 배너를 조회한다.
- 두 추천 API의 배너 조회가 `content_banner.is_adult`를 필터링하지 않아 성인 콘텐츠 조회 불가 사용자에게 성인 배너가 노출될 수 있다.
문제를 해결했다는 판단은 두 API의 탭 조건과 성인 배너 노출 조건이 Repository 테스트로 구분되어 검증되는 것으로 한다.
## 3. Goals
- 성인 콘텐츠 조회 불가 사용자는 `is_adult = false`인 배너만 조회한다.
- 성인 콘텐츠 조회 가능 사용자는 성인·비성인 배너를 모두 조회한다.
- 메인 홈 추천 API는 기존처럼 `tab_id IS NULL`인 배너를 조회한다.
- 메인 콘텐츠 추천 API는 `tab_id = 2`인 배너를 조회한다.
## 4. Non-Goals
- 공개 API endpoint와 응답 DTO를 변경하지 않는다.
- 배너 언어 필터, 정렬, 최대 조회 개수, 대상 활성/차단 정책을 변경하지 않는다.
- 배너 또는 탭 데이터와 DB 스키마를 변경하지 않는다.
## 5. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `BANNER-001` | 확정 | `GET /api/v2/home/recommendations`의 배너는 `tab_id IS NULL` 조건을 유지한다. | 탭이 없는 활성 배너만 조회하고 `tab_id = 2` 배너는 조회하지 않는다. | `P1-T1` |
| `BANNER-002` | 확정 | `GET /api/v2/audio/recommendations`의 배너는 `tab_id = 2` 조건을 사용한다. | `tab_id = 2`인 활성 배너만 조회하고 탭이 없는 배너는 조회하지 않는다. | `P1-T1` |
| `BANNER-003` | 확정 | 두 API 모두 회원의 성인 콘텐츠 조회 가능 여부를 배너 조회에 반영한다. | 조회 불가이면 비성인 배너만, 조회 가능이면 성인·비성인 배너를 모두 반환한다. | `P1-T1` |
| `BANNER-004` | 확정 | 기존 배너 활성/차단/대상 유효성/정렬/limit 정책을 유지한다. | 기존 관련 Repository 테스트가 계속 통과한다. | `P1-GATE` |
## 6. API 계약
| Method | Path | 변경 내용 |
|---|---|---|
| `GET` | `/api/v2/home/recommendations` | 응답 스키마 변경 없이 배너의 성인 조회 조건만 추가한다. |
| `GET` | `/api/v2/audio/recommendations` | 응답 스키마 변경 없이 배너 탭 조건을 `tab_id = 2`로 변경하고 성인 조회 조건을 추가한다. |
## 7. 성공 기준
- [x] 홈 추천 배너가 `tab_id IS NULL` 조건을 유지한다.
- [x] 콘텐츠 추천 배너가 `tab_id = 2` 조건만 사용한다.
- [x] 성인 콘텐츠 조회 불가/가능 사용자의 배너 결과가 확정 정책과 일치한다.
- [x] 기존 배너 응답 스키마와 활성/차단/정렬/limit 정책이 유지된다.
## 8. Open Questions
- 없음.
## 9. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-05 | `DEC-001` | 확정 | 성인 조회 불가이면 비성인 배너만, 조회 가능이면 성인·비성인 배너를 모두 조회한다. | 사용자 답변 A | `BANNER-003`, `P1-T1` |
| 2026-08-05 | `DEC-002` | 확정 | 홈 추천은 `tab_id IS NULL`, 콘텐츠 추천은 `tab_id = 2`를 사용한다. | 사용자 직접 요구사항 | `BANNER-001`, `BANNER-002`, `P1-T1` |

View File

@@ -0,0 +1,174 @@
# 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정 Plan/TASK
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-05 |
| 요구사항 기준 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/prd.md` |
| API 기준 | 기존 `GET /creator-admin/audio-content/series/{seriesId}` 계약 유지 |
| 현재 Phase | Phase 1 완료 |
| 현재 활성 Goal | 없음, 구현 완료 |
## 목표
OSIV off 환경에서 크리에이터 관리자가 본인 시리즈 상세를 조회할 때 `Series.keywordList` lazy 초기화 예외 없이 기존 응답을 받게 한다.
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `1/1` | 없음 | 없음 |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
## 범위
### 포함
- `CreatorAdminContentSeriesService`의 class-level read-only 트랜잭션 경계
- `Series.keywordList` lazy 예외를 재현하고 방지하는 서비스 통합 테스트
- 기존 상세 응답과 소유권 동작의 영향 범위 회귀 검증
### 제외
- endpoint와 `GetCreatorAdminContentSeriesDetailResponse` 변경
- OSIV, entity fetch 전략, repository query 변경
- 다른 시리즈 조회·수정 흐름 리팩터링
- 새 abstraction 또는 dependency 추가
## 기술적 제약
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5를 유지한다.
- production code는 `CreatorAdminContentSeriesService` class-level annotation 한 줄만 변경한다.
- 기존 쓰기 메서드 `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`,
`updateSeriesOrders()`의 메서드 레벨 `@Transactional`은 유지해 class-level read-only 기본값을 재정의한다.
- 테스트는 실제 Spring 서비스 프록시를 사용하고 외부 테스트 트랜잭션으로 서비스 경계를 가리지 않는다.
- focused test부터 실행하고 직접 영향받는 characterization test까지만 회귀 범위를 확장한다.
- 전체 테스트는 class-level annotation 한 줄 변경과 targeted test로 영향 범위를 판정할 수 있으므로 기본적으로 생략한다. targeted test에서
범위를 설명할 수 없는 실패가 발생하거나 공통 경계 변경으로 확대될 때만 실행한다.
## Phase 1: 상세 조회 트랜잭션 회귀 수정
**Phase 결과:** 크리에이터 관리자 시리즈 상세 조회가 OSIV off 환경에서 키워드를 포함한 기존 DTO를 정상 반환한다.
**선행조건:** `CASD-001`~`CASD-003` 요구사항과 `DEC-CASD-002`, `DEC-CASD-003` 결정 확정.
**Phase 완료 조건:** `P1-T1``P1-GATE` 완료, focused·영향 범위 회귀 결과 기록.
### 구현 항목
#### Task 1.1 상세 조회 lazy 예외 재현 및 최소 수정
**Goal 실행 `P1-T1`:** 서비스 클래스의 기본 read-only 트랜잭션 안에서 상세 조회가 키워드 lazy 컬렉션을 DTO로 변환하게 한다.
- **시작 조건:** PRD 구현 기준 확정, production code 미수정 상태.
- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, focused test 실제 실행 결과, class-level annotation 이외 production diff 없음,
기존 쓰기 메서드 annotation 유지.
- **범위 밖:** OSIV·entity mapping·repository query·controller·response DTO 변경.
**Files:**
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesServiceIntegrationTest.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt`
- Modify: `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md`
- Verify: `src/test/resources/application.yml`
**Interfaces:**
- Consumes: `CreatorAdminContentSeriesService.getDetail(id: Long, memberId: Long)`과 기존 `Series.toDetailResponse(imageHost)`
- Produces: 기존 시그니처·DTO를 유지하면서 class-level read-only 트랜잭션 안에서 완성된
`GetCreatorAdminContentSeriesDetailResponse`
- [x] **RED:** `@SpringBootTest`, `EmbeddedRedisInitializer`, 실제 `CreatorAdminContentSeriesService` 빈을 사용하는 통합 테스트를 작성한다.
테스트 외부 트랜잭션은 사용하지 않고 `TransactionTemplate` 안에서 소유 회원, 장르, 시리즈, 해시태그와 `SeriesKeyword` fixture를
저장한 뒤 트랜잭션 밖에서 `service.getDetail()`을 호출해 `keywords`와 주요 상세 필드를 검증한다.
- [x] **RED 확인:** `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`
를 실행해 `Series.keywordList``LazyInitializationException`으로 실패하는지 확인한다. 환경·fixture·컴파일 실패는 RED 증거로
인정하지 않고 먼저 바로잡는다.
- [x] **GREEN:** `CreatorAdminContentSeriesService` 클래스 선언 바로 위에 기존 import를 사용하는
`@Transactional(readOnly = true)` 한 줄을 추가하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다.
- [x] **GREEN 확인:** RED와 같은 focused test를 다시 실행해 `BUILD SUCCESSFUL`과 fixture의 `keywords`, `seriesId`,
`publishedDaysOfWeek`, `state` 값 일치를 확인한다.
- [x] **REFACTOR:** 새 abstraction 없이 테스트 fixture의 중복만 파일 내부 private helper로 제한한다. focused test와
`./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`
를 실행하고, production diff가 class-level annotation 한 줄이며 모든 기존 쓰기 메서드 annotation이 유지되는지 확인해 결과를
이 Task 아래에 기록한다.
### 완료 조건
- [x] `P1-T1`의 RED/GREEN/REFACTOR와 완료 증거가 모두 충족됐다.
- [x] `CASD-001`~`CASD-003`이 자동 검증 결과로 추적된다.
- [x] API 계약, OSIV, entity mapping, repository query에 변경이 없다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 상세 조회 수정의 기능·회귀·문서 범위를 최종 판정한다.
- **시작 조건:** `P1-T1` 완료.
- **완료 증거:** 아래 명령 통과 및 실제 결과를 검증 기록에 누적.
- **범위 밖:** Gate 통과를 위한 테스트 완화와 관련 없는 코드 수정.
```bash
./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest
./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest
./gradlew --no-daemon ktlintCheck
./gradlew --no-daemon tasks --all
git diff --check
./gradlew --no-daemon test
```
- [x] focused test가 실제 실행되고 failures/errors 0으로 통과한다.
- [x] 기존 시리즈 상세·소유권 characterization test가 통과한다.
- [x] `ktlintCheck`, `tasks --all`, `git diff --check`가 통과한다.
- [x] ultrawork verification 요구에 따라 전체 테스트를 실행하고 통과했다.
- [x] production code 변경이 `CreatorAdminContentSeriesService`의 class-level `@Transactional(readOnly = true)` 한 줄뿐이다.
- [x] 기존 쓰기 메서드 5개의 메서드 레벨 `@Transactional`이 유지된다.
## 실행 순서
| 순서 | Goal | 완료 후 다음 Goal |
|---:|---|---|
| 1 | `P1-T1` | `P1-GATE` |
| 2 | `P1-GATE` | 구현 완료 |
## Progress
- 2026-08-05: 운영 stack trace와 controller → service → repository → entity DTO 변환 흐름을 확인했다.
- 2026-08-05: `Series.keywordList` lazy 접근과 트랜잭션 없는 `getDetail()`을 원인으로 확정했다.
- 2026-08-05: PRD와 Plan/TASK를 작성했으며 production code와 테스트는 아직 변경하지 않았다.
- 2026-08-05: 메서드 분류 재검토 결과 트랜잭션 없는 public 메서드 4개는 모두 조회이고, 쓰기 메서드 5개는 모두 메서드 레벨
`@Transactional`을 보유함을 확인했다. 해결안을 `getDetail()` 메서드 단위에서 서비스 class-level read-only 기본값으로 정정했다.
- 2026-08-05: `P1-T1` RED/GREEN/REFACTOR를 완료했다. 다음 Goal은 `P1-GATE`이며 Gate 체크박스는 아직 미완료다.
- 2026-08-05: `P1-GATE`의 focused·characterization·정적 검사·Gradle task 확인·전체 테스트를 모두 통과해 Phase 1과 구현을 완료했다.
## 검증 기록
- 문서 작성 시점에는 구현용 RED/GREEN 테스트를 실행하지 않았다.
- 2026-08-05: 문서 변경 후 `./gradlew --no-daemon tasks --all`로 계획에 사용한 Gradle 명령이 유효한지 확인했다.
- sandbox 실행은 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근 제한으로 실패했다.
- 승인 실행은 `BUILD SUCCESSFUL in 8s`로 통과했다.
- 2026-08-05: `git diff --check`가 출력 없이 통과했고, 구현 파일과 테스트 파일은 변경하지 않았음을 확인했다.
- 2026-08-05: class-level read-only 트랜잭션 기준으로 문서를 보완한 뒤 `./gradlew --no-daemon tasks --all`을 재실행해
`BUILD SUCCESSFUL in 7s`를 확인했다.
- 2026-08-05: production 수정 전 focused test를 실행해 `BUILD FAILED in 6m 50s`
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`을 확인했다.
- 2026-08-05: `CreatorAdminContentSeriesService``@Transactional(readOnly = true)` 한 줄을 추가한 뒤 같은 focused test를
재실행해 `BUILD SUCCESSFUL in 4m 52s`를 확인했다. 테스트는 fixture의 `seriesId`, `title`, `introduction`, `coverImageUrl`,
`publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`, `state`, `writer`, `studio` 값을 검증한다.
- 2026-08-05: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`
를 실행해 `BUILD SUCCESSFUL in 47s`를 확인했다.
- 2026-08-05: production diff는 `CreatorAdminContentSeriesService` 클래스 선언 위의 `@Transactional(readOnly = true)` 한 줄뿐이며,
`createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존 메서드 레벨
`@Transactional` 5개가 유지됨을 확인했다. API 계약, OSIV, entity mapping, repository query 변경은 없다.
- 2026-08-05: `P1-GATE`에서 명령을 순차 실행해 다음 결과를 확인했다.
- `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`: exit code 0, `BUILD SUCCESSFUL in 4m 44s`.
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`: exit code 0, `BUILD SUCCESSFUL in 41s`.
- `./gradlew --no-daemon ktlintCheck`: exit code 0, `BUILD SUCCESSFUL in 30s`.
- `./gradlew --no-daemon tasks --all`: exit code 0, `BUILD SUCCESSFUL in 7s`.
- `git diff --check`: exit code 0, 출력 없음.
- `./gradlew --no-daemon test`: exit code 0, `BUILD SUCCESSFUL in 6m 20s`. 기본 계획의 targeted 검증 범위를 넘어 전체 테스트를 실행한 이유는 ultrawork verification 요구사항 때문이다.
- 2026-08-05: Gate 검증 후 production diff가 class-level `@Transactional(readOnly = true)` 한 줄뿐이고, 새 테스트 파일은
`CreatorAdminContentSeriesServiceIntegrationTest.kt` 하나이며, 기존 쓰기 메서드의 `@Transactional` 5개가 유지됨을 재확인했다.

View File

@@ -0,0 +1,148 @@
# PRD: 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-08-05 |
| 최종 수정일 | 2026-08-05 |
| 대상 기능 | 크리에이터 관리자 시리즈 상세 조회 |
| 관련 구현 계획 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md` |
## 1. Overview
`spring.jpa.open-in-view=false` 환경에서 `GET /creator-admin/audio-content/series/{seriesId}` 호출 시
`Series.keywordList` 접근으로 발생하는 `LazyInitializationException`을 서비스 클래스의 기본 read-only 트랜잭션 경계로 방지한다.
## 2. Problem Statement
- `CreatorAdminContentSeriesController.getDetail()``CreatorAdminContentSeriesService.getDetail()`에 상세 조회를 위임한다.
- `CreatorAdminContentSeriesService.getDetail()`에는 트랜잭션이 없으며,
`CreatorAdminContentSeriesRepository.findByIdAndCreatorId()`가 반환한 `Series`로 상세 DTO를 생성한다.
- 같은 서비스의 트랜잭션 없는 public 메서드는 모두 조회 기능이고, 데이터를 변경하는 public 메서드에는 이미 메서드 레벨
`@Transactional`이 적용되어 있다.
- `Series.keywordList`는 별도 fetch 설정이 없는 `@OneToMany`이므로 lazy 컬렉션이다.
- `Series.toDetailResponse()``keywordList.map { it.keyword!!.tag }`를 실행한다.
- 운영·테스트 설정의 `spring.jpa.open-in-view=false` 때문에 리포지토리 호출 후 영속성 컨텍스트가 종료되고, DTO 변환 중
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`
예외가 발생한다.
- 기존 `LegacyCreatorAdminSeriesCharacterizationTest`는 클래스 레벨 `@Transactional`과 직접 생성한 서비스 객체를 사용하므로,
실제 Spring 서비스 프록시의 트랜잭션 유무에 따른 회귀를 검증하지 못한다.
문제를 해결했다는 판단은 외부 테스트 트랜잭션이 없는 OSIV off 통합 테스트에서 실제 Spring 서비스 프록시로 상세 조회 후
키워드가 포함된 기존 응답을 정상 생성하는 것으로 한다.
## 3. Goals
- OSIV off 환경에서도 소유한 시리즈 상세 조회가 `LazyInitializationException` 없이 완료된다.
- 서비스 클래스의 조회 기본값을 read-only 트랜잭션으로 두고, 시리즈 조회부터 `toDetailResponse()`의 lazy 컬렉션 접근까지
같은 영속성 컨텍스트에서 처리한다.
- 실제 Spring 서비스 프록시를 호출하는 통합 테스트로 수정 전 실패와 수정 후 성공을 검증한다.
- 기존 endpoint, 인증·소유권 검사, 성공 응답 필드와 값 형식을 유지한다.
## 4. Non-Goals
- `spring.jpa.open-in-view`를 활성화하지 않는다.
- `Series.keywordList`를 전역 eager fetch로 변경하지 않는다.
- 상세 조회 쿼리를 fetch join 또는 projection으로 재작성하지 않는다.
- `Series.toDetailResponse()` 또는 `GetCreatorAdminContentSeriesDetailResponse` 구조를 변경하지 않는다.
- 시리즈 목록·수정·콘텐츠 연결 등 다른 흐름을 함께 리팩터링하지 않는다.
## 5. Target Users and Permissions
- 대상 사용자: 본인이 소유한 시리즈 상세를 조회하는 `CREATOR` 역할의 크리에이터 관리자
- 인증·권한: 기존 `@PreAuthorize("hasRole('CREATOR')")`와 인증 회원 검사를 유지한다.
- 소유권: 기존 `findByIdAndCreatorId(id, creatorId)` 조건과 `creator.admin.series.invalid_access` 오류를 유지한다.
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `CASD-001` | 확정 | 서비스 클래스에 read-only 트랜잭션을 기본 적용하고 기존 쓰기 메서드의 메서드 레벨 트랜잭션을 유지한다. | 키워드가 있는 소유 시리즈 조회가 OSIV off 환경에서 예외 없이 완료되고 기존 쓰기 메서드 annotation이 보존된다. | `P1-T1` |
| `CASD-002` | 확정 | 기존 상세 조회 API 계약을 유지한다. | endpoint, 권한, 오류 key, 응답 DTO의 필드·형식이 바뀌지 않는다. | `P1-T1`, `P1-GATE` |
| `CASD-003` | 확정 | 테스트 외부 트랜잭션 없이 실제 서비스 프록시를 검증한다. | 수정 전 `Series.keywordList` 예외를 재현하고, 수정 후 같은 테스트가 통과한다. | `P1-T1` |
## 7. API 계약
| Method | Path | 변경 사항 |
|---|---|---|
| `GET` | `/creator-admin/audio-content/series/{seriesId}` | 공개 계약 변경 없음 |
- 성공 응답은 기존 `ApiResponse.ok(GetCreatorAdminContentSeriesDetailResponse)`를 유지한다.
- `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`,
`state`, `writer`, `studio` 필드와 기존 문자열 변환 규칙을 유지한다.
- 인증 실패와 타 소유자·미존재 시리즈 오류 처리를 변경하지 않는다.
## 8. 해결 방안
`CreatorAdminContentSeriesService` 클래스에 `@Transactional(readOnly = true)`를 기본 적용한다.
```kotlin
@Service
@Transactional(readOnly = true)
class CreatorAdminContentSeriesService(
```
`getDetail()``findByIdAndCreatorId()` 조회와 `series.toDetailResponse()` 변환이 이 경계 안에서 모두 끝나므로 `keywordList`
정상 초기화할 수 있다. 다른 조회 메서드도 같은 기본 경계를 사용하며, 기존 쓰기 메서드의 메서드 레벨 `@Transactional`
class-level `readOnly = true`를 쓰기 트랜잭션으로 재정의한다. 이미 서비스 파일에서 `Transactional`을 사용하고 있어 새 의존성이나
import는 필요하지 않다.
### 현재 메서드 분류
| 기본 read-only 트랜잭션을 사용하는 조회 메서드 | 메서드 레벨 쓰기 트랜잭션을 유지하는 메서드 |
|---|---|
| `getSeriesList()` | `createSeries()` |
| `getDetail()` | `modifySeries()` |
| `getSeriesContent()` | `addingContentToTheSeries()` |
| `searchContentNotInSeries()` | `removeContentInTheSeries()` |
| | `updateSeriesOrders()` |
### 제외한 대안
- OSIV 활성화: 요청 전체로 영속성 컨텍스트를 확장해 현재 저장소 정책을 되돌리므로 제외한다.
- `keywordList` eager 변경: 모든 `Series` 조회 비용에 영향을 주는 전역 변경이므로 제외한다.
- fetch join/projection 추가: 이 endpoint만의 결함을 고치는 데 리포지토리 계약과 쿼리 변경이 불필요하므로 제외한다.
- 컨트롤러 트랜잭션: 영속성 및 DTO 변환 경계는 서비스가 소유하는 기존 구조에 맞지 않으므로 제외한다.
- `getDetail()`에만 read-only 트랜잭션 적용: 현재 트랜잭션 없는 메서드가 모두 조회 기능이므로 class-level 기본값보다 반복과
누락 가능성이 크다.
## 9. 기술적 제약
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5, Gradle Wrapper를 사용한다.
- `src/main/resources/application.yml``src/test/resources/application.yml``spring.jpa.open-in-view=false`를 유지한다.
- 테스트는 클래스 외부 트랜잭션을 비활성화하고 fixture 생성만 `TransactionTemplate`로 분리한다.
- 실제 Spring `CreatorAdminContentSeriesService` 빈을 주입해 proxy annotation 동작을 검증한다.
- production code 변경은 `CreatorAdminContentSeriesService` class-level annotation 한 줄로 제한한다.
- `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존
메서드 레벨 `@Transactional`을 유지한다.
## 10. 성공 기준
- [ ] 수정 전 focused test가 `Series.keywordList``LazyInitializationException`으로 실패한다.
- [ ] 서비스 클래스에 `@Transactional(readOnly = true)` 적용 후 같은 테스트가 통과한다.
- [ ] 응답의 `keywords`와 주요 기존 상세 필드 값이 fixture와 일치한다.
- [ ] 기존 소유권·상세 동작 characterization test와 `ktlintCheck`가 통과한다.
- [ ] `tasks --all``git diff --check`가 통과한다.
- [ ] API 스키마, 엔티티 fetch 전략, repository query에는 변경이 없다.
- [ ] 모든 기존 쓰기 메서드의 메서드 레벨 `@Transactional`이 유지된다.
## 11. 요구사항 추적표
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|---|---:|---|---|
| `CASD-001`, `CASD-003` | 1 | `P1-T1` | `CreatorAdminContentSeriesServiceIntegrationTest` |
| `CASD-002` | 1 | `P1-T1`, `P1-GATE` | focused test, `LegacyCreatorAdminSeriesCharacterizationTest` |
## 12. Open Questions
없음. 운영 stack trace, entity mapping, 서비스 호출 경계와 OSIV 설정으로 원인과 최소 해결 범위가 확인됐다.
## 13. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-05 | `DEC-CASD-001` | 확정 | `CreatorAdminContentSeriesService.getDetail()`에 메서드 단위 read-only 트랜잭션을 적용한다. | DTO 변환이 이미 서비스 내부에 있어 한 줄로 lazy 접근 전체를 영속성 컨텍스트 안에 포함할 수 있다. | `CASD-001`, `CASD-002`, `P1-T1` |
| 2026-08-05 | `DEC-CASD-002` | 확정 | 실제 Spring 서비스 프록시와 외부 트랜잭션이 없는 통합 테스트로 회귀를 고정한다. | 기존 characterization test는 테스트 트랜잭션과 직접 생성한 서비스 때문에 annotation 회귀를 검증할 수 없다. | `CASD-003`, `P1-T1` |
| 2026-08-05 | `DEC-CASD-003` | 정정 | `DEC-CASD-001`의 메서드 단위 적용을 class-level `@Transactional(readOnly = true)` 적용으로 정정하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다. | 트랜잭션 없는 기존 public 메서드는 모두 조회 기능이며, 쓰기 메서드는 이미 메서드 레벨 annotation으로 read-only 기본값을 재정의한다. | `CASD-001`, `CASD-002`, `P1-T1` |

View File

@@ -0,0 +1,293 @@
# 크리에이터 채널 홈 커뮤니티 응답 확장 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 크리에이터 채널 홈의 `notices``communities` 게시글에 실제 고정 여부와 댓글 가능 여부를 non-null Boolean으로 제공한다.
**Architecture:** 기존 `CreatorChannelCommunityPost`가 보유한 두 값을 공유 `CreatorChannelCommunityPostResponse.from`에서 그대로 매핑한다. 조회 계층과 데이터 모델은 건드리지 않고 공개 응답 DTO와 직접 영향받는 직렬화·E2E 테스트만 수정한다.
**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Jackson, JUnit 5, MockMvc, Gradle Wrapper
## 문서 상태
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-10 |
| 요구사항 기준 | `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/prd.md` |
| API 기준 | PRD `7. API 계약` |
| 현재 Phase | Phase 1 완료: 홈 커뮤니티 상태 응답 확장 |
| 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 |
| Gate | `P1-GATE` 완료 |
## 현재 상태
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|---:|---|---:|---|---|
| 1 | 완료 | `1/1` | 없음 | 없음 |
- 동시에 하나의 미완료 goal만 운용한다.
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
- goal 실행 시 token budget을 설정하지 않는다.
## 범위
### 포함
- `GET /api/v2/creator-channels/{creatorId}/home``notices[*]`, `communities[*]``isPinned` 추가
- 같은 게시글 응답에 `isCommentAvailable` 추가
- 도메인 값의 DTO 매핑과 정확한 `is*` JSON 필드명 검증
- 기존 홈 API E2E와 Kotlin 포맷 회귀 검증
### 제외
- 조회 조건, 정렬, 최대 노출 개수와 권한 정책 변경
- 커뮤니티 탭·상세·댓글 API 변경
- DB, entity, domain model, repository 수정
- 전용 DTO 분리, 신규 dependency와 관련 없는 리팩터링
## Global Constraints
- Kotlin + Java 17, Spring Boot 2.7.14와 기존 Jackson 직렬화 방식을 유지한다.
- 기존 공유 `CreatorChannelCommunityPostResponse``from` 변환만 확장한다.
- 공개 필드명은 `isPinned`, `isCommentAvailable`로 고정하고 두 값은 nullable로 만들지 않는다.
- `CreatorChannelCommunityPost.isPinned`, `CreatorChannelCommunityPost.isCommentAvailable` 값을 재계산하지 않는다.
- 구현은 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR 순서로 진행한다.
- focused test에서 시작해 직접 영향받는 홈 E2E와 `ktlintCheck`까지만 확장한다.
- 전체 `./gradlew test`는 공통 경계 영향이나 targeted test로 판단할 수 없는 실패가 있을 때만 실행한다.
---
## Phase 1: 홈 커뮤니티 상태 응답 확장
**Phase 결과:** 홈 API의 공지와 일반 커뮤니티 게시글이 고정 여부와 댓글 가능 여부를 정확한 Boolean 필드로 반환한다.
**선행조건:** 승인된 PRD의 `CCHC-001~004`, `DEC-001~003` 확인.
**Phase 완료 조건:** `P1-T1``P1-GATE` 완료, 체크박스와 실제 검증 결과를 Progress에 누적.
### Task 1.1 공유 게시글 응답에 상태 필드 추가
**Goal 실행 `P1-T1`:** 공유 홈 게시글 응답이 도메인의 `isPinned`, `isCommentAvailable`을 정확한 JSON 필드로 직렬화하게 한다.
- **시작 조건:** PRD 상태가 `구현 기준 확정`이고 현재 활성 Goal이 없다.
- **완료 증거:** 아래 5단계 완료, controller focused test와 홈 E2E 통과, 변경 파일과 결과를 Progress에 기록.
- **범위 밖:** 조회 service/repository, domain model, 다른 커뮤니티 API 수정.
**Files:**
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt:92`
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt:57`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt:173`
- Modify: `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md`
**Interfaces:**
- Consumes: `CreatorChannelCommunityPost.isPinned: Boolean`, `CreatorChannelCommunityPost.isCommentAvailable: Boolean`
- Produces: `CreatorChannelCommunityPostResponse.isPinned: Boolean`, `CreatorChannelCommunityPostResponse.isCommentAvailable: Boolean`
- JSON: `data.notices[*].isPinned`, `data.notices[*].isCommentAvailable`, `data.communities[*].isPinned`, `data.communities[*].isCommentAvailable`
- [x] **RED:** `CreatorChannelHomeControllerTest.shouldReturnCreatorChannelHomeForAuthenticatedMember`의 일반 커뮤니티 fixture를 아래처럼 공지와 반대 상태로 만들고, 네 JSON 경로 assertion을 추가한다. 같은 assertion을 `CreatorChannelHomeEndToEndTest.shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest`에도 추가하되 E2E fixture의 댓글 가능 값은 기존 `true`를 사용한다.
```kotlin
communities = listOf(
post.copy(
postId = 302L,
content = "community",
isCommentAvailable = false,
isPinned = false
)
)
```
```kotlin
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(false))
```
E2E assertion은 실제 저장값과 조회 조건을 검증한다.
```kotlin
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(true))
```
- [x] **RED 확인:** 다음 focused test를 실행해 `$.data.notices[0].isPinned` 등 신규 JSON 경로에 값이 없어 실패하는지 확인한다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --no-daemon
```
**Expected:** 테스트가 신규 필드 미직렬화로 실패한다. compile 오류나 fixture 오류는 RED 증거로 인정하지 않는다.
- [x] **GREEN:** `CreatorChannelCommunityPostResponse`에 기존 커뮤니티 탭 DTO와 같은 `@JsonProperty` 관례로 필드를 추가하고 `from`에서 도메인 값을 직접 매핑한다.
```kotlin
data class CreatorChannelCommunityPostResponse(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String,
val imageUrl: String?,
val audioUrl: String?,
val content: String,
val price: Int,
val dateUtc: String,
val existOrdered: Boolean,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val likeCount: Int,
val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
@JsonProperty("isLiked")
val isLiked: Boolean
) {
companion object {
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
return CreatorChannelCommunityPostResponse(
postId = post.postId,
creatorId = post.creatorId,
creatorNickname = post.creatorNickname,
creatorProfileUrl = post.creatorProfileUrl,
imageUrl = post.imageUrl,
audioUrl = post.audioUrl,
content = post.content,
price = post.price,
dateUtc = post.createdAt.toUtcIso(),
existOrdered = post.existOrdered,
isCommentAvailable = post.isCommentAvailable,
likeCount = post.likeCount,
commentCount = post.commentCount,
isPinned = post.isPinned,
isLiked = post.isLiked
)
}
}
}
```
- [x] **GREEN 확인:** controller test와 홈 E2E를 함께 실행해 공유 DTO 매핑, JSON 이름, 실제 조회값이 모두 통과하는지 확인한다.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' \
--no-daemon
```
**Expected:** 두 테스트 클래스가 `BUILD SUCCESSFUL`로 종료된다.
- [x] **REFACTOR:** 새 abstraction을 만들지 않고 이번 Task가 추가한 fixture·필드·매핑만 정리한다. 다음 명령을 실행하고 결과를 `P1-T1` Progress에 기록한다.
```bash
./gradlew ktlintCheck --no-daemon
git diff --check
```
**Expected:** 두 명령 모두 exit code `0`.
- [x] **Commit:** 커밋 전후 규칙 검증과 함께 구현·테스트·Progress 변경만 커밋한다.
```bash
work/scripts/check-commit-message-rules.sh --message "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"
git add \
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt \
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt \
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt \
docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md
git commit -m "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"
work/scripts/check-commit-message-rules.sh HEAD
```
### Phase 1 Gate
**Goal 실행 `P1-GATE`:** PRD의 네 확정 요구사항과 작은 변경 범위를 최종 판정한다.
- **시작 조건:** `P1-T1`의 체크박스, focused test, E2E, 포맷과 Progress 기록 완료.
- **완료 증거:** 아래 자동 검증 통과, PRD 추적성 확인, Gate 결과를 Progress에 기록.
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
```bash
./gradlew test \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' \
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' \
--no-daemon
./gradlew ktlintCheck --no-daemon
./gradlew tasks --all --no-daemon
git diff --check
```
**Expected:** 모든 명령이 exit code `0`이고 다음 조건이 충족된다.
- [x] `notices[*]``communities[*]`에 두 non-null Boolean이 존재한다. (`CCHC-001~003`)
- [x] `notices[*].isPinned == true`, `communities[*].isPinned == false`가 실제 조회 조건과 일치한다.
- [x] 댓글 가능 값이 `true``false` 모두 그대로 직렬화된다.
- [x] endpoint, 인증, 기존 홈 응답 필드와 조회 결과가 유지된다. (`CCHC-004`)
- [x] 전체 회귀 테스트를 생략한 근거와 대신 실행한 focused·E2E 검증을 Progress에 기록한다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|---:|---|---|---|---|
| 1 | `P1-T1` | 승인된 PRD | 아니요 | RED 실패 원인이 신규 필드 누락인지 다시 확인 |
| 2 | `P1-GATE` | `P1-T1` 완료 | 아니요 | 실패를 소유한 Task에 회귀 수정 goal 추가 |
```text
P1-T1 → P1-GATE
```
## 변경 금지 항목
- 공개 필드명이나 기존 홈 응답 필드를 근거 없이 변경하지 않는다.
- domain, service, repository에서 두 Boolean을 재계산하지 않는다.
- 전용 DTO, helper, 신규 dependency를 추가하지 않는다.
- 테스트를 삭제·skip·완화하거나 관련 없는 코드를 정리하지 않는다.
- 기존 완료 기록과 Decision Log를 삭제하거나 덮어쓰지 않는다.
## 의사결정 및 중단 규칙
- 구현 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다.
- 신규 JSON 필드 외 실패가 발생하면 원인을 분리하고 범위 밖 변경을 임의로 적용하지 않는다.
- targeted test로 영향 범위를 판단할 수 없을 때만 전체 `./gradlew test --no-daemon`을 실행한다.
- 체크박스와 완료 증거, Progress 기록이 모두 충족된 뒤에만 goal을 완료 처리한다.
## Progress
기존 기록을 삭제하지 않고 Goal별 실행 결과를 아래에 누적한다.
### P1-T1
- RED 작성: controller fixture의 일반 게시글을 `postId=302L`, `content="community"`, `isCommentAvailable=false`, `isPinned=false`로 구성하고 controller 및 E2E에 공지/일반 게시글 상태 assertion을 추가했다.
- RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --no-daemon`은 exit code `1`로 실패했다. `CreatorChannelHomeControllerTest.kt:138``$.data.notices[0].isPinned` assertion에서 `PathNotFoundException`이 발생해 신규 JSON 필드 미직렬화가 원인임을 확인했으며 compile 및 fixture 오류는 없었다.
- GREEN 구현: `CreatorChannelCommunityPostResponse`에 non-null Boolean `isCommentAvailable`, `isPinned`을 명시적 `@JsonProperty`와 함께 추가하고 `post.isCommentAvailable`, `post.isPinned`을 직접 매핑했다.
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 54s`로 종료됐다. controller focused test와 직접 영향받는 홈 E2E가 함께 통과해 공지 `true/true`, 일반 게시글 `false/false` controller fixture 및 `false/true` 실제 E2E 저장값의 매핑과 JSON 이름을 확인했다.
- REFACTOR 확인: 새 abstraction 없이 계획된 fixture, 응답 필드, 직접 매핑만 유지했다. `./gradlew ktlintCheck --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 25s`였고 `git diff --check`도 exit code `0`이었다.
- 전체 회귀 생략: 이번 변경은 공유 홈 DTO의 additive 직렬화 필드 두 개와 직접 영향받는 테스트에 한정되고 controller focused test와 실제 저장·조회 E2E가 모두 통과했으므로 계획의 조건에 따라 전체 `./gradlew test`는 실행하지 않았다.
- 변경 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`, `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md`.
- 커밋 검증: 커밋 전 `work/scripts/check-commit-message-rules.sh --message "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"`가 모든 규칙을 `[PASS]`로 확인했다. 최종 diff는 계획된 네 파일만 포함하고 비밀값, 관련 없는 변경, agent footer가 없음을 자체 검토했다. 커밋 후 `work/scripts/check-commit-message-rules.sh HEAD`를 커밋 `d99104d945743663803b506417b4d2ad3ac4b0a2`에 실행해 모든 규칙이 `[PASS]`임을 확인했다.
### P1-GATE
- 자동 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --no-daemon`은 최초 실행에서 exit code `0`, `BUILD SUCCESSFUL in 40s`였고 테스트 산출물이 `UP-TO-DATE`였다. 실제 HTTP 경계를 새로 실행하기 위해 `./gradlew cleanTest --no-daemon`을 exit code `0`, `BUILD SUCCESSFUL in 16s`로 수행한 뒤 같은 Gate 테스트 명령을 다시 실행해 `:test` 실행과 exit code `0`, `BUILD SUCCESSFUL in 48s`를 확인했다.
- 정적·빌드 검증: `./gradlew ktlintCheck --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 1m 22s`, `./gradlew tasks --all --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 22s`, `git diff --check`는 출력 없이 exit code `0`이었다.
- 요구사항 추적: `CCHC-001~003``notices[*]`, `communities[*]`의 non-null Boolean `isPinned`, `isCommentAvailable`과 도메인 값 직접 매핑으로 확인했다. 실제 저장·조회 E2E에서 공지는 `isPinned=true`, 일반 게시글은 `isPinned=false`였고 controller fixture와 E2E를 합쳐 `isCommentAvailable=true/false`가 모두 그대로 직렬화됨을 확인했다. `CCHC-004`는 기존 `GET /api/v2/creator-channels/{creatorId}/home`, 비회원 `401`, 인증 회원 `200`, 기존 홈 섹션·필드·조회 결과 assertion의 유지와 focused·E2E 통과로 확인했다.
- 실제 HTTP 표면: `CreatorChannelHomeEndToEndTest.shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest`가 인증된 MockMvc `GET`을 controller에서 service, repository, H2 database 조회까지 통과시켜 실제 API 응답의 공지와 일반 게시글 값을 검증했다.
- 전체 회귀 생략: 변경은 공유 홈 응답 DTO의 additive Boolean 두 필드와 직접 직렬화 경계에 한정되고 focused controller test와 실제 저장·조회 MockMvc E2E로 영향을 판정할 수 있었으며 모호한 실패가 없었다. 계획의 실행 조건에 따라 전체 `./gradlew test`는 실행하지 않았다.
- 변경 문서 상태: 구현·테스트는 커밋 `d99104d945743663803b506417b4d2ad3ac4b0a2`에 있고, Gate에서는 기존 `P1-T1` 기록과 이미 존재한 커밋 후 검증 문장을 보존한 채 `plan-task.md``prd.md`의 완료 상태만 변경했다. Kotlin 코드, 테스트, Decision Log는 변경하지 않았다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-08-10 | `PLAN-DEC-001` | 확정 | 하나의 구현 Task와 하나의 Gate로 실행한다. | 공유 DTO 한 곳의 additive 변경이며 별도 구현 경계가 없다. | `P1-T1`, `P1-GATE` |
| 2026-08-10 | `PLAN-DEC-002` | 확정 | 전체 회귀는 조건부로 두고 controller test, 홈 E2E, `ktlintCheck`를 기본 검증으로 사용한다. | 변경이 홈 게시글 응답 직렬화 경계에 한정된다. | `P1-T1`, `P1-GATE` |
## 발견된 문제
없음.

View File

@@ -0,0 +1,144 @@
# PRD: 크리에이터 채널 홈 커뮤니티 응답 확장
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 기준 확정 |
| 작성일 | 2026-08-10 |
| 최종 수정일 | 2026-08-10 |
| 대상 제품 | 크리에이터 채널 홈 API |
| 작성자·결정권자 | 사용자 |
| 관련 API Contract | 별도 문서 없음, 이 문서의 `7. API 계약`을 기준으로 함 |
| 관련 구현 계획 | `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md` |
| 관련 review | 없음 |
## 1. Overview
크리에이터 채널 홈 API의 `notices``communities` 게시글 응답에 고정 여부와 댓글 가능 여부를 각각 `isPinned`, `isCommentAvailable`로 제공한다. 기존 조회 결과가 이미 보유한 값을 응답에 그대로 반영해 클라이언트가 별도 추론 없이 게시글 상태를 표시할 수 있게 한다.
## 2. Problem Statement
- 현재 `GET /api/v2/creator-channels/{creatorId}/home`의 게시글 도메인 모델에는 고정 여부와 댓글 가능 여부가 있지만 홈 응답 DTO에는 두 값이 없다.
- 클라이언트는 홈의 게시글이 고정 글인지, 댓글 작성이 가능한지 응답만으로 일관되게 판단할 수 없다.
- `notices``communities`가 같은 `CreatorChannelCommunityPostResponse`를 사용하므로 두 배열의 계약을 함께 확장해야 한다.
문제를 해결했다는 판단은 두 배열의 각 게시글에 실제 도메인 값과 일치하는 non-null Boolean 필드가 직렬화되는지로 한다.
## 3. Goals
- 홈 응답의 `notices[*]``communities[*]``isPinned`를 제공한다.
- 홈 응답의 `notices[*]``communities[*]``isCommentAvailable`을 제공한다.
- 기존 커뮤니티 조회 결과의 값을 변형하거나 재계산하지 않고 그대로 사용한다.
- 기존 홈 API의 endpoint, 인증 정책과 기존 응답 필드를 유지한다.
## 4. Non-Goals
- 커뮤니티 조회 조건, 고정 정렬, 최대 노출 개수는 변경하지 않는다.
- 댓글 작성·수정·삭제 정책과 API는 변경하지 않는다.
- 커뮤니티 탭 및 게시글 상세 API 계약은 변경하지 않는다.
- DB schema, entity, domain model과 repository query는 변경하지 않는다.
- `notices``communities` 전용 응답 DTO를 새로 분리하지 않는다.
- 관련 없는 홈 응답 필드나 패키지 구조는 리팩터링하지 않는다.
## 5. Target Users and Permissions
- 대상 사용자: 인증 후 크리에이터 채널 홈을 조회하는 앱 사용자
- 인증 및 권한: 기존 홈 API 정책을 그대로 사용한다.
- 차단, 성인 콘텐츠와 구매 여부 정책: 기존 홈 API 조회 결과를 그대로 사용한다.
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|---|---|---|---|---|
| `CCHC-001` | 확정 | `notices[*]``communities[*]``isPinned`를 제공한다. | JSON 필드명이 정확히 `isPinned`이고 값은 `CreatorChannelCommunityPost.isPinned`와 일치한다. | `7. API 계약`, `P1-T1` |
| `CCHC-002` | 확정 | `notices[*]``communities[*]``isCommentAvailable`을 제공한다. | JSON 필드명이 정확히 `isCommentAvailable`이고 값은 `CreatorChannelCommunityPost.isCommentAvailable`과 일치한다. | `7. API 계약`, `P1-T1` |
| `CCHC-003` | 확정 | 두 필드는 non-null Boolean으로 응답한다. | 게시글이 존재하면 두 필드가 누락되거나 `null`이 되지 않는다. | `P1-T1`, `P1-GATE` |
| `CCHC-004` | 확정 | 기존 홈 API 계약과 조회 정책을 보존한다. | endpoint, 인증 정책, 기존 응답 필드와 게시글 선택·정렬 결과가 변경되지 않는다. | `P1-GATE` |
### 상태별 기대값
| 배열 | `isPinned` | `isCommentAvailable` |
|---|---:|---|
| `notices` | 해당 게시글의 실제 값. 현재 홈 조회 조건상 `true` | 해당 게시글의 댓글 허용 설정값 |
| `communities` | 해당 게시글의 실제 값. 현재 홈 조회 조건상 `false` | 해당 게시글의 댓글 허용 설정값 |
- 댓글 수가 `0`이어도 `isCommentAvailable`을 별도로 계산하지 않는다.
-`notices` 또는 `communities`는 기존처럼 빈 배열로 응답한다.
## 7. API 계약
### 7.1 Endpoint
- Method: `GET`
- Path: `/api/v2/creator-channels/{creatorId}/home`
- 요청과 인증 정책: 변경 없음
### 7.2 응답 확장
`data.notices[*]``data.communities[*]`에 다음 필드를 추가한다.
| 필드 | 타입 | nullable | 의미 |
|---|---|---:|---|
| `isPinned` | Boolean | 아니요 | 해당 커뮤니티 게시글의 고정 여부 |
| `isCommentAvailable` | Boolean | 아니요 | 해당 커뮤니티 게시글의 댓글 작성 허용 여부 |
예시:
```json
{
"data": {
"notices": [
{
"postId": 301,
"isPinned": true,
"isCommentAvailable": true
}
],
"communities": [
{
"postId": 302,
"isPinned": false,
"isCommentAvailable": false
}
]
}
}
```
예시는 추가 필드와 배열별 의미만 나타내며 기존 게시글 응답 필드는 그대로 유지한다.
## 8. 기술적 제약
- Kotlin + Java 17, Spring Boot 2.7.14와 기존 Jackson 직렬화 방식을 유지한다.
- 기존 공유 `CreatorChannelCommunityPostResponse``from` 변환을 재사용한다.
- `is*` JSON 이름이 변경되지 않도록 기존 Boolean 응답 필드의 `@JsonProperty` 관례를 따른다.
- 신규 dependency와 별도 abstraction을 추가하지 않는다.
- 구현은 응답 DTO와 직접 영향받는 테스트로 제한한다.
## 9. 성공 기준
- [x] `notices[*].isPinned``notices[*].isCommentAvailable`이 실제 값으로 응답된다. (`CCHC-001~003`)
- [x] `communities[*].isPinned``communities[*].isCommentAvailable`이 실제 값으로 응답된다. (`CCHC-001~003`)
- [x] 기존 홈 API 응답 및 조회 흐름 회귀 테스트가 통과한다. (`CCHC-004`)
- [x] `ktlintCheck`가 통과한다.
- [x] 전체 회귀 테스트를 생략하면 작은 응답 DTO 변경이라는 근거와 대신 실행한 focused·영향 범위 테스트를 검증 기록에 남긴다.
## 10. Open Questions
없음.
## 11. 요구사항 추적표
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|---|---:|---|---|---|
| `CCHC-001~003` | 1 | `P1-T1` | `CreatorChannelHomeControllerTest` | 홈 응답 JSON 필드명과 Boolean 값 확인 |
| `CCHC-004` | 1 | `P1-GATE` | `CreatorChannelHomeEndToEndTest`, `ktlintCheck` | 기존 필드 유지 여부 확인 |
## 12. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-10 | `DEC-001` | 확정 | `isPinned`, `isCommentAvailable``notices``communities` 모두에 추가한다. | 두 배열이 같은 게시글 응답 DTO를 사용하며 사용자가 인터뷰 선택지 A를 승인했다. | `CCHC-001~004`, `P1-T1`, `P1-GATE` |
| 2026-08-10 | `DEC-002` | 확정 | 공유 응답 DTO를 확장하고 전용 DTO는 분리하지 않는다. | 기존 도메인 값과 변환 경로를 재사용하는 최소 변경이다. | `CCHC-001~004`, `P1-T1` |
| 2026-08-10 | `DEC-003` | 확정 | PRD를 구현 기준으로 승인한다. | 사용자가 작성된 PRD를 검토하고 승인했다. | 문서 전체, `plan-task.md` |

View File

@@ -391,7 +391,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
return queryFactory return queryFactory
.select( .select(
QGetCalculateByCreatorQueryData( QGetCalculateByCreatorQueryData(
member.email, member.id,
member.nickname, member.nickname,
useCan.can.add(useCan.rewardCan).sum(), useCan.can.add(useCan.rewardCan).sum(),
creatorSettlementRatio.liveSettlementRatio creatorSettlementRatio.liveSettlementRatio
@@ -447,7 +447,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
return queryFactory return queryFactory
.select( .select(
QGetCalculateByCreatorQueryData( QGetCalculateByCreatorQueryData(
member.email, member.id,
member.nickname, member.nickname,
order.can.sum(), order.can.sum(),
creatorSettlementRatio.contentSettlementRatio creatorSettlementRatio.contentSettlementRatio
@@ -504,7 +504,7 @@ class AdminCalculateQueryRepository(private val queryFactory: JPAQueryFactory) {
return queryFactory return queryFactory
.select( .select(
QGetCalculateByCreatorQueryData( QGetCalculateByCreatorQueryData(
member.email, member.id,
member.nickname, member.nickname,
useCan.can.add(useCan.rewardCan).sum(), useCan.can.add(useCan.rewardCan).sum(),
creatorSettlementRatio.communitySettlementRatio creatorSettlementRatio.communitySettlementRatio

View File

@@ -474,7 +474,7 @@ class AdminCalculateService(
return createExcelStream( return createExcelStream(
sheetName = sheetName, sheetName = sheetName,
headers = listOf( headers = listOf(
"이메일", "크리에이터 번호",
"닉네임", "닉네임",
"총 캔", "총 캔",
"원화", "원화",
@@ -486,7 +486,7 @@ class AdminCalculateService(
) { sheet -> ) { sheet ->
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
val row = sheet.createRow(index + 1) val row = sheet.createRow(index + 1)
row.createCell(0).setCellValue(item.email) row.createCell(0).setCellValue(item.creatorId.toDouble())
row.createCell(1).setCellValue(item.nickname) row.createCell(1).setCellValue(item.nickname)
row.createCell(2).setCellValue(item.totalCan.toDouble()) row.createCell(2).setCellValue(item.totalCan.toDouble())
row.createCell(3).setCellValue(item.totalKrw.toDouble()) row.createCell(3).setCellValue(item.totalKrw.toDouble())

View File

@@ -3,7 +3,7 @@ package kr.co.vividnext.sodalive.admin.calculate
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
data class GetCalculateByCreatorItem( data class GetCalculateByCreatorItem(
@JsonProperty("email") val email: String, @JsonProperty("creatorId") val creatorId: Long,
@JsonProperty("nickname") val nickname: String, @JsonProperty("nickname") val nickname: String,
@JsonProperty("totalCan") val totalCan: Int, @JsonProperty("totalCan") val totalCan: Int,
@JsonProperty("totalKrw") val totalKrw: Int, @JsonProperty("totalKrw") val totalKrw: Int,

View File

@@ -5,7 +5,7 @@ import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
data class GetCalculateByCreatorQueryData @QueryProjection constructor( data class GetCalculateByCreatorQueryData @QueryProjection constructor(
val email: String, val creatorId: Long,
val nickname: String, val nickname: String,
val totalCan: Int, val totalCan: Int,
val settlementRatio: Int? val settlementRatio: Int?
@@ -31,7 +31,7 @@ data class GetCalculateByCreatorQueryData @QueryProjection constructor(
val depositAmount = settlementAmount.subtract(tax) val depositAmount = settlementAmount.subtract(tax)
return GetCalculateByCreatorItem( return GetCalculateByCreatorItem(
email = email, creatorId = creatorId,
nickname = nickname, nickname = nickname,
totalCan = totalCan, totalCan = totalCan,
totalKrw = totalKrw.toInt(), totalKrw = totalKrw.toInt(),

View File

@@ -967,7 +967,7 @@ class AudioContentService(
previousContent = previousContent, previousContent = previousContent,
nextContent = nextContent, nextContent = nextContent,
buyerList = buyerList, buyerList = buyerList,
isAvailableUsePoint = audioContent.isPointAvailable, isAvailableUsePoint = audioContent.isPointAvailable && audioContent.price > 0,
translated = translated translated = translated
) )
} }

View File

@@ -26,6 +26,7 @@ import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
@Service @Service
@Transactional(readOnly = true)
class CreatorAdminContentSeriesService( class CreatorAdminContentSeriesService(
private val repository: CreatorAdminContentSeriesRepository, private val repository: CreatorAdminContentSeriesRepository,
private val genreRepository: CreatorAdminContentSeriesGenreRepository, private val genreRepository: CreatorAdminContentSeriesGenreRepository,

View File

@@ -60,7 +60,7 @@ data class MainContentAudioResponse(
imageUrl = audio.imageUrl, imageUrl = audio.imageUrl,
price = audio.price, price = audio.price,
isAdult = audio.isAdult, isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable, isPointAvailable = audio.isPointAvailable && audio.price > 0,
isFirstContent = audio.isFirstContent, isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries, isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname creatorNickname = audio.creatorNickname

View File

@@ -46,7 +46,7 @@ data class ContentOverviewItemResponse(
title = audio.title, title = audio.title,
coverImage = audio.imageUrl, coverImage = audio.imageUrl,
price = audio.price, price = audio.price,
isPointAvailable = audio.isPointAvailable, isPointAvailable = audio.isPointAvailable && audio.price > 0,
creatorNickname = audio.creatorNickname, creatorNickname = audio.creatorNickname,
isAdult = audio.isAdult, isAdult = audio.isAdult,
isFirstContent = audio.isFirstContent, isFirstContent = audio.isFirstContent,
@@ -65,7 +65,7 @@ data class ContentOverviewItemResponse(
title = audio.title, title = audio.title,
coverImage = coverImage, coverImage = coverImage,
price = audio.price, price = audio.price,
isPointAvailable = audio.isPointAvailable, isPointAvailable = audio.isPointAvailable && audio.price > 0,
creatorNickname = audio.creatorNickname, creatorNickname = audio.creatorNickname,
isAdult = isAdult, isAdult = isAdult,
isFirstContent = true, isFirstContent = true,

View File

@@ -69,7 +69,7 @@ data class AudioCardResponse(
imageUrl = audio.imageUrl, imageUrl = audio.imageUrl,
price = audio.price, price = audio.price,
isAdult = audio.isAdult, isAdult = audio.isAdult,
isPointAvailable = audio.isPointAvailable, isPointAvailable = audio.isPointAvailable && audio.price > 0,
isFirstContent = audio.isFirstContent, isFirstContent = audio.isFirstContent,
isOriginalSeries = audio.isOriginalSeries, isOriginalSeries = audio.isOriginalSeries,
creatorNickname = audio.creatorNickname creatorNickname = audio.creatorNickname

View File

@@ -32,7 +32,7 @@ data class CreatorChannelAudioContentResponse(
imageUrl = audioContent.imageUrl, imageUrl = audioContent.imageUrl,
price = audioContent.price, price = audioContent.price,
isAdult = audioContent.isAdult, isAdult = audioContent.isAdult,
isPointAvailable = audioContent.isPointAvailable, isPointAvailable = audioContent.isPointAvailable && audioContent.price > 0,
isFirstContent = audioContent.isFirstContent, isFirstContent = audioContent.isFirstContent,
seriesName = audioContent.seriesName, seriesName = audioContent.seriesName,
isOriginalSeries = audioContent.isOriginalSeries, isOriginalSeries = audioContent.isOriginalSeries,

View File

@@ -181,8 +181,12 @@ data class CreatorChannelCommunityPostResponse(
val price: Int, val price: Int,
val dateUtc: String, val dateUtc: String,
val existOrdered: Boolean, val existOrdered: Boolean,
@JsonProperty("isCommentAvailable")
val isCommentAvailable: Boolean,
val likeCount: Int, val likeCount: Int,
val commentCount: Int, val commentCount: Int,
@JsonProperty("isPinned")
val isPinned: Boolean,
@JsonProperty("isLiked") @JsonProperty("isLiked")
val isLiked: Boolean val isLiked: Boolean
) { ) {
@@ -199,8 +203,10 @@ data class CreatorChannelCommunityPostResponse(
price = post.price, price = post.price,
dateUtc = post.createdAt.toUtcIso(), dateUtc = post.createdAt.toUtcIso(),
existOrdered = post.existOrdered, existOrdered = post.existOrdered,
isCommentAvailable = post.isCommentAvailable,
likeCount = post.likeCount, likeCount = post.likeCount,
commentCount = post.commentCount, commentCount = post.commentCount,
isPinned = post.isPinned,
isLiked = post.isLiked isLiked = post.isLiked
) )
} }

View File

@@ -53,7 +53,11 @@ class HomeRecommendationFacade(
memberId = member?.id, memberId = member?.id,
includeAdultLives = includeAdult includeAdultLives = includeAdult
).map { it.toItem() }, ).map { it.toItem() },
banners = queryService.findHomeBanners(HOME_BANNER_LIMIT, member?.id) banners = queryService.findHomeBanners(
limit = HOME_BANNER_LIMIT,
memberId = member?.id,
includeAdultBanners = includeAdult
)
.map { RecommendationBannerResponse.from(it.toBanner()) }, .map { RecommendationBannerResponse.from(it.toBanner()) },
recentlyActiveCreators = queryService.findRecentlyActiveCreators( recentlyActiveCreators = queryService.findRecentlyActiveCreators(
HOME_ACTIVE_CREATOR_LIMIT, HOME_ACTIVE_CREATOR_LIMIT,
@@ -267,7 +271,7 @@ class HomeRecommendationFacade(
title = title, title = title,
price = price, price = price,
coverImage = imageUrl(cloudFrontHost, coverImage), coverImage = imageUrl(cloudFrontHost, coverImage),
isPointAvailable = isPointAvailable isPointAvailable = isPointAvailable && price > 0
) )
private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem( private fun HomeAiCharacterRecommendationRecord.toItem() = HomeAiCharacterItem(

View File

@@ -406,7 +406,7 @@ class DefaultMainContentAllQueryRepository(
} }
private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? { private fun optionalAudioPointCondition(onlyPointAvailable: Boolean): BooleanExpression? {
return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue else null return if (onlyPointAvailable) audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0)) else null
} }
private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? { private fun optionalOriginalCondition(onlyOriginal: Boolean): BooleanExpression? {

View File

@@ -63,7 +63,8 @@ class DefaultAudioRecommendationQueryRepository(
.leftJoin(series.member, seriesOwner) .leftJoin(series.member, seriesOwner)
.where( .where(
audioContentBanner.isActive.isTrue, audioContentBanner.isActive.isTrue,
audioContentBanner.tab.isNull, audioContentBanner.tab.id.eq(2L),
adultBannerCondition(canViewAdultContent),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
) )
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
@@ -135,7 +136,12 @@ class DefaultAudioRecommendationQueryRepository(
now: LocalDateTime now: LocalDateTime
): List<AudioCard> { ): List<AudioCard> {
val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')") val randomTieBreaker = Expressions.numberTemplate(Double::class.java, "function('rand')")
val rows = audioRows(memberId, canViewAdultContent, now, audioContent.isPointAvailable.isTrue) { val rows = audioRows(
memberId,
canViewAdultContent,
now,
audioContent.isPointAvailable.isTrue.and(audioContent.price.gt(0))
) {
orderBy(randomTieBreaker.asc()).limit(limit.toLong()) orderBy(randomTieBreaker.asc()).limit(limit.toLong())
} }
return rows.toAudioCards(now, canViewAdultContent) return rows.toAudioCards(now, canViewAdultContent)
@@ -539,6 +545,10 @@ class DefaultAudioRecommendationQueryRepository(
return if (canViewAdultContent) null else series.isAdult.isFalse return if (canViewAdultContent) null else series.isAdult.isFalse
} }
private fun adultBannerCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContentBanner.isAdult.isFalse
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? { private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse return if (canViewAdultContent) null else audioContent.isAdult.isFalse
} }

View File

@@ -87,7 +87,8 @@ class DefaultHomeRecommendationQueryRepository(
override fun findHomeBanners( override fun findHomeBanners(
limit: Int, limit: Int,
memberId: Long? memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> { ): List<HomeBannerRecommendationRecord> {
val bannerCreator = QMember("bannerCreator") val bannerCreator = QMember("bannerCreator")
val seriesOwner = QMember("seriesOwner") val seriesOwner = QMember("seriesOwner")
@@ -115,6 +116,7 @@ class DefaultHomeRecommendationQueryRepository(
.where( .where(
audioContentBanner.isActive.isTrue, audioContentBanner.isActive.isTrue,
audioContentBanner.tab.isNull, audioContentBanner.tab.isNull,
includeAdultBannerCondition(includeAdultBanners),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
) )
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
@@ -154,6 +156,7 @@ class DefaultHomeRecommendationQueryRepository(
and lr.channel_name <> '' and lr.channel_name <> ''
and (:includeAdultActivities = true or lr.is_adult = false) and (:includeAdultActivities = true or lr.is_adult = false)
and m.is_active = true and m.is_active = true
and m.role = 'CREATOR'
union all union all
select m.id as creator_id, select m.id as creator_id,
m.nickname as creator_nickname, m.nickname as creator_nickname,
@@ -169,6 +172,7 @@ class DefaultHomeRecommendationQueryRepository(
and ac.release_date is not null and ac.release_date is not null
and (:includeAdultActivities = true or ac.is_adult = false) and (:includeAdultActivities = true or ac.is_adult = false)
and m.is_active = true and m.is_active = true
and m.role = 'CREATOR'
union all union all
select m.id as creator_id, select m.id as creator_id,
m.nickname as creator_nickname, m.nickname as creator_nickname,
@@ -182,6 +186,7 @@ class DefaultHomeRecommendationQueryRepository(
where cc.is_active = true where cc.is_active = true
and (:includeAdultActivities = true or cc.is_adult = false) and (:includeAdultActivities = true or cc.is_adult = false)
and m.is_active = true and m.is_active = true
and m.role = 'CREATOR'
) activities ) activities
) ranked ) ranked
where ranked.creator_rank = 1 where ranked.creator_rank = 1
@@ -1262,6 +1267,10 @@ class DefaultHomeRecommendationQueryRepository(
return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse return if (includeAdultCommunities) null else creatorCommunity.isAdult.isFalse
} }
private fun includeAdultBannerCondition(includeAdultBanners: Boolean): BooleanExpression? {
return if (includeAdultBanners) null else audioContentBanner.isAdult.isFalse
}
private fun includeAdultLiveCondition(includeAdultLives: Boolean): BooleanExpression? { private fun includeAdultLiveCondition(includeAdultLives: Boolean): BooleanExpression? {
return if (includeAdultLives) null else liveRoom.isAdult.isFalse return if (includeAdultLives) null else liveRoom.isAdult.isFalse
} }

View File

@@ -43,9 +43,10 @@ class HomeRecommendationQueryService(
fun findHomeBanners( fun findHomeBanners(
limit: Int = DEFAULT_BANNER_LIMIT, limit: Int = DEFAULT_BANNER_LIMIT,
memberId: Long? = null memberId: Long? = null,
includeAdultBanners: Boolean = false
): List<HomeBannerRecommendationRecord> { ): List<HomeBannerRecommendationRecord> {
return queryPort.findHomeBanners(limit, memberId) return queryPort.findHomeBanners(limit, memberId, includeAdultBanners)
} }
fun findRecentlyActiveCreators( fun findRecentlyActiveCreators(

View File

@@ -13,7 +13,8 @@ interface HomeRecommendationQueryPort {
fun findHomeBanners( fun findHomeBanners(
limit: Int, limit: Int,
memberId: Long? = null memberId: Long? = null,
includeAdultBanners: Boolean = false
): List<HomeBannerRecommendationRecord> ): List<HomeBannerRecommendationRecord>
fun findRecentlyActiveCreators( fun findRecentlyActiveCreators(

View File

@@ -0,0 +1,169 @@
package kr.co.vividnext.sodalive.admin.calculate
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanRepository
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.AudioContentRepository
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityRepository
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.jpa.database-platform=kr.co.vividnext.sodalive.support.H2MySqlFunctionDialect"
]
)
@Import(QueryDslConfig::class)
class AdminCalculateQueryRepositoryTest @Autowired constructor(
private val queryFactory: JPAQueryFactory,
private val memberRepository: MemberRepository,
private val liveRoomRepository: LiveRoomRepository,
private val audioContentRepository: AudioContentRepository,
private val orderRepository: OrderRepository,
private val creatorCommunityRepository: CreatorCommunityRepository,
private val useCanRepository: UseCanRepository,
private val entityManager: EntityManager
) {
private lateinit var repository: AdminCalculateQueryRepository
@BeforeEach
fun setup() {
repository = AdminCalculateQueryRepository(queryFactory)
}
@Test
@DisplayName("이메일이 없는 크리에이터도 크리에이터별 정산에서 번호로 조회한다")
fun shouldReturnCreatorIdForByCreatorRowsWhenCreatorEmailIsNull() {
// given
val creator = saveMember(email = null, nickname = "creator", role = MemberRole.CREATOR)
val buyer = saveMember(email = "buyer@test.com", nickname = "buyer", role = MemberRole.USER)
val createdAt = LocalDateTime.of(2026, 8, 4, 12, 0, 0)
val room = saveLiveRoom(creator)
saveUseCan(buyer, CanUsage.LIVE, 10, createdAt) { this.room = room }
val content = saveAudioContent(creator)
saveOrder(buyer, creator, content, createdAt)
val post = saveCommunityPost(creator)
saveUseCan(buyer, CanUsage.PAID_COMMUNITY_POST, 30, createdAt) { communityPost = post }
entityManager.flush()
entityManager.clear()
val startDate = LocalDateTime.of(2026, 8, 4, 0, 0, 0)
val endDate = LocalDateTime.of(2026, 8, 4, 23, 59, 59)
// when
val liveRows = repository.getCalculateLiveByCreator(startDate, endDate, offset = 0, limit = 10)
val contentRows = repository.getCalculateContentByCreator(startDate, endDate, offset = 0, limit = 10)
val communityRows = repository.getCalculateCommunityByCreator(startDate, endDate, offset = 0, limit = 10)
// then
assertEquals(listOf(creator.id), liveRows.map { it.creatorId })
assertEquals(listOf(creator.id), contentRows.map { it.creatorId })
assertEquals(listOf(creator.id), communityRows.map { it.creatorId })
}
private fun saveMember(email: String?, nickname: String, role: MemberRole): Member {
return memberRepository.saveAndFlush(
Member(
email = email,
password = "password",
nickname = nickname,
role = role
)
)
}
private fun saveLiveRoom(creator: Member): LiveRoom {
val room = LiveRoom(
title = "live-room",
notice = "notice",
beginDateTime = LocalDateTime.of(2026, 8, 4, 8, 0, 0),
numberOfPeople = 1,
isAdult = false,
price = 10
)
room.member = creator
return liveRoomRepository.saveAndFlush(room)
}
private fun saveAudioContent(creator: Member): AudioContent {
val theme = AudioContentTheme(theme = "theme", image = "theme.png")
entityManager.persist(theme)
val content = AudioContent(
title = "content",
detail = "detail",
languageCode = "ko",
price = 20
)
content.theme = theme
content.member = creator
content.isActive = true
return audioContentRepository.saveAndFlush(content)
}
private fun saveOrder(buyer: Member, creator: Member, content: AudioContent, createdAt: LocalDateTime) {
val order = Order(type = OrderType.KEEP)
order.member = buyer
order.creator = creator
order.audioContent = content
val saved = orderRepository.saveAndFlush(order)
updateCreatedAt("Order", saved.id!!, createdAt)
}
private fun saveCommunityPost(creator: Member): CreatorCommunity {
val post = CreatorCommunity(
content = "community-content",
price = 30,
isCommentAvailable = true,
isAdult = false
)
post.member = creator
return creatorCommunityRepository.saveAndFlush(post)
}
private fun saveUseCan(
member: Member,
usage: CanUsage,
can: Int,
createdAt: LocalDateTime,
configure: UseCan.() -> Unit
) {
val useCan = UseCan(canUsage = usage, can = can, rewardCan = 0)
useCan.member = member
useCan.configure()
val saved = useCanRepository.saveAndFlush(useCan)
updateCreatedAt("UseCan", saved.id!!, createdAt)
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
}

View File

@@ -9,11 +9,13 @@ import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository import kr.co.vividnext.sodalive.can.use.UseCanCalculateRepository
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.convertLocalDateTime
import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.LiveRoomRepository import kr.co.vividnext.sodalive.live.room.LiveRoomRepository
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@@ -24,6 +26,8 @@ import org.mockito.Mockito.atLeastOnce
import org.mockito.Mockito.mock import org.mockito.Mockito.mock
import org.mockito.Mockito.verify import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` import org.mockito.Mockito.`when`
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.Optional import java.util.Optional
class AdminCalculateServiceTest { class AdminCalculateServiceTest {
@@ -45,6 +49,35 @@ class AdminCalculateServiceTest {
langContext langContext
) )
@Test
@DisplayName("크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 출력한다")
fun shouldWriteCreatorIdInsteadOfEmailToCreatorSettlementExcel() {
// given
val startDate = "2026-08-04".convertLocalDateTime()
val endDate = "2026-08-04".convertLocalDateTime(hour = 23, minute = 59, second = 59)
val queryData = GetCalculateByCreatorQueryData(
creatorId = 42L,
nickname = "creator",
totalCan = 100,
settlementRatio = 70
)
`when`(repository.getCalculateLiveByCreatorTotalCount(startDate, endDate)).thenReturn(1)
`when`(repository.getCalculateLiveByCreator(startDate, endDate, 0L, 1L)).thenReturn(listOf(queryData))
// when
val output = ByteArrayOutputStream()
adminCalculateService.downloadCalculateLiveByCreatorExcel("2026-08-04", "2026-08-04").writeTo(output)
// then
XSSFWorkbook(ByteArrayInputStream(output.toByteArray())).use { workbook ->
val sheet = workbook.getSheetAt(0)
assertEquals("크리에이터 번호", sheet.getRow(0).getCell(0).stringCellValue)
assertEquals(42.0, sheet.getRow(1).getCell(0).numericCellValue)
assertEquals("닉네임", sheet.getRow(0).getCell(1).stringCellValue)
}
}
@Test @Test
@DisplayName("라이브 환불 성공 테스트") @DisplayName("라이브 환불 성공 테스트")
fun shouldRefundLiveSuccessfully() { fun shouldRefundLiveSuccessfully() {

View File

@@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@@ -282,6 +283,50 @@ class AudioContentServiceTest {
assertTrue(output.out.contains("contentId=${audioContent.id}")) assertTrue(output.out.contains("contentId=${audioContent.id}"))
} }
@Test
@DisplayName("콘텐츠 상세는 가격과 저장값을 함께 반영해 포인트 사용 가능 여부를 반환한다")
fun shouldReturnEffectivePointAvailabilityForAudioDetail() {
val viewer = createMember(id = 1004L, nickname = "free-detail-viewer")
val creator = createMember(id = 2004L, nickname = "free-detail-creator")
val audioContent = createAudioContent(creator)
audioContent.price = 0
audioContent.isPointAvailable = true
stubSuccessfulDetailDependencies(viewer, creator, audioContent)
val response = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertFalse(response.isAvailableUsePoint)
audioContent.price = 100
audioContent.isPointAvailable = false
val paidUnavailableResponse = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertFalse(paidUnavailableResponse.isAvailableUsePoint)
audioContent.price = 100
audioContent.isPointAvailable = true
val paidAvailableResponse = service.getDetail(
id = audioContent.id!!,
member = viewer,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertTrue(paidAvailableResponse.isAvailableUsePoint)
}
@Test @Test
@DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다") @DisplayName("업로드 완료 시 즉시 공개 콘텐츠는 최근 소식을 발행한다")
fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() { fun shouldPublishNewsWhenUploadCompleteMakesContentPublicImmediately() {

View File

@@ -0,0 +1,96 @@
package kr.co.vividnext.sodalive.creator.admin.content.series
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.content.hashtag.HashTag
import kr.co.vividnext.sodalive.creator.admin.content.series.keyword.SeriesKeyword
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.support.TransactionTemplate
import javax.persistence.EntityManager
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://cdn.test"])
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorAdminContentSeriesServiceIntegrationTest @Autowired constructor(
private val service: CreatorAdminContentSeriesService,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("OSIV off 환경에서 크리에이터 관리자 시리즈 상세는 키워드와 기존 필드를 반환한다")
fun shouldReturnOwnedSeriesDetailWithKeywordsWhenOpenInViewIsDisabled() {
val fixtureIds = createSeriesFixture()
val response = service.getDetail(id = fixtureIds.seriesId, memberId = fixtureIds.memberId)
assertEquals(fixtureIds.seriesId, response.seriesId)
assertEquals("lazy series", response.title)
assertEquals("lazy series introduction", response.introduction)
assertEquals("https://cdn.test/series/lazy-series.png", response.coverImageUrl)
assertEquals("월, 수", response.publishedDaysOfWeek)
assertEquals("lazy genre", response.genre)
assertEquals("#lazy-keyword", response.keywords)
assertEquals(true, response.isAdult)
assertEquals("완결", response.state)
assertEquals("lazy writer", response.writer)
assertEquals("lazy studio", response.studio)
}
private fun createSeriesFixture(): FixtureIds {
return transactionTemplate.execute {
val member = Member(
email = "creator-admin-series-detail@test.com",
password = "password",
nickname = "creator-admin-series-detail",
role = MemberRole.CREATOR
)
entityManager.persist(member)
val genre = SeriesGenre(genre = "lazy genre")
entityManager.persist(genre)
val hashTag = HashTag("#lazy-keyword")
entityManager.persist(hashTag)
val series = Series(
title = "lazy series",
introduction = "lazy series introduction",
state = SeriesState.COMPLETE,
writer = "lazy writer",
studio = "lazy studio",
publishedDaysOfWeek = mutableSetOf(
SeriesPublishedDaysOfWeek.MON,
SeriesPublishedDaysOfWeek.WED
),
isAdult = true
).apply {
this.member = member
this.genre = genre
coverImage = "series/lazy-series.png"
}
series.keywordList.add(
SeriesKeyword().apply {
this.series = series
keyword = hashTag
}
)
entityManager.persist(series)
entityManager.flush()
val fixtureIds = FixtureIds(memberId = member.id!!, seriesId = series.id!!)
entityManager.clear()
fixtureIds
}!!
}
private data class FixtureIds(
val memberId: Long,
val seriesId: Long
)
}

View File

@@ -339,7 +339,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
val content = saveAudioContent( val content = saveAudioContent(
owner = character.creatorMember!!, owner = character.creatorMember!!,
title = "target night walk", title = "target night walk",
contentPath = "private/target-night-walk.mp3" contentPath = "private/target-night-walk.mp3",
price = 0
) )
val otherCharacter = chatCharacterService.createChatCharacterWithDetails( val otherCharacter = chatCharacterService.createChatCharacterWithDetails(
characterUUID = "v2-audio-other-character", characterUUID = "v2-audio-other-character",
@@ -374,6 +375,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.totalCount").value(1)) .andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id)) .andExpect(jsonPath("$.data.items[0].audioContentId").value(content.id))
.andExpect(jsonPath("$.data.items[0].title").value("target night walk")) .andExpect(jsonPath("$.data.items[0].title").value("target night walk"))
.andExpect(jsonPath("$.data.items[0].isPointAvailable").value(true))
.andExpect( .andExpect(
jsonPath("$.data.items[0].contentUrl").value( jsonPath("$.data.items[0].contentUrl").value(
"https://signed.example.com/private/target-night-walk.mp3?Expires=1" "https://signed.example.com/private/target-night-walk.mp3?Expires=1"
@@ -495,7 +497,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
val content = saveAudioContent( val content = saveAudioContent(
owner = character.creatorMember!!, owner = character.creatorMember!!,
title = "detail night walk", title = "detail night walk",
contentPath = "private/detail-night-walk.mp3" contentPath = "private/detail-night-walk.mp3",
price = 0
) )
entityManager.flush() entityManager.flush()
Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong())) Mockito.`when`(audioContentCloudFront.generateSignedURL(Mockito.anyString(), Mockito.anyLong()))
@@ -899,7 +902,8 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
owner: Member, owner: Member,
title: String, title: String,
contentPath: String, contentPath: String,
releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime() releaseDate: LocalDateTime = Instant.now().minusSeconds(60).atOffset(ZoneOffset.UTC).toLocalDateTime(),
price: Int = 100
): AudioContent { ): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png") val theme = AudioContentTheme(theme = "theme-$title", image = "theme.png")
entityManager.persist(theme) entityManager.persist(theme)
@@ -907,7 +911,7 @@ class AiCharacterAdminAudioContentControllerTest @Autowired constructor(
title = title, title = title,
detail = "detail", detail = "detail",
languageCode = "ko", languageCode = "ko",
price = 100, price = price,
isAdult = false, isAdult = false,
isPointAvailable = true, isPointAvailable = true,
isCommentAvailable = true isCommentAvailable = true

View File

@@ -94,6 +94,27 @@ class MainContentAllEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.audios.length()").value(1)) .andExpect(jsonPath("$.data.audios.length()").value(1))
} }
@Test
@DisplayName("전체 탭 POINT API는 무료 포인트 콘텐츠를 목록과 페이징 후보에서 제외한다")
fun shouldExcludeFreePointAudioContentsForPointType() {
val fixture = createAudioFixture("main-all-point-paid-e2e", freeControl = true)
mockMvc.perform(
get("/api/v2/audio/contents")
.param("type", "POINT")
.param("sort", "LATEST")
.param("page", "0")
.param("size", "1")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.type").value("POINT"))
.andExpect(jsonPath("$.data.totalCount").value(1))
.andExpect(jsonPath("$.data.audios.length()").value(1))
.andExpect(jsonPath("$.data.audios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.audios[0].price").value(100))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test @Test
@DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다") @DisplayName("전체 탭 SERIES API는 dayOfWeek 조건으로 시리즈 응답과 빈 audios를 반환한다")
fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() { fun shouldReturnSeriesContentsFilteredByDayOfWeekThroughControllerServiceAndRepository() {

View File

@@ -67,6 +67,28 @@ class MainContentAllTabResponseTest {
isFirstContent = true, isFirstContent = true,
isOriginalSeries = false, isOriginalSeries = false,
creatorNickname = "creator" creatorNickname = "creator"
),
MainContentAllAudio(
audioContentId = 2L,
title = "free audio",
imageUrl = "https://cdn/free-audio.jpg",
price = 0,
isAdult = false,
isPointAvailable = true,
isFirstContent = false,
isOriginalSeries = false,
creatorNickname = "creator"
),
MainContentAllAudio(
audioContentId = 3L,
title = "point unavailable audio",
imageUrl = "https://cdn/point-unavailable-audio.jpg",
price = 100,
isAdult = false,
isPointAvailable = false,
isFirstContent = false,
isOriginalSeries = false,
creatorNickname = "creator"
) )
), ),
series = emptyList(), series = emptyList(),
@@ -83,6 +105,8 @@ class MainContentAllTabResponseTest {
assertEquals(true, json["audios"][0]["isPointAvailable"].asBoolean()) assertEquals(true, json["audios"][0]["isPointAvailable"].asBoolean())
assertEquals(true, json["audios"][0]["isFirstContent"].asBoolean()) assertEquals(true, json["audios"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["audios"][0]["isOriginalSeries"].asBoolean()) assertEquals(false, json["audios"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["audios"][1]["isPointAvailable"].asBoolean())
assertEquals(false, json["audios"][2]["isPointAvailable"].asBoolean())
assertEquals(true, json["hasNext"].asBoolean()) assertEquals(true, json["hasNext"].asBoolean())
assertFalse(json["audios"][0].has("duration")) assertFalse(json["audios"][0].has("duration"))
assertFalse(json["series"].any { it.has("publishedDaysOfWeek") }) assertFalse(json["series"].any { it.has("publishedDaysOfWeek") })

View File

@@ -1,8 +1,11 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.dto package kr.co.vividnext.sodalive.v2.api.content.overview.dto
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -77,4 +80,71 @@ class ContentOverviewPageResponseTest {
assertEquals(true, response.isAdult) assertEquals(true, response.isAdult)
assertEquals(true, response.isOriginalSeries) assertEquals(true, response.isOriginalSeries)
} }
@Test
@DisplayName("NEW_AND_HOT 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeNewAndHotAudio() {
val freeAudio = audioCard(price = 0, isPointAvailable = true)
val paidAudio = audioCard(price = 100, isPointAvailable = true)
val paidUnavailableAudio = audioCard(price = 100, isPointAvailable = false)
assertFalse(ContentOverviewItemResponse.fromNewAndHot(freeAudio).isPointAvailable)
assertTrue(ContentOverviewItemResponse.fromNewAndHot(paidAudio).isPointAvailable)
assertFalse(ContentOverviewItemResponse.fromNewAndHot(paidUnavailableAudio).isPointAvailable)
}
@Test
@DisplayName("첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
val freeAudio = firstAudio(price = 0, isPointAvailable = true)
val paidAudio = firstAudio(price = 100, isPointAvailable = true)
val paidUnavailableAudio = firstAudio(price = 100, isPointAvailable = false)
assertFalse(
ContentOverviewItemResponse.fromFirstAudioContent(freeAudio, "https://cdn.test/free.png", false, false)
.isPointAvailable
)
assertTrue(
ContentOverviewItemResponse.fromFirstAudioContent(paidAudio, "https://cdn.test/paid.png", false, false)
.isPointAvailable
)
assertFalse(
ContentOverviewItemResponse.fromFirstAudioContent(
paidUnavailableAudio,
"https://cdn.test/paid-unavailable.png",
false,
false
).isPointAvailable
)
}
private fun audioCard(price: Int, isPointAvailable: Boolean): AudioCard {
return AudioCard(
audioContentId = price.toLong() + 1,
title = "audio",
duration = "00:01",
imageUrl = "https://cdn.test/audio.png",
price = price,
isAdult = false,
isPointAvailable = isPointAvailable,
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator"
)
}
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = price.toLong() + 1,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileImage = null,
title = "first audio",
price = price,
coverImage = "cover/audio.png",
isPointAvailable = isPointAvailable,
isAdult = false,
isOriginalSeries = false
)
}
} }

View File

@@ -54,6 +54,9 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true)) .andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true))
.andExpect(jsonPath("$.data.recommendedAudios").isArray) .andExpect(jsonPath("$.data.recommendedAudios").isArray)
.andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId)) .andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId))
.andExpect(jsonPath("$.data.pointAudios.length()").value(1))
.andExpect(jsonPath("$.data.pointAudios[0].audioContentId").value(fixture.paidPointAudioContentId))
.andExpect(jsonPath("$.data.pointAudios[0].price").value(100))
.andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment")) .andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment"))
.andExpect( .andExpect(
jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl") jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl")
@@ -68,6 +71,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png") val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png")
val theme = saveTheme() val theme = saveTheme()
val audio = saveAudio(creator, theme, now) val audio = saveAudio(creator, theme, now)
val paidPointAudio = saveAudio(creator, theme, now.minusMinutes(10), "paid-point", 100)
val series = saveSeries(creator) val series = saveSeries(creator)
saveSeriesContent(series, audio) saveSeriesContent(series, audio)
saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10)) saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10))
@@ -79,7 +83,8 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
Fixture( Fixture(
seriesId = series.id!!, seriesId = series.id!!,
audioContentId = audio.id!! audioContentId = audio.id!!,
paidPointAudioContentId = paidPointAudio.id!!
) )
}!! }!!
} }
@@ -102,14 +107,20 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
return theme return theme
} }
private fun saveAudio(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent { private fun saveAudio(
creator: Member,
theme: AudioContentTheme,
releaseDate: LocalDateTime,
title: String = "audio-recommendation-e2e",
price: Int = 0
): AudioContent {
val audio = AudioContent( val audio = AudioContent(
title = "audio-recommendation-e2e", title = title,
detail = "detail", detail = "detail",
languageCode = "ko", languageCode = "ko",
releaseDate = releaseDate, releaseDate = releaseDate,
isAdult = false, isAdult = false,
price = 0, price = price,
isPointAvailable = true isPointAvailable = true
) )
audio.member = creator audio.member = creator
@@ -174,6 +185,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
private data class Fixture( private data class Fixture(
val seriesId: Long, val seriesId: Long,
val audioContentId: Long val audioContentId: Long,
val paidPointAudioContentId: Long
) )
} }

View File

@@ -33,10 +33,13 @@ class AudioRecommendationFacadeTest {
assertEquals(1, response.mostCommentedAudios.size) assertEquals(1, response.mostCommentedAudios.size)
assertEquals(1, response.recommendedAudios.size) assertEquals(1, response.recommendedAudios.size)
assertEquals(false, response.latestAudios[0].isOriginalSeries) assertEquals(false, response.latestAudios[0].isOriginalSeries)
assertEquals(false, response.latestAudios[0].isPointAvailable)
assertEquals(true, response.newAndHotAudios[0].isPointAvailable)
assertEquals(false, response.recommendedAudios[0].isPointAvailable)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean()) assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean())
assertEquals(true, json["latestAudios"][0]["isPointAvailable"].asBoolean()) assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean())
assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean()) assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean())
assertEquals(false, json["latestAudios"][0]["isOriginalSeries"].asBoolean()) assertEquals(false, json["latestAudios"][0]["isOriginalSeries"].asBoolean())
assertEquals(false, json["latestAudios"][0].has("adult")) assertEquals(false, json["latestAudios"][0].has("adult"))
@@ -61,7 +64,7 @@ class AudioRecommendationFacadeTest {
banners = listOf(RecommendationBanner("https://cdn.test/banner.png", null, null, null, "https://link.test")), banners = listOf(RecommendationBanner("https://cdn.test/banner.png", null, null, null, "https://link.test")),
originalSeries = listOf(OriginalSeries(2L, "https://cdn.test/series.png")), originalSeries = listOf(OriginalSeries(2L, "https://cdn.test/series.png")),
latestAudios = listOf(card), latestAudios = listOf(card),
newAndHotAudios = listOf(card.copy(audioContentId = 3L)), newAndHotAudios = listOf(card.copy(audioContentId = 3L, price = 100)),
freeAudios = listOf(card.copy(audioContentId = 4L)), freeAudios = listOf(card.copy(audioContentId = 4L)),
pointAudios = listOf(card.copy(audioContentId = 5L)), pointAudios = listOf(card.copy(audioContentId = 5L)),
mostCommentedAudios = listOf( mostCommentedAudios = listOf(
@@ -73,7 +76,7 @@ class AudioRecommendationFacadeTest {
latestCommentWriterProfileImageUrl = "https://cdn.test/profile.png" latestCommentWriterProfileImageUrl = "https://cdn.test/profile.png"
) )
), ),
recommendedAudios = listOf(card.copy(audioContentId = 7L)) recommendedAudios = listOf(card.copy(audioContentId = 7L, price = 100, isPointAvailable = false))
) )
} }
} }

View File

@@ -0,0 +1,38 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto
import kr.co.vividnext.sodalive.v2.creator.channel.common.domain.CreatorChannelAudioContent
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class CreatorChannelAudioContentResponseTest {
@Test
@DisplayName("크리에이터 채널 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeAudioContent() {
val freeAudio = audioContent(price = 0, isPointAvailable = true)
val paidAudio = audioContent(price = 100, isPointAvailable = true)
val paidUnavailableAudio = audioContent(price = 100, isPointAvailable = false)
assertFalse(CreatorChannelAudioContentResponse.from(freeAudio).isPointAvailable)
assertTrue(CreatorChannelAudioContentResponse.from(paidAudio).isPointAvailable)
assertFalse(CreatorChannelAudioContentResponse.from(paidUnavailableAudio).isPointAvailable)
}
private fun audioContent(price: Int, isPointAvailable: Boolean): CreatorChannelAudioContent {
return CreatorChannelAudioContent(
audioContentId = price.toLong() + 1,
title = "audio",
duration = "00:10:00",
imageUrl = "audio.png",
price = price,
isAdult = false,
isPointAvailable = isPointAvailable,
isFirstContent = true,
seriesName = "series",
isOriginalSeries = true,
isOwned = false,
isRented = false
)
}
}

View File

@@ -66,12 +66,16 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId)) .andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
.andExpect(jsonPath("$.data.channelDonations[0].can").value(500)) .andExpect(jsonPath("$.data.channelDonations[0].can").value(500))
.andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId)) .andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId))
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.schedules[0].targetId").value(fixture.liveScheduleId)) .andExpect(jsonPath("$.data.schedules[0].targetId").value(fixture.liveScheduleId))
.andExpect(jsonPath("$.data.schedules[1].targetId").value(fixture.audioScheduleId)) .andExpect(jsonPath("$.data.schedules[1].targetId").value(fixture.audioScheduleId))
.andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.listAudioId)) .andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.listAudioId))
.andExpect(jsonPath("$.data.audioContents[1].audioContentId").value(fixture.firstAudioId)) .andExpect(jsonPath("$.data.audioContents[1].audioContentId").value(fixture.firstAudioId))
.andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId)) .andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId))
.andExpect(jsonPath("$.data.communities[0].postId").value(fixture.communityId)) .andExpect(jsonPath("$.data.communities[0].postId").value(fixture.communityId))
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.fanTalk.latestFanTalk.fanTalkId").value(fixture.fanTalkId)) .andExpect(jsonPath("$.data.fanTalk.latestFanTalk.fanTalkId").value(fixture.fanTalkId))
.andExpect(jsonPath("$.data.introduce").value("e2e introduce")) .andExpect(jsonPath("$.data.introduce").value("e2e introduce"))
.andExpect(jsonPath("$.data.activity.audioContentCount").value(3)) .andExpect(jsonPath("$.data.activity.audioContentCount").value(3))

View File

@@ -135,7 +135,11 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z")) .andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z"))
.andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist()) .andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist()) .andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
.andExpect(jsonPath("$.data.notices[0].isLiked").value(true)) .andExpect(jsonPath("$.data.notices[0].isLiked").value(true))
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(false))
.andExpect(jsonPath("$.data.communities[0].isLiked").value(true)) .andExpect(jsonPath("$.data.communities[0].isLiked").value(true))
.andExpect(jsonPath("$.data.series[0].isNew").value(true)) .andExpect(jsonPath("$.data.series[0].isNew").value(true))
.andExpect(jsonPath("$.data.series[0].isOriginal").value(true)) .andExpect(jsonPath("$.data.series[0].isOriginal").value(true))
@@ -283,7 +287,14 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
isOriginal = true isOriginal = true
) )
), ),
communities = listOf(post.copy(postId = 302L, content = "community")), communities = listOf(
post.copy(
postId = 302L,
content = "community",
isCommentAvailable = false,
isPinned = false
)
),
fanTalk = CreatorChannelFanTalkSummary( fanTalk = CreatorChannelFanTalkSummary(
totalCount = 1, totalCount = 1,
latestFanTalk = CreatorChannelFanTalk( latestFanTalk = CreatorChannelFanTalk(

View File

@@ -0,0 +1,183 @@
package kr.co.vividnext.sodalive.v2.api.home.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeAiCharacterRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeBannerRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeCheerCreatorRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeGenreCreatorRecommendationGroup
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class HomeRecommendationFacadeTest {
private val queryPort = FakeHomeRecommendationQueryPort()
private val queryService = HomeRecommendationQueryService(queryPort, EmptyRecommendationSnapshotPort())
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = HomeRecommendationFacade(queryService, preferenceService, "https://cdn.test")
@Test
@DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
queryPort.firstAudioContents = listOf(
firstAudio(price = 0, isPointAvailable = true),
firstAudio(price = 100, isPointAvailable = true),
firstAudio(price = 100, isPointAvailable = false)
)
val response = facade.getHomeRecommendations(null)
assertFalse(response.firstAudioContents[0].isPointAvailable)
assertTrue(response.firstAudioContents[1].isPointAvailable)
assertFalse(response.firstAudioContents[2].isPointAvailable)
}
@Test
@DisplayName("홈 추천은 회원의 성인 콘텐츠 조회 가능 여부를 배너 조회에 전달한다")
fun shouldPassAdultVisibilityToHomeBannerQuery() {
val member = Member(password = "password", nickname = "member").apply { id = 10L }
Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member)
facade.getHomeRecommendations(member)
assertTrue(queryPort.bannerIncludeAdultBanners)
}
private fun firstAudio(price: Int, isPointAvailable: Boolean): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = price.toLong() + 1,
creatorId = 10L,
creatorNickname = "creator",
creatorProfileImage = null,
title = "first audio",
price = price,
coverImage = "cover/audio.png",
isPointAvailable = isPointAvailable,
isAdult = false,
isOriginalSeries = false
)
}
private class FakeHomeRecommendationQueryPort : HomeRecommendationQueryPort {
var firstAudioContents: List<HomeFirstAudioContentRecord> = emptyList()
var bannerIncludeAdultBanners: Boolean = false
override fun findLiveRecommendations(
offset: Long,
limit: Int,
memberId: Long?,
includeAdultLives: Boolean
): List<HomeLiveRecommendationRecord> = emptyList()
override fun findHomeBanners(
limit: Int,
memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> {
bannerIncludeAdultBanners = includeAdultBanners
return emptyList()
}
override fun findRecentlyActiveCreators(
limit: Int,
memberId: Long?,
includeAdultActivities: Boolean
): List<RecentlyActiveCreatorRecord> = emptyList()
override fun findRecentDebutCreators(
now: LocalDateTime,
offset: Long,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
): List<RecentDebutCreatorRecord> = emptyList()
override fun findFirstAudioContents(
now: LocalDateTime,
offset: Long,
limit: Int,
memberId: Long?,
includeAdultContents: Boolean
): List<HomeFirstAudioContentRecord> = firstAudioContents
override fun findAiCharacterSnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findCheerCreatorSnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findPopularCommunitySnapshots(
windowStart: LocalDateTime,
windowEndExclusive: LocalDateTime,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findAiCharacterRecommendationDetails(
characterIds: List<Long>
): List<HomeAiCharacterRecommendationRecord> = emptyList()
override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List<Long>, limit: Int): List<Long> = emptyList()
override fun findCheerCreatorRecommendationDetails(
creatorIds: List<Long>,
memberId: Long?
): List<HomeCheerCreatorRecommendationRecord> = emptyList()
override fun findPopularCommunityRecommendationDetails(
communityIds: List<Long>,
memberId: Long?,
includeAdultCommunities: Boolean
): List<HomePopularCommunityRecommendationRecord> = emptyList()
override fun findGenreCreatorRecommendations(
memberId: Long?,
includeAdultGenres: Boolean,
genreLimit: Int,
creatorLimit: Int
): List<HomeGenreCreatorRecommendationGroup> = emptyList()
}
private class EmptyRecommendationSnapshotPort : RecommendationSnapshotPort {
override fun findLatestSnapshots(
sectionType: RecommendedSectionType,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun findSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
offset: Long,
limit: Int
): List<RecommendationSnapshotRecord> = emptyList()
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean = true
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean = true
override fun replaceSnapshots(
sectionType: RecommendedSectionType,
snapshotAt: LocalDateTime,
newSnapshots: List<RecommendationSnapshotRecord>
) = Unit
}
}

View File

@@ -52,6 +52,7 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
val inactiveTheme = saveTheme("inactive-audio-theme", isActive = false) val inactiveTheme = saveTheme("inactive-audio-theme", isActive = false)
val free = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 0) val free = saveAudioContent(creator, theme, now.minusDays(3), isAdult = false, price = 0)
val point = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100, isPointAvailable = true) val point = saveAudioContent(creator, theme, now.minusDays(2), isAdult = false, price = 100, isPointAvailable = true)
val freePoint = saveAudioContent(creator, theme, now.minusHours(12), isAdult = false, price = 0, isPointAvailable = true)
saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 200) saveAudioContent(creator, theme, now.minusDays(1), isAdult = true, price = 200)
saveAudioContent(blockedCreator, theme, now.minusDays(1), isAdult = false, price = 100) saveAudioContent(blockedCreator, theme, now.minusDays(1), isAdult = false, price = 100)
saveAudioContent(inactiveCreator, theme, now.minusDays(1), isAdult = false, price = 100) saveAudioContent(inactiveCreator, theme, now.minusDays(1), isAdult = false, price = 100)
@@ -66,11 +67,12 @@ class DefaultMainContentAllQueryRepositoryTest @Autowired constructor(
val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true) val freeAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyFree = true)
val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true) val pointAudios = repository.findAudios(null, false, now, ContentSort.LATEST, 0, 20, onlyPointAvailable = true)
assertEquals(2, repository.countAudios(viewer.id, canViewAdultContent = false, now)) assertEquals(3, repository.countAudios(viewer.id, canViewAdultContent = false, now))
assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true)) assertEquals(1, repository.countAudios(viewer.id, false, now, onlyPaid = true))
assertEquals(listOf(point.id, free.id), visible.map { it.audioContentId }) assertEquals(1, repository.countAudios(null, false, now, onlyPointAvailable = true))
assertEquals(listOf(freePoint.id, point.id, free.id), visible.map { it.audioContentId })
assertEquals(listOf(point.id), paidAudios.map { it.audioContentId }) assertEquals(listOf(point.id), paidAudios.map { it.audioContentId })
assertEquals(listOf(free.id), freeAudios.map { it.audioContentId }) assertEquals(listOf(freePoint.id, free.id), freeAudios.map { it.audioContentId })
assertEquals(listOf(point.id), pointAudios.map { it.audioContentId }) assertEquals(listOf(point.id), pointAudios.map { it.audioContentId })
assertEquals("https://cdn.test/audio.png", visible.first().imageUrl) assertEquals("https://cdn.test/audio.png", visible.first().imageUrl)
} }

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.like.AudioContentLike import kr.co.vividnext.sodalive.content.like.AudioContentLike
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
@@ -41,25 +42,58 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test") private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
@Test @Test
@DisplayName("배너는 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다") @DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다")
fun shouldFindBannersWithHomeBannerPolicy() { fun shouldFindBannersForContentRecommendationTabWithAdultVisibility() {
val viewer = saveMember("viewer", MemberRole.USER) val viewer = saveMember("viewer", MemberRole.USER)
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR) val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator) val recommendationTab = saveContentRecommendationTab()
val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test") val visibleBanner = saveBanner(
saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test") "visible.png",
saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator) AudioContentBannerType.CREATOR,
1,
creator = visibleCreator,
tab = recommendationTab
)
val adultBanner = saveBanner(
"adult.png",
AudioContentBannerType.LINK,
2,
isAdult = true,
link = "https://adult.test",
tab = recommendationTab
)
saveBanner(
"inactive.png",
AudioContentBannerType.LINK,
3,
isActive = false,
link = "https://inactive.test",
tab = recommendationTab
)
saveBanner(
"blocked.png",
AudioContentBannerType.CREATOR,
4,
creator = blockedCreator,
tab = recommendationTab
)
saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test")
saveBlock(viewer, blockedCreator) saveBlock(viewer, blockedCreator)
flushAndClear() flushAndClear()
val banners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false) val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
val allBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = true)
assertEquals( assertEquals(
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"), listOf("https://cdn.test/${visibleBanner.thumbnailImage}"),
banners.map { it.imageUrl } safeBanners.map { it.imageUrl }
) )
assertEquals(visibleCreator.id, banners.first().creatorId) assertEquals(
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"),
allBanners.map { it.imageUrl }
)
assertEquals(visibleCreator.id, safeBanners.first().creatorId)
} }
@Test @Test
@@ -90,7 +124,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val now = LocalDateTime.of(2026, 6, 23, 12, 0) val now = LocalDateTime.of(2026, 6, 23, 12, 0)
val creator = saveMember("audio-creator", MemberRole.CREATOR) val creator = saveMember("audio-creator", MemberRole.CREATOR)
val theme = saveTheme() val theme = saveTheme()
val first = saveAudio( saveAudio(
creator = creator, creator = creator,
theme = theme, theme = theme,
title = "first", title = "first",
@@ -108,6 +142,15 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
isPointAvailable = false, isPointAvailable = false,
coverImage = "latest.png" coverImage = "latest.png"
) )
val paidPoint = saveAudio(
creator = creator,
theme = theme,
title = "paid-point",
releaseDate = now.minusDays(2),
price = 20,
isPointAvailable = true,
coverImage = "paid-point.png"
)
saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true) saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true)
saveAudio(creator, theme, "future", now.plusDays(1)) saveAudio(creator, theme, "future", now.plusDays(1))
saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false
@@ -139,11 +182,11 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now) val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now)
assertEquals(12, latestAudios.size) assertEquals(12, latestAudios.size)
assertEquals(listOf(latest.id, first.id), latestAudios.take(2).map { it.audioContentId }) assertEquals(listOf(latest.id, paidPoint.id), latestAudios.take(2).map { it.audioContentId })
assertEquals(10, freeAudios.size) assertEquals(10, freeAudios.size)
assertEquals(true, freeAudios.all { it.price == 0 }) assertEquals(true, freeAudios.all { it.price == 0 })
assertEquals(10, pointAudios.size) assertEquals(listOf(paidPoint.id), pointAudios.map { it.audioContentId })
assertEquals(true, pointAudios.all { it.isPointAvailable }) assertEquals(true, pointAudios.all { it.isPointAvailable && it.price > 0 })
val latestCard = latestAudios.first() val latestCard = latestAudios.first()
assertEquals("latest", latestCard.title) assertEquals("latest", latestCard.title)
assertEquals("00:01", latestCard.duration) assertEquals("00:01", latestCard.duration)
@@ -154,8 +197,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(false, latestCard.isFirstContent) assertEquals(false, latestCard.isFirstContent)
assertEquals(true, latestCard.isOriginalSeries) assertEquals(true, latestCard.isOriginalSeries)
assertEquals(creator.nickname, latestCard.creatorNickname) assertEquals(creator.nickname, latestCard.creatorNickname)
assertEquals(true, latestAudios[1].isFirstContent) assertEquals(true, latestAudios[2].isFirstContent)
assertEquals(false, latestAudios[1].isOriginalSeries) assertEquals(false, latestAudios[2].isOriginalSeries)
} }
@Test @Test
@@ -360,7 +403,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
isActive: Boolean = true, isActive: Boolean = true,
isAdult: Boolean = false, isAdult: Boolean = false,
creator: Member? = null, creator: Member? = null,
link: String? = null link: String? = null,
tab: AudioContentMainTab? = null
): AudioContentBanner { ): AudioContentBanner {
val banner = AudioContentBanner( val banner = AudioContentBanner(
thumbnailImage = thumbnailImage, thumbnailImage = thumbnailImage,
@@ -371,10 +415,18 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
) )
banner.creator = creator banner.creator = creator
banner.link = link banner.link = link
banner.tab = tab
entityManager.persist(banner) entityManager.persist(banner)
return banner return banner
} }
private fun saveContentRecommendationTab(): AudioContentMainTab {
entityManager.createNativeQuery(
"insert into audio_content_main_tab (id, title, is_active) values (2, '추천', true)"
).executeUpdate()
return entityManager.getReference(AudioContentMainTab::class.java, 2L)
}
private fun saveSeries( private fun saveSeries(
title: String, title: String,
creator: Member, creator: Member,

View File

@@ -262,6 +262,36 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage }) assertEquals(listOf(homeBanner.thumbnailImage), banners.map { it.thumbnailImage })
} }
@Test
@DisplayName("홈 배너는 성인 콘텐츠 조회가 불가능하면 비성인 배너만 반환한다")
fun shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible() {
val safeBanner = saveBanner(
"safe-home-banner.png",
AudioContentBannerType.LINK,
orders = 1,
isActive = true,
link = "https://safe-home-banner.test"
)
val adultBanner = saveBanner(
"adult-home-banner.png",
AudioContentBannerType.LINK,
orders = 2,
isActive = true,
isAdult = true,
link = "https://adult-home-banner.test"
)
flushAndClear()
val safeBanners = repository.findHomeBanners(limit = 20)
val allBanners = repository.findHomeBanners(limit = 20, includeAdultBanners = true)
assertEquals(listOf(safeBanner.thumbnailImage), safeBanners.map { it.thumbnailImage })
assertEquals(
listOf(safeBanner.thumbnailImage, adultBanner.thumbnailImage),
allBanners.map { it.thumbnailImage }
)
}
@Test @Test
@DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다") @DisplayName("홈 배너는 비활성 대상 엔티티를 제외하고 LINK는 배너 자체 활성 상태만으로 조회한다")
fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() { fun shouldExcludeHomeBannersWithInactiveTargetsExceptLink() {
@@ -527,6 +557,23 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType) assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType)
} }
@Test
@DisplayName("최근 활동 크리에이터는 현재 크리에이터 역할이 아닌 회원(전 크리에이터)의 활동을 제외한다")
fun shouldExcludeNonCreatorRoleMembersFromRecentlyActiveCreators() {
val baseAt = LocalDateTime.of(2026, 5, 31, 10, 0)
val formerCreator = saveMember("former-creator", MemberRole.USER)
val visibleCreator = saveMember("visible-role-creator", MemberRole.CREATOR)
saveLiveRoom(formerCreator, baseAt.plusMinutes(2), channelName = "former-creator-channel")
val community = saveCommunity(visibleCreator, isCommentAvailable = true)
updateCreatedAt("CreatorCommunity", community.id!!, baseAt.plusMinutes(1))
flushAndClear()
val creators = repository.findRecentlyActiveCreators(limit = 10)
assertEquals(listOf(visibleCreator.nickname), creators.map { it.creatorNickname })
assertEquals(CreatorActivityType.COMMUNITY, creators.single().activityType)
}
@Test @Test
@DisplayName("AI 캐릭터 스냅샷은 전날 AI 발화 수, 중복 없는 활성 사용자 수, 신규 팔로우 증가량을 집계한다") @DisplayName("AI 캐릭터 스냅샷은 전날 AI 발화 수, 중복 없는 활성 사용자 수, 신규 팔로우 증가량을 집계한다")
fun shouldFindAiCharacterSnapshotsWithFollowIncrease() { fun shouldFindAiCharacterSnapshotsWithFollowIncrease() {
@@ -2641,6 +2688,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
type: AudioContentBannerType, type: AudioContentBannerType,
orders: Int, orders: Int,
isActive: Boolean, isActive: Boolean,
isAdult: Boolean = false,
creator: Member? = null, creator: Member? = null,
event: Event? = null, event: Event? = null,
series: Series? = null, series: Series? = null,
@@ -2651,7 +2699,7 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
thumbnailImage = thumbnailImage, thumbnailImage = thumbnailImage,
type = type, type = type,
lang = Lang.KO, lang = Lang.KO,
isAdult = false, isAdult = isAdult,
isActive = isActive, isActive = isActive,
orders = orders orders = orders
) )

View File

@@ -86,12 +86,13 @@ class HomeRecommendationQueryServiceTest {
} }
@Test @Test
@DisplayName("홈 배너 추천은 기본 20개를 활성 배너 조회 포트에 위임한다") @DisplayName("홈 배너 추천은 기본 20개와 성인 노출 여부를 조회 포트에 위임한다")
fun shouldFindHomeBannersWithDefaultLimit() { fun shouldFindHomeBannersWithDefaultLimit() {
val banners = service.findHomeBanners(memberId = 100L) val banners = service.findHomeBanners(memberId = 100L, includeAdultBanners = true)
assertEquals(20, port.bannerLimit) assertEquals(20, port.bannerLimit)
assertEquals(100L, port.bannerMemberId) assertEquals(100L, port.bannerMemberId)
assertEquals(true, port.bannerIncludeAdultBanners)
assertEquals(port.banners, banners) assertEquals(port.banners, banners)
} }
@@ -964,6 +965,7 @@ class HomeRecommendationQueryServiceTest {
var liveIncludeAdultLives: Boolean? = null var liveIncludeAdultLives: Boolean? = null
var bannerLimit: Int? = null var bannerLimit: Int? = null
var bannerMemberId: Long? = null var bannerMemberId: Long? = null
var bannerIncludeAdultBanners: Boolean? = null
var activeCreatorLimit: Int? = null var activeCreatorLimit: Int? = null
var activeCreatorMemberId: Long? = null var activeCreatorMemberId: Long? = null
var activeCreatorIncludeAdultActivities: Boolean? = null var activeCreatorIncludeAdultActivities: Boolean? = null
@@ -1063,9 +1065,14 @@ class HomeRecommendationQueryServiceTest {
return liveRecommendations return liveRecommendations
} }
override fun findHomeBanners(limit: Int, memberId: Long?): List<HomeBannerRecommendationRecord> { override fun findHomeBanners(
limit: Int,
memberId: Long?,
includeAdultBanners: Boolean
): List<HomeBannerRecommendationRecord> {
bannerLimit = limit bannerLimit = limit
bannerMemberId = memberId bannerMemberId = memberId
bannerIncludeAdultBanners = includeAdultBanners
return banners return banners
} }

View File

@@ -14,8 +14,8 @@ import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executor import java.util.concurrent.Executor
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Future import java.util.concurrent.Future
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class RecommendationSnapshotFallbackServiceTest { class RecommendationSnapshotFallbackServiceTest {
@Test @Test
@@ -265,10 +265,18 @@ class RecommendationSnapshotFallbackServiceTest {
homeWaitMillis = 50 homeWaitMillis = 50
) )
val first = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)) val first = try {
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS)) val result = service.refreshCheerCreatorIfMissing(
allowRefreshComplete.countDown() offset = 0,
executor.shutdown() limit = 16,
nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
)
assertEquals(true, refreshStarted.await(1, TimeUnit.SECONDS))
result
} finally {
allowRefreshComplete.countDown()
executor.shutdown()
}
assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS)) assertEquals(true, executor.awaitTermination(1, TimeUnit.SECONDS))
val second = service.refreshCheerCreatorIfMissing( val second = service.refreshCheerCreatorIfMissing(
offset = 0, offset = 0,
@@ -283,46 +291,43 @@ class RecommendationSnapshotFallbackServiceTest {
@Test @Test
@DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다") @DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다")
fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() { fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() {
val existsSnapshotEntered = CountDownLatch(2) val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val existsSnapshotReturned = CountDownLatch(2) val refreshStarted = CountDownLatch(1)
val snapshotPort = FakeRecommendationFallbackSnapshotPort( val allowRefreshComplete = CountDownLatch(1)
existsSnapshotEntered = existsSnapshotEntered, val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
existsSnapshotReturned = existsSnapshotReturned
)
val refreshService = FastCheerRefreshService(snapshotPort)
val redissonClient = Mockito.mock(RedissonClient::class.java) val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java) val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock) Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true) Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true) Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val workerExecutor = CapturingExecutor() val workerTaskCount = AtomicInteger()
val workerPool = Executors.newFixedThreadPool(2)
val workerExecutor = Executor { command ->
workerTaskCount.incrementAndGet()
workerPool.execute(command)
}
val requestExecutor = Executors.newFixedThreadPool(2) val requestExecutor = Executors.newFixedThreadPool(2)
val service = RecommendationSnapshotFallbackService( val service = RecommendationSnapshotFallbackService(
snapshotPort, snapshotPort,
refreshService, refreshService,
redissonClient, redissonClient,
workerExecutor, workerExecutor,
homeWaitMillis = 1_000 homeWaitMillis = 50
) )
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0) val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
try { try {
val first = requestExecutor.submitCheerRefresh(service, nowUtc) val first = requestExecutor.submitCheerRefresh(service, nowUtc)
val second = requestExecutor.submitCheerRefresh(service, nowUtc) val second = requestExecutor.submitCheerRefresh(service, nowUtc)
assertEquals(true, existsSnapshotEntered.await(1, TimeUnit.SECONDS)) assertEquals(true, refreshStarted.await(5, TimeUnit.SECONDS))
assertEquals(true, existsSnapshotReturned.await(1, TimeUnit.SECONDS)) assertEquals(emptyList<RecommendationSnapshotRecord>(), first.get(5, TimeUnit.SECONDS))
assertEquals(true, workerExecutor.taskSubmitted.await(1, TimeUnit.SECONDS)) assertEquals(emptyList<RecommendationSnapshotRecord>(), second.get(5, TimeUnit.SECONDS))
assertEquals(false, first.isDone) assertEquals(1, workerTaskCount.get())
assertEquals(false, second.isDone)
assertEquals(1, workerExecutor.taskCount)
workerExecutor.runNext()
assertEquals(listOf(99L), first.get(1, TimeUnit.SECONDS).map { it.targetId })
assertEquals(listOf(99L), second.get(1, TimeUnit.SECONDS).map { it.targetId })
assertEquals(1, refreshService.cheerRefreshCount) assertEquals(1, refreshService.cheerRefreshCount)
} finally { } finally {
allowRefreshComplete.countDown()
requestExecutor.shutdownNow() requestExecutor.shutdownNow()
workerPool.shutdownNow()
} }
} }
@@ -408,23 +413,6 @@ class RecommendationSnapshotFallbackServiceTest {
} }
} }
private class CapturingExecutor : Executor {
val taskSubmitted = CountDownLatch(1)
private val tasks = LinkedBlockingQueue<Runnable>()
val taskCount: Int
get() = tasks.size
override fun execute(command: Runnable) {
tasks.add(command)
taskSubmitted.countDown()
}
fun runNext() {
tasks.poll(1, TimeUnit.SECONDS)!!.run()
}
}
private class BlockingCheerRefreshService( private class BlockingCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort, private val snapshotPort: RecommendationSnapshotPort,
private val refreshStarted: CountDownLatch, private val refreshStarted: CountDownLatch,
@@ -438,26 +426,7 @@ private class BlockingCheerRefreshService(
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
cheerRefreshCount += 1 cheerRefreshCount += 1
refreshStarted.countDown() refreshStarted.countDown()
allowRefreshComplete.await(1, TimeUnit.SECONDS) allowRefreshComplete.await()
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59),
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 99L))
)
return 1
}
}
private class FastCheerRefreshService(
private val snapshotPort: RecommendationSnapshotPort
) : RecommendationSnapshotRefreshService(
snapshotPort,
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
) {
var cheerRefreshCount: Int = 0
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
cheerRefreshCount += 1
snapshotPort.replaceSnapshots( snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR, RecommendedSectionType.CHEER_CREATOR,
LocalDateTime.of(2026, 7, 9, 14, 59, 59), LocalDateTime.of(2026, 7, 9, 14, 59, 59),
@@ -496,10 +465,7 @@ private class BlockingAiAndFastCheerRefreshService(
} }
} }
private class FakeRecommendationFallbackSnapshotPort( private class FakeRecommendationFallbackSnapshotPort : RecommendationSnapshotPort {
private val existsSnapshotEntered: CountDownLatch? = null,
private val existsSnapshotReturned: CountDownLatch? = null
) : RecommendationSnapshotPort {
private val snapshots = mutableListOf<RecommendationSnapshotRecord>() private val snapshots = mutableListOf<RecommendationSnapshotRecord>()
override fun findLatestSnapshots( override fun findLatestSnapshots(
@@ -531,12 +497,7 @@ private class FakeRecommendationFallbackSnapshotPort(
} }
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean { override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
existsSnapshotEntered?.countDown() return snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
existsSnapshotEntered?.await(1, TimeUnit.SECONDS)
val exists = snapshots.any { it.sectionType == sectionType && it.snapshotAt == snapshotAt }
existsSnapshotReturned?.countDown()
existsSnapshotReturned?.await(1, TimeUnit.SECONDS)
return exists
} }
override fun replaceSnapshots( override fun replaceSnapshots(