Compare commits

...

13 Commits

Author SHA1 Message Date
b18e40d4fc docs(creator-channel): 홈 커뮤니티 검증 완료를 기록한다 2026-08-10 14:15:18 +09:00
d99104d945 feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다 2026-08-10 12:09:58 +09:00
af347a71a2 docs(creator-channel): 홈 커뮤니티 응답 확장 PRD와 구현 계획 문서를 추가한다 2026-08-10 11:51:39 +09:00
631b63f5f1 docs(recommendation): 추천 탭 배너 정책과 검증 기록을 추가한다 2026-08-05 21:17:22 +09:00
0193b77700 fix(recommendation): 추천 탭 배너 조회 조건을 보정한다 2026-08-05 21:16:55 +09:00
8538880d81 fix(content): 크리에이터 관리자 시리즈 상세 조회 예외를 수정한다 2026-08-05 19:50:15 +09:00
579a48c2f1 fix(calculate): 관리자 정산 크리에이터 식별자를 보정한다 2026-08-04 21:22:35 +09:00
20f73a89f0 test(recommendation): 응원 크리에이터 동시성 테스트를 안정화한다 2026-08-03 20:53:28 +09:00
033044b4de fix(content): 무료 콘텐츠 포인트 사용 조건을 보정한다 2026-07-31 20:00:58 +09:00
eb0ff7537e fix(recommendation): 전 크리에이터 최근 활동 노출을 제외한다 2026-07-31 14:50:54 +09:00
7b9213a9ea docs(recommendation): 응원 크리에이터 검토 기록을 갱신한다 2026-07-31 14:23:34 +09:00
ed54483cab fix(recommendation): 응원 크리에이터 후속 보완을 반영한다 2026-07-31 14:22:59 +09:00
5123494e0e fix(content): 삭제 콘텐츠의 상단 고정을 비활성화한다 2026-07-31 03:46:08 +09:00
67 changed files with 4420 additions and 99 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

@@ -1,11 +1,23 @@
# 메인 홈 추천 응원 크리에이터 스냅샷 수정 Plan/Task # 메인 홈 추천 응원 크리에이터 스냅샷 수정 Plan/Task
## 후속 변경 상태
| 항목 | 내용 |
|---|---|
| 상태 | Jenkins 회귀 수정 완료 |
| 확정일 | 2026-08-03 |
| 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 |
| 현재 Phase | Phase 1~7 및 `P5-R2` 완료 |
| 현재 활성 Goal | 없음 |
| 다음 Goal | 없음 |
## 시나리오 계약 ## 시나리오 계약
- Happy path: 인기 커뮤니티와 동일한 최근 7일 UTC half-open 범위로 `CHEER_CREATOR` 점수를 계산하고, 점수순 상위 16개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`. - Happy path: 인기 커뮤니티와 동일한 최근 7일 UTC half-open 범위로 `CHEER_CREATOR` 점수를 계산하고, 점수순 상위 16개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`.
- Score: 응원 점수는 `((donationAmount * 0.45) + (fanTalkCount * 0.30) + (donationCount * 0.10)) * newBoost`다. 후원 금액은 `CHANNEL_DONATION``DONATION``use_can_calculate.can`을 그대로 사용하고, 후원 수는 `UseCanCalculate.useCan` 기준으로 중복 제거한다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`. - Score: 응원 점수는 `((donationAmount * 0.45) + (fanTalkCount * 0.30) + (donationCount * 0.10)) * newBoost`다. 후원 금액은 `CHANNEL_DONATION``DONATION``use_can_calculate.can`을 그대로 사용하고, 후원 수는 `UseCanCalculate.useCan` 기준으로 중복 제거한다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
- Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`. - Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
- Fallback: 최신 `CHEER_CREATOR` 스냅샷이 없으면 lock, double-check, 동일 refresh 로직 재사용, refresh 후 재조회 순서로 fallback을 실행한다. lock 대기는 최대 300ms, 홈 API refresh 완료 대기는 최대 1,500ms다. Real surface: fallback service test, `HomeRecommendationQueryServiceTest`. - Fallback: 최신 `CHEER_CREATOR` 스냅샷이 없으면 lock, double-check, 동일 refresh 로직 재사용, refresh 후 재조회 순서로 fallback을 실행한다. lock 대기는 최대 300ms, 홈 API refresh 완료 대기는 최대 1,500ms다. Real surface: fallback service test, `HomeRecommendationQueryServiceTest`.
- Empty marker: `CHEER_CREATOR` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`. - Empty marker: `CHEER_CREATOR` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`.
- Personalized filter: 인증 회원의 `cheerCreators`에서 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외한다. 비활성 팔로우 이력과 비회원 조회는 기존 동작을 유지한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`.
- Adjacent regression: 메인 홈 추천 API URL과 `CHEER_CREATOR` 응답 필드는 변경하지 않는다. AI 캐릭터, 인기 커뮤니티, 최근 데뷔 등 다른 섹션 산식과 공개 스키마는 이번 변경으로 바꾸지 않는다. Real surface: 기존 focused tests, `HomeRecommendationControllerTest`. - Adjacent regression: 메인 홈 추천 API URL과 `CHEER_CREATOR` 응답 필드는 변경하지 않는다. AI 캐릭터, 인기 커뮤니티, 최근 데뷔 등 다른 섹션 산식과 공개 스키마는 이번 변경으로 바꾸지 않는다. Real surface: 기존 focused tests, `HomeRecommendationControllerTest`.
## 범위와 전제 ## 범위와 전제
@@ -16,12 +28,15 @@
- 집계 기간은 인기 커뮤니티와 동일하게 KST 전날을 포함한 최근 7일이며, DB 조회에는 UTC half-open window를 사용한다. - 집계 기간은 인기 커뮤니티와 동일하게 KST 전날을 포함한 최근 7일이며, DB 조회에는 UTC half-open window를 사용한다.
- fallback orchestration은 AI 캐릭터 전용 구현을 그대로 복사하지 않고, 섹션별 lock key와 refresh action을 받을 수 있는 최소 공통 runner를 우선 적용한다. - fallback orchestration은 AI 캐릭터 전용 구현을 그대로 복사하지 않고, 섹션별 lock key와 refresh action을 받을 수 있는 최소 공통 runner를 우선 적용한다.
- 다른 스냅샷 섹션으로 empty marker를 확장하는 작업은 이번 구현 범위에서 제외한다. 단, `CHEER_CREATOR`에 적용할 때 이후 공통화가 가능하도록 조건문/상수명을 명확히 둔다. - 다른 스냅샷 섹션으로 empty marker를 확장하는 작업은 이번 구현 범위에서 제외한다. 단, `CHEER_CREATOR`에 적용할 때 이후 공통화가 가능하도록 조건문/상수명을 명확히 둔다.
- 본인·팔로우 제외는 스냅샷 생성이 아닌 `findCheerCreatorRecommendationDetails(...)` 상세 조회 시점에서 기존 `memberId`로 적용한다.
- 기존 16명 스냅샷 후보 안에서만 필터링하며, 필터 결과가 8명 미만이어도 하위 후보 조회나 스냅샷 저장 수 확대를 하지 않는다.
## 기존 CHEER_CREATOR 로직 유지/변경 경계 ## 기존 CHEER_CREATOR 로직 유지/변경 경계
- 유지: `RecommendedSectionType.CHEER_CREATOR` enum 값과 code는 변경하지 않는다. - 유지: `RecommendedSectionType.CHEER_CREATOR` enum 값과 code는 변경하지 않는다.
- 유지: 홈 응원 크리에이터 응답 필드인 `creatorId`, `creatorNickname`, `creatorProfileImage`는 변경하지 않는다. - 유지: 홈 응원 크리에이터 응답 필드인 `creatorId`, `creatorNickname`, `creatorProfileImage`는 변경하지 않는다.
- 유지: 홈 첫 화면 응답은 최대 8명, 스냅샷 후보 조회는 최대 16개를 사용한다. - 유지: 홈 첫 화면 응답은 최대 8명, 스냅샷 후보 조회는 최대 16개를 사용한다.
- 유지: 상세 조회 시점의 활성 크리에이터 필터와 차단 필터는 유지한다. - 유지: 상세 조회 시점의 활성 크리에이터 필터와 차단 필터는 유지한다.
- 유지: 비회원 조회와 비활성 팔로우 이력이 있는 크리에이터 조회는 기존 동작을 유지한다.
- 유지: 후원 금액은 `use_can_calculate.can` 값을 그대로 합산한다. - 유지: 후원 금액은 `use_can_calculate.can` 값을 그대로 합산한다.
- 변경: 점수 가중치는 후원 금액 45%, 팬Talk 수 30%, 후원 수 10%로 바꾼다. - 변경: 점수 가중치는 후원 금액 45%, 팬Talk 수 30%, 후원 수 10%로 바꾼다.
- 변경: 후원 수는 `UseCanCalculate.useCan` 기준 distinct count로 계산한다. - 변경: 후원 수는 `UseCanCalculate.useCan` 기준 distinct count로 계산한다.
@@ -30,6 +45,7 @@
- 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `CHEER_CREATOR` 전용 `1.15/1.10/1.05`를 사용한다. - 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `CHEER_CREATOR` 전용 `1.15/1.10/1.05`를 사용한다.
- 추가: `CHEER_CREATOR` 최신 스냅샷이 없을 때 fallback refresh를 실행한다. - 추가: `CHEER_CREATOR` 최신 스냅샷이 없을 때 fallback refresh를 실행한다.
- 추가: `CHEER_CREATOR` refresh 결과 0건이면 empty snapshot marker를 저장한다. - 추가: `CHEER_CREATOR` refresh 결과 0건이면 empty snapshot marker를 저장한다.
- 추가: 인증 회원 본인과 활성 팔로우 중인 크리에이터를 `cheerCreators` 상세 조회에서 제외한다.
## 실행 명령 ## 실행 명령
- 문서 명령 확인: `./gradlew tasks --all` - 문서 명령 확인: `./gradlew tasks --all`
@@ -55,6 +71,32 @@
- REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다. - REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다.
- 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다. - 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다.
#### Task R1.1 PRD 관련 문서 경로 정합성 복구
**Goal 실행 `P1-R1`:** `REV-P1-001`에서 확인한 존재하지 않는 샘플 PRD 경로를 실제 가이드 경로로 정정한다.
- **시작 조건:** `reviews/phase-1-review.md``REV-P1-001` 확정.
- **완료 증거:** 문서 경로 정정, 대상 파일 존재 확인, `git diff --check` 통과, 전체 검증 기록 누적.
- **범위 밖:** PRD 요구사항·결정 내용 변경, 코드·테스트 변경, 다른 문서의 링크 일괄 정리.
- **TDD 예외 사유:** 문서 링크 정정만 수행하며 런타임 동작을 변경하지 않는다.
- [x] `prd.md``docs/prd/sample-prd.md`를 실제 파일인 `docs/sample/sample-prd.md`로 정정한다.
- [x] `test -f docs/sample/sample-prd.md``git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`로 링크 대상과 문서 diff를 확인한다.
- [x] 무엇을/왜/어떻게 검증했는지 전체 검증 기록에 누적한다.
#### Task R1.2 후속 변경 상태 정합성 복구
**Goal 실행 `P1-R2`:** `REV-P1-002`에서 확인한 상단 상태표의 요구사항 범위·Phase·다음 Goal을 현재 계획과 검증 상태에 맞게 정리한다.
- **시작 조건:** `reviews/phase-1-review.md``REV-P1-002` 확정.
- **완료 증거:** 상태표가 PRD 전체, Phase 1~7, 미완료 후속 Goal을 정확히 가리킴, `git diff --check`와 문서 명령 유효성 확인, 전체 검증 기록 누적.
- **범위 밖:** PRD 요구사항 변경, 코드·테스트 변경, 이전 검증 기록 삭제.
- **TDD 예외 사유:** 현재 작업 상태 문구만 복구하며 런타임 동작을 변경하지 않는다.
- [x] 상태표의 요구사항 기준을 PRD 전체로, Phase 상태를 Phase 1~7 현재 판정으로, 다음 Goal을 실제 미완료 Goal로 맞춘다.
- [x] `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md``./gradlew tasks --all`로 문서 diff와 명령 유효성을 확인한다.
- [x] 완료 후 다음 Goal을 `P3-R2`로 갱신하고 전체 검증 기록에 무엇을/왜/어떻게 검증했는지 누적한다.
--- ---
### Phase 2: 산식과 부스트 정책 ### Phase 2: 산식과 부스트 정책
@@ -144,6 +186,32 @@
- REFACTOR: AI 캐릭터와 인기 커뮤니티 스냅샷 query가 영향받지 않았는지 focused test로 확인한다. - REFACTOR: AI 캐릭터와 인기 커뮤니티 스냅샷 query가 영향받지 않았는지 focused test로 확인한다.
- 기대 결과: `CHEER_CREATOR` 스냅샷 저장 후보만 정확히 변경된다. - 기대 결과: `CHEER_CREATOR` 스냅샷 저장 후보만 정확히 변경된다.
#### Task R3.1 `CHEER_CREATOR` 후보 경계·정렬 완료 근거 보강
**Goal 실행 `P3-R1`:** `REV-P3-001`에서 누락이 확인된 Task 3.5의 후보 제외·상위 16개·저장 정렬 회귀 증거를 테스트로 고정한다.
- **시작 조건:** `reviews/phase-3-review.md``REV-P3-001` 확정.
- **완료 증거:** 실패 재현 테스트 작성·확인, 필요한 경우 최소 구현, repository/persistence focused test 통과, 전체 검증 기록 누적.
- **범위 밖:** 점수 가중치·집계 기간·스냅샷 저장 수 변경, 랜덤 정책 변경, 다른 추천 섹션 쿼리 수정.
- [x] **RED:** 실제 데뷔 이력이 있지만 후원·팬Talk가 모두 0인 후보, 미래 데뷔 이력만 있는 후보, 비활성 크리에이터가 제외되는 테스트를 추가한다.
- [x] **RED:** 17개 이상의 점수 후보에서 점수 내림차순 상위 16개만 반환되는 repository 테스트와, 저장된 동점 스냅샷이 `randomTieBreaker` 오름차순으로 조회되는 `CHEER_CREATOR` persistence 테스트를 추가한다.
- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 해당 조건·정렬 경로를 최소 수정하고, 테스트 누락뿐이면 프로덕션 코드는 변경하지 않는다.
- [x] **REFACTOR/GATE:** `DefaultHomeRecommendationQueryRepositoryTest``RecommendationSnapshotPersistenceAdapterTest` focused test 및 `git diff --check`를 실행해 결과를 누적한다.
#### Task R3.2 종료된 라이브 데뷔 이력 복구
**Goal 실행 `P3-R2`:** `REV-P3-002`에서 확인한 `CHEER_CREATOR` 데뷔일 계산이 채널명이 있는 종료 라이브를 제외하는 문제를 수정하고 회귀를 방지한다.
- **시작 조건:** `reviews/phase-3-review.md``REV-P3-002` 확정과 `P1-R2` 완료.
- **완료 증거:** 종료 라이브 재현 테스트의 의도한 실패, `CHEER_CREATOR` 데뷔 CTE 최소 수정, repository focused test·`ktlintCheck`·`git diff --check` 통과, 전체 검증 기록 누적.
- **범위 밖:** 점수 가중치·집계 window·저장 수·정렬 변경, 다른 추천 섹션의 데뷔 정책 변경, 공개 API 변경.
- [x] **RED:** 활성 콘텐츠는 없고 `channel_name`이 있는 종료 라이브와 최근 7일 응원 활동만 있는 활성 크리에이터가 `CHEER_CREATOR` 후보에 포함되는 실패 테스트를 추가한다. 빈 `channel_name`의 종료 라이브는 데뷔 이력으로 인정하지 않는 경계를 함께 유지한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 현재 `lr.is_active = true` 조건 때문에 종료 라이브 후보가 누락되는 assertion 실패를 확인한다.
- [x] **GREEN:** `findCheerCreatorSnapshots(...)``creator_debut` 라이브 branch에서 `lr.is_active = true`만 제거하고, `channel_name is not null`, `channel_name <> ''`, `begin_date_time <= :snapshotAt` 조건은 유지한다.
- [x] **REFACTOR/GATE:** repository focused test, `./gradlew ktlintCheck`, `git diff --check`를 실행하고 점수·window·후보 상한·다른 섹션 쿼리가 변경되지 않았음을 기록한다.
--- ---
### Phase 4: refresh 저장과 empty marker ### Phase 4: refresh 저장과 empty marker
@@ -178,6 +246,19 @@
- REFACTOR: 전체 일괄 refresh 성공 로그는 유지하되, 섹션별 로그와 중복되어도 검색 가능한 event key를 사용한다. - REFACTOR: 전체 일괄 refresh 성공 로그는 유지하되, 섹션별 로그와 중복되어도 검색 가능한 event key를 사용한다.
- 기대 결과: 운영에서 `CHEER_CREATOR` refresh 결과 0건과 실패를 구분할 수 있다. - 기대 결과: 운영에서 `CHEER_CREATOR` refresh 결과 0건과 실패를 구분할 수 있다.
#### Task R4.1 `CHEER_CREATOR` refresh 실패 로그 보강
**Goal 실행 `P4-R1`:** `REV-P4-001`에서 확인한 섹션별 refresh 실패 로그 누락을 보완해 성공·빈 결과·실패를 운영 로그로 구분한다.
- **시작 조건:** `reviews/phase-4-review.md``REV-P4-001` 확정.
- **완료 증거:** 실패 로그 RED 테스트, 최소 로그 구현, refresh focused test 통과, 전체 검증 기록 누적.
- **범위 밖:** fallback 실패 로그 형식 변경, 로그 수집 인프라·메트릭 시스템 추가, refresh 트랜잭션 정책 변경.
- [x] **RED:** `refreshCheerCreatorSnapshots(nowUtc)`의 query 또는 저장 실패 시 `event=cheer_creator_recommendation_snapshot_refresh_failure`, window·`snapshotAt`, 오류 정보가 기록되고 예외는 기존처럼 전파되는 테스트를 추가한다.
- [x] **GREEN:** 성공 경로를 변경하지 않는 최소 `runCatching` 또는 `try/catch` 로그를 추가한 뒤 원래 예외를 다시 던진다.
- [x] **REFACTOR/GATE:** `RecommendationSnapshotRefreshServiceTest``git diff --check`를 실행하고 중복 로그가 의도된 event key로 구분되는지 확인한다.
- [x] 무엇을/왜/어떻게 검증했는지 전체 검증 기록에 누적한다.
--- ---
### Phase 5: fallback refresh ### Phase 5: fallback refresh
@@ -224,6 +305,33 @@
- REFACTOR: 스냅샷 후보 16개 조회, 상세 조회 후 최대 8개 반환, 차단 필터 전달은 기존 동작을 유지한다. - REFACTOR: 스냅샷 후보 16개 조회, 상세 조회 후 최대 8개 반환, 차단 필터 전달은 기존 동작을 유지한다.
- 기대 결과: 홈 통합 조회의 최근 응원이 많은 크리에이터 섹션이 스냅샷 없음 상황을 자체 복구할 수 있다. - 기대 결과: 홈 통합 조회의 최근 응원이 많은 크리에이터 섹션이 스냅샷 없음 상황을 자체 복구할 수 있다.
#### Task R5.1 fallback single-flight·double-check 회귀 증거 보강
**Goal 실행 `P5-R1`:** `REV-P5-001`에서 누락이 확인된 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 결정적 테스트로 고정한다.
- **시작 조건:** `reviews/phase-5-review.md``REV-P5-001` 확정.
- **완료 증거:** 동시성·double-check RED 테스트 작성·확인, 필요한 경우 최소 구현, fallback focused test 통과, 전체 검증 기록 누적.
- **범위 밖:** worker 수 조정, timeout·lock 대기 값 변경, 다른 섹션 fallback 정책 확대.
- [x] **RED:** 동일 JVM에서 동시에 들어온 둘 이상의 `CHEER_CREATOR` fallback 요청이 하나의 refresh future만 공유하고 `refreshCheerCreatorSnapshots(...)`를 1회만 호출하는 테스트를 latch 기반으로 추가한다.
- [x] **RED:** 최초 조회 뒤 lock 획득 전 다른 요청/스케줄러가 대상일 marker 또는 실제 row를 저장하면 lock 안의 double-check가 refresh를 생략하고 최신 상태를 다시 조회하는 테스트를 추가한다.
- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다.
- [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 회귀와 최종 검증
@@ -262,18 +370,106 @@
- REFACTOR: 문서와 코드의 산식/timeout/window 값이 다르면 구현 또는 문서를 수정한 뒤 재검증한다. - REFACTOR: 문서와 코드의 산식/timeout/window 값이 다르면 구현 또는 문서를 수정한 뒤 재검증한다.
- 기대 결과: 전체 테스트, 포맷, 문서 명령 유효성, diff 공백 검사가 모두 통과한다. - 기대 결과: 전체 테스트, 포맷, 문서 명령 유효성, diff 공백 검사가 모두 통과한다.
#### Task R6.1 `cheerCreators` item 공개 스키마 회귀 테스트 보강
**Goal 실행 `P6-R1`:** `REV-P6-001`에서 확인한 빈 배열 중심 스키마 검증을 실제 item의 정확한 필드 계약 검증으로 보강한다.
- **시작 조건:** `reviews/phase-6-review.md``REV-P6-001` 확정.
- **완료 증거:** 비어 있지 않은 item 직렬화/컨트롤러 테스트, 정확한 필드 집합 검증, API focused test 통과, 전체 검증 기록 누적.
- **범위 밖:** DTO 필드 추가·이름 변경, API URL 변경, 다른 홈 섹션 스키마 정리.
- [x] **RED:** `cheerCreators`에 item을 넣고 `creatorId`, `creatorNickname`, `creatorProfileImage` 값과 필드 수 3을 검증하는 직렬화 테스트를 추가한다.
- [x] **RED:** 홈 API 테스트에서 비어 있지 않은 `cheerCreators` item의 동일 필드 계약을 검증하고 예상 밖 필드가 없음을 확인한다.
- [x] **GREEN:** 기존 DTO가 테스트를 만족하면 프로덕션 코드는 변경하지 않고, 계약 불일치가 드러날 때만 기존 공개 스키마로 최소 복구한다.
- [x] **REFACTOR/GATE:** `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`, `git diff --check`를 실행해 결과를 누적한다.
---
### Phase 7: 본인·팔로우 크리에이터 노출 제외
**Phase 결과:** 인증 회원의 `cheerCreators`에서 조회자 본인과 활성 팔로우 크리에이터가 제외되고, 비활성 팔로우 이력과 비회원 조회는 기존 동작을 유지한다.
**선행조건:** Phase 1~6 완료와 PRD Feature D의 2026-07-31 후속 요구사항 확정.
**Phase 완료 조건:** `P7-T1``P7-GATE` 완료, focused test·영향 범위 회귀·문서 검증 결과 누적.
#### Task 7.1 `cheerCreators` 상세 조회 개인화 필터 보강
**Goal 실행 `P7-T1`:** 기존 `memberId` 기반 상세 조회에서 본인과 활성 팔로우 크리에이터만 제외하는 최소 조회 조건을 추가한다.
- **시작 조건:** PRD Feature D의 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 16명 후보 안에서만 필터링한다는 결정 확정.
- **완료 증거:** 아래 체크박스 전체 완료, repository focused test 통과, 직접 영향 회귀 통과, 실행 결과를 이 문서의 전체 검증 기록에 누적.
- **범위 밖:** 스냅샷 산식·정렬·저장 수, 16명 밖 하위 후보 보충, 공개 DTO/API, 다른 추천 섹션의 팔로우 필터.
**Files:**
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
**Interfaces:**
- Consumes: `HomeRecommendationQueryPort.findCheerCreatorRecommendationDetails(creatorIds: List<Long>, memberId: Long?): List<HomeCheerCreatorRecommendationRecord>`
- Produces: 같은 시그니처와 반환 타입을 유지하면서, `memberId != null`일 때만 본인과 활성 팔로우를 제외하는 상세 조회 계약.
- [x] **RED:** `DefaultHomeRecommendationQueryRepositoryTest`에 조회자 크리에이터, 활성 팔로우 크리에이터, 비활성 팔로우 이력만 있는 크리에이터, 관계가 없는 크리에이터를 준비한다. `memberId = viewer.id`로 조회했을 때 비활성 팔로우 이력 크리에이터와 관계가 없는 크리에이터만 반환하는 `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails` 테스트를 작성한다. `memberId = null`은 모든 활성 후보를 유지하는 `shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters` 테스트도 작성한다.
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 현재 조회가 조회자 본인과 활성 팔로우 크리에이터를 함께 반환하여 첫 번째 테스트가 의도한 assertion 실패를 내는지 확인한다.
- [x] **GREEN:** `findCheerCreatorRecommendationDetails(...)``memberId`가 있을 때 `member.id != memberId`를 적용하고, 동일 회원과 후보 크리에이터 사이에 `CreatorFollowing.isActive == true`인 row가 존재하지 않는 조건을 추가한다. `memberId == null`이면 두 조건은 적용하지 않는다.
- [x] **GREEN 확인:** 같은 repository focused test를 재실행해 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 기존 양방향 차단 제외 테스트가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 이번 조건에 필요한 QueryDSL alias/helper만 남기고, `HomeRecommendationQueryServiceTest`로 기존 16명 후보 조회·필터 후 최대 8명 조립이 유지되는지 확인한다. `HomeRecommendationControllerTest``ktlintCheck`로 공개 스키마·포맷 회귀를 확인하고 실제 명령·결과를 전체 검증 기록에 남긴다.
### Phase 7 완료 조건
- [x] `P7-T1`의 RED·GREEN·REFACTOR 체크박스와 완료 증거가 모두 충족됐다.
- [x] 인증 회원 본인·활성 팔로우만 제외되고 비활성 팔로우·비회원·양방향 차단·활성 크리에이터 정책이 조합되는 것이 검증됐다.
- [x] 스냅샷 산식·저장 수·정렬, 응답 DTO, 다른 추천 섹션에 변경이 없다.
#### Phase 7 Gate
**Goal 실행 `P7-GATE`:** `cheerCreators` 개인화 필터와 직접 영향 회귀를 최종 판정한다.
- **시작 조건:** `P7-T1` 완료.
- **완료 증거:** 아래 명령 전부 통과, `git diff --check` 출력 없음, 전체 검증 기록 누적.
- **범위 밖:** 게이트 통과를 위한 테스트 삭제·완화와 관련 없는 리팩터링.
```bash
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
./gradlew ktlintCheck
git diff --check
```
**Expected:** 모든 명령이 exit code 0이고, 인증 회원 본인·활성 팔로우는 제외되며 비활성 팔로우·비회원 결과와 기존 API 스키마는 유지된다.
--- ---
## Coverage Check ## Coverage Check
- Feature A: Task 2.1, Task 3.2, Task 3.4에서 후원 금액 45%, 팬Talk 30%, 후원 수 10%, 후원 수 distinct 기준을 검증한다. - Feature A: Task 2.1, Task 3.2, Task 3.4에서 후원 금액 45%, 팬Talk 30%, 후원 수 10%, 후원 수 distinct 기준을 검증한다.
- Feature B: Task 3.1, Task 3.3에서 최근 7일 KST 범위를 UTC half-open 조회 범위로 변환하고 `windowEndExclusiveUtc` 경계를 검증한다. - Feature B: Task 3.1, Task 3.3에서 최근 7일 KST 범위를 UTC half-open 조회 범위로 변환하고 `windowEndExclusiveUtc` 경계를 검증한다.
- Feature C: Task 2.2, Task 3.4에서 데뷔일 기준 응원 전용 신규 부스트와 경계값을 검증한다. - Feature C: Task 2.2, Task 3.4에서 데뷔일 기준 응원 전용 신규 부스트와 경계값을 검증한다.
- Feature D: Task 3.5, Task 5.4, Task 6.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 기존 응답 스키마 유지를 검증한다. - Feature D: Task 3.5, Task 5.4, Task 6.1, Task 7.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 기존 응답 스키마 유지를 검증한다.
- Feature E: Task 5.1, Task 5.2, Task 5.3, Task 5.4에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다. - Feature E: Task 5.1, Task 5.2, Task 5.3, Task 5.4에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다.
- Feature F: Task 4.1, Task 4.2, Task 5.3에서 `CHEER_CREATOR` empty marker 저장, 조회 제외, 존재 여부 true, marker 기반 fallback 반복 방지를 검증한다. - Feature F: Task 4.1, Task 4.2, Task 5.3에서 `CHEER_CREATOR` empty marker 저장, 조회 제외, 존재 여부 true, marker 기반 fallback 반복 방지를 검증한다.
- Non-Goals: Task 4.1, Task 6.1, Task 6.3에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 신규 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: `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: Phase 1~7 2차 리뷰로 PRD·plan-task·현재 코드·테스트·관련 구현 이력을 정적 대조했다. `REV-P1-002`의 상단 상태표 불일치와 `REV-P3-002`의 종료 라이브 데뷔 이력 제외를 확정해 각각 `P1-R2`, `P3-R2` 신규 회귀 Task로 전환했고, Phase 2·4·5·6·7은 추가 확정 발견 사항이 없다. 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 2차 리뷰로 각각 누적했다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했고, `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 후 승인된 동일 명령으로 재실행해 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-31: `P1-R1`, `P3-R1`, `P4-R1`, `P5-R1`, `P6-R1` 후속 보완의 최종 focused 회귀로 `./gradlew cleanTest test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`를 실행해 `BUILD SUCCESSFUL`로 통과했다. 이어서 `./gradlew ktlintCheck``BUILD SUCCESSFUL`, `git diff --check`는 출력 없이 통과했다. 전체 `./gradlew test`는 테스트 assertion 실패가 아니라 `build/test-results/test/TEST-*.xml` 결과 파일 쓰기 실패로 중단되어 별도 환경/파일시스템 이슈 확인이 필요하다.
- 2026-07-31: `P1-R1``prd.md`의 샘플 PRD 링크를 실제 파일 `docs/sample/sample-prd.md`로 정정했다. `test -f docs/sample/sample-prd.md``git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`를 실행해 출력 없이 통과했다.
- 2026-07-31: `P3-R1``CHEER_CREATOR` 후보 제외·상위 16개·동점 저장 정렬 회귀 테스트를 보강했다. 리뷰 판정처럼 구현 결함은 드러나지 않아 프로덕션 코드는 변경하지 않았다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest``BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다.
- 2026-07-31: `P4-R1``refreshCheerCreatorSnapshots(nowUtc)` 실패 시 `event=cheer_creator_recommendation_snapshot_refresh_failure`와 window·`snapshotAt`·오류 정보를 남기고 원 예외를 전파하도록 보강했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest.shouldLogCheerCreatorRefreshFailureWithWindow``AssertionFailedError`로 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest``BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다.
- 2026-07-31: `P5-R1`로 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 sleep 없이 latch 기반 테스트로 보강했다. 리뷰 판정처럼 구현 결함은 드러나지 않아 프로덕션 코드는 변경하지 않았다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest``BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다.
- 2026-07-31: `P6-R1``cheerCreators` item 직렬화와 홈 API 응답의 `creatorId`, `creatorNickname`, `creatorProfileImage` 3개 필드 계약을 보강했다. 최초 controller focused test는 대상일 snapshot fixture 불일치와 테스트 환경 CDN host 기대값 불일치로 실패했고, fixture를 `RecommendationSnapshotWindowPolicy.previousKstDayUtcWindow(LocalDateTime.now(UTC)).snapshotAt` 및 실제 테스트 host 설정에 맞춘 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest``BUILD SUCCESSFUL`로 통과했다. 관련 `git diff --check`는 출력 없이 통과했다.
- 2026-07-31: Phase별 리뷰 문서와 회귀 Task 추가 후 `git diff --check`를 실행해 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패했고, 승인 후 동일 명령을 재실행해 `BUILD SUCCESSFUL`로 통과했다. compile/test task는 실행하지 않았다.
- 2026-07-31: Phase 1~7 구현 상태를 PRD·plan-task·현재 코드·테스트와 정적으로 대조했다. 사용자 지시에 따라 Gradle compile/test는 새로 실행하지 않았고 기존 검증 기록을 실행 증거로 참조했다. 확정 항목은 `reviews/phase-1-review.md`부터 `reviews/phase-7-review.md`까지 Phase별로 기록했으며, `REV-P1-001`, `REV-P3-001`, `REV-P4-001`, `REV-P5-001`, `REV-P6-001`을 각각 `P1-R1`, `P3-R1`, `P4-R1`, `P5-R1`, `P6-R1` 신규 회귀 Task로 전환했다. Phase 2와 Phase 7은 확정 발견 사항이 없다.
- 2026-07-31: Phase 7 `P7-T1` 구현으로 `findCheerCreatorRecommendationDetails(...)`가 인증 회원 조회 시 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외하도록 보강했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails` assertion 실패를 확인했고, GREEN 후 같은 명령은 `BUILD SUCCESSFUL`로 통과했다. 직접 영향 회귀 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest``./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했다.
- 2026-07-31: 후속 요구사항으로 인증 회원 본인과 활성 팔로우 크리에이터를 `cheerCreators` 상세 조회에서 제외하는 정책을 PRD Feature D와 Phase 7 `P7-T1`/`P7-GATE`에 반영했다. 비활성 팔로우·비회원 유지와 16명 후보 밖 보충 없음을 경계로 고정했다. `git diff --check`는 출력 없이 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 권한으로 실패했고, 승인 후 동일 명령을 재실행해 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-10: PRD 기반으로 `plan-task.md`를 생성했다. 구현 전 계획 문서 작성 작업이므로 코드 테스트는 아직 실행하지 않았고, 문서 형식/명령 유효성 검증을 진행한다. - 2026-07-10: PRD 기반으로 `plan-task.md`를 생성했다. 구현 전 계획 문서 작성 작업이므로 코드 테스트는 아직 실행하지 않았고, 문서 형식/명령 유효성 검증을 진행한다.
- 2026-07-10: 문서 검증으로 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`를 실행해 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다. - 2026-07-10: 문서 검증으로 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`를 실행해 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
- 2026-07-10: 구현 RED 확인으로 `RecommendationScorePolicyTest``CHEER_NEW_BOOST_*``calculateCheerCreatorNewBoost(...)` 미구현 컴파일 실패를 확인했고, `DefaultHomeRecommendationQueryRepositoryTest`는 half-open/distinct 집계 기대값 불일치 실패를 확인했다. `RecommendationSnapshotPersistenceAdapterTest``CHEER_CREATOR` empty marker 미지원 실패를 확인했다. - 2026-07-10: 구현 RED 확인으로 `RecommendationScorePolicyTest``CHEER_NEW_BOOST_*``calculateCheerCreatorNewBoost(...)` 미구현 컴파일 실패를 확인했고, `DefaultHomeRecommendationQueryRepositoryTest`는 half-open/distinct 집계 기대값 불일치 실패를 확인했다. `RecommendationSnapshotPersistenceAdapterTest``CHEER_CREATOR` empty marker 미지원 실패를 확인했다.

View File

@@ -1,7 +1,7 @@
# PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정 # PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정
## 1. Overview ## 1. Overview
메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 최근 7일 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다. 메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 최근 7일 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다. 인증 회원에게는 조회자 본인과 현재 활성 팔로우 중인 크리에이터를 `cheerCreators` 응답에서 제외한다.
--- ---
@@ -11,6 +11,7 @@
- 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다. - 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
- 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다. - 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다.
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다. - 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
- 현재 `CHEER_CREATOR` 상세 조회는 활성 크리에이터와 양방향 차단 조건만 적용하여, 인증 회원 본인이나 이미 팔로우 중인 크리에이터가 추천에 노출될 수 있다.
--- ---
@@ -23,21 +24,24 @@
- 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다. - 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다.
- fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고 `CHEER_CREATOR` 섹션 빈 배열로 처리한다. - fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고 `CHEER_CREATOR` 섹션 빈 배열로 처리한다.
- 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다. - 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다.
- 인증 회원의 `cheerCreators` 상세 조회에서 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외한다.
- 비활성 팔로우 이력과 비회원 조회는 기존 조회 정책을 유지한다.
--- ---
## 4. Non-Goals ## 4. Non-Goals
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다. - 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
- `CHEER_CREATOR` 이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다. - `CHEER_CREATOR` 이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다.
- 관리자 화면, 수동 추천 편집, A/B 테스트, 개인화 추천은 이번 범위에 포함하지 않는다. - 관리자 화면, 수동 추천 편집, A/B 테스트, 사용자별 응원 점수·스냅샷 순서 산정은 이번 범위에 포함하지 않는다.
- 후원, 팬Talk 생성/수정/삭제 자체의 도메인 동작은 변경하지 않는다. - 후원, 팬Talk 생성/수정/삭제 자체의 도메인 동작은 변경하지 않는다.
- 신규 추천 스냅샷 테이블을 만들지 않고, 기존 `recommendation_snapshot` 구조를 우선 재사용한다. - 신규 추천 스냅샷 테이블을 만들지 않고, 기존 `recommendation_snapshot` 구조를 우선 재사용한다.
- 팔로우/본인 필터링으로 8명이 채워지지 않을 때 스냅샷 저장 수나 조회 후보를 16명 이상으로 늘리는 작업은 범위에 포함하지 않는다.
--- ---
## 5. Target Users ## 5. Target Users
- 회원/비회원: 메인 홈 추천 탭에서 최근 7일 응원 반응이 많았던 크리에이터를 발견하는 사용자 - 회원/비회원: 메인 홈 추천 탭에서 최근 7일 응원 반응이 많았던 크리에이터를 발견하는 사용자
- 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 추천 순서만 변경된 결과를 받는 클라이언트 - 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 점수 순서와 인증 회원 조회 필터를 반영한 결과를 받는 클라이언트
- 운영자: 최근 7일 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자 - 운영자: 최근 7일 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
--- ---
@@ -46,6 +50,7 @@
- 사용자는 메인 홈 추천 탭에서 최근 7일 응원이 많았던 크리에이터를 우선 보고 싶다. - 사용자는 메인 홈 추천 탭에서 최근 7일 응원이 많았던 크리에이터를 우선 보고 싶다.
- 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다. - 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다.
- 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다. - 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
- 인증 회원은 자신과 이미 팔로우 중인 크리에이터를 제외한 새로운 응원 크리에이터를 보고 싶다.
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다. - 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
- 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다. - 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다.
@@ -118,10 +123,15 @@
- 크리에이터 닉네임 - 크리에이터 닉네임
- 크리에이터 프로필 이미지 - 크리에이터 프로필 이미지
- 조회 시점에도 기존 차단 필터와 활성 크리에이터 필터를 적용한다. - 조회 시점에도 기존 차단 필터와 활성 크리에이터 필터를 적용한다.
- 인증 회원이 크리에이터인 경우 `creatorId == memberId`인 조회자 본인을 제외한다.
- 인증 회원과 크리에이터 사이의 `CreatorFollowing.isActive == true`인 팔로우 관계가 있으면 해당 크리에이터를 제외한다.
- 과거 언팔로우로 `CreatorFollowing.isActive == false`인 이력만 있는 크리에이터는 제외하지 않는다.
- 비회원은 본인과 팔로우 관계를 판정할 `memberId`가 없으므로 해당 필터를 적용하지 않는다.
- 스냅샷 후보는 최대 16개까지 조회하고, 상세 조회/필터링 후 홈 첫 화면에는 최대 8명을 반환한다. - 스냅샷 후보는 최대 16개까지 조회하고, 상세 조회/필터링 후 홈 첫 화면에는 최대 8명을 반환한다.
#### Edge Cases #### Edge Cases
- 최신 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다. - 최신 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다.
- 본인과 활성 팔로우 크리에이터를 제외한 결과가 8명보다 적으면 16명 스냅샷 후보 범위 안에서 조회 가능한 수만 반환하고, 16명 밖의 하위 후보로 보충하지 않는다.
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다. - 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
- 스냅샷 정렬 순서와 응답 순서는 일치해야 한다. - 스냅샷 정렬 순서와 응답 순서는 일치해야 한다.
@@ -175,6 +185,7 @@
- 기존 `kr.co.vividnext.sodalive.v2.recommendation` 패키지 경계와 `v2.api.home`에서 `v2.recommendation`을 호출하는 의존 방향을 유지한다. - 기존 `kr.co.vividnext.sodalive.v2.recommendation` 패키지 경계와 `v2.api.home`에서 `v2.recommendation`을 호출하는 의존 방향을 유지한다.
- 기존 `RecommendationSnapshot`, `RecommendationSnapshotPort`, `HomeRecommendationQueryPort` 기반 저장/조회 구조를 재사용한다. - 기존 `RecommendationSnapshot`, `RecommendationSnapshotPort`, `HomeRecommendationQueryPort` 기반 저장/조회 구조를 재사용한다.
- 공개 API 응답 DTO는 필드 추가 없이 유지한다. - 공개 API 응답 DTO는 필드 추가 없이 유지한다.
- 본인과 활성 팔로우 제외는 기존 `memberId`를 사용하는 `findCheerCreatorRecommendationDetails(...)` 상세 조회 경로에서 적용하고, 스냅샷 생성 산식과 저장 데이터는 변경하지 않는다.
- 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다. - 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다.
- fallback refresh 기능은 AI 캐릭터 전용 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면 `CHEER_CREATOR`에 필요한 최소 추상화만 적용한다. - fallback refresh 기능은 AI 캐릭터 전용 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면 `CHEER_CREATOR`에 필요한 최소 추상화만 적용한다.
- `CHEER_CREATOR` 집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다. - `CHEER_CREATOR` 집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다.
@@ -208,6 +219,9 @@
- 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거한다. - 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거한다.
- 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다. - 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다.
- 빈 결과 marker 정책은 다른 스냅샷 섹션에도 확장하는 것이 맞지만, 이번 구현 범위에서는 `CHEER_CREATOR`에만 적용한다. - 빈 결과 marker 정책은 다른 스냅샷 섹션에도 확장하는 것이 맞지만, 이번 구현 범위에서는 `CHEER_CREATOR`에만 적용한다.
- 인증 회원 본인과 활성 팔로우 중인 크리에이터는 `cheerCreators`에서 제외하고, 비활성 팔로우 이력은 제외 근거로 사용하지 않는다.
- 필터링 후 8명 미만이어도 기존 16명 스냅샷 후보 범위를 넘어서 보충하지 않는다.
- 비회원은 기존 `CHEER_CREATOR` 조회 결과를 유지한다.
--- ---
@@ -244,7 +258,7 @@
--- ---
## 13. Related Documents ## 13. Related Documents
- `docs/prd/sample-prd.md` - `docs/sample/sample-prd.md`
- `docs/agent-guides/작업절차.md` - `docs/agent-guides/작업절차.md`
- `docs/agent-guides/문서유지보수.md` - `docs/agent-guides/문서유지보수.md`
- `docs/20260529_메인_홈_추천_API/prd.md` - `docs/20260529_메인_홈_추천_API/prd.md`

View File

@@ -0,0 +1,125 @@
# Phase 1 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / Task 1.1 |
| 기준 commit 또는 working tree | `5123494e` 기준 working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md` |
| 리뷰 상태 | 보완 완료 |
## 2. 리뷰 목적과 범위
- PRD와 구현 계획이 같은 작업 디렉터리에 있고 구현 기준을 충분히 고정했는지 확인한다.
- 문서 참조 경로와 완료 체크가 현재 저장소 근거와 일치하는지 확인한다.
- 코드·테스트 동작과 다른 Phase의 구현 품질은 제외한다.
## 3. 판정 기준
- 심각도는 `docs/sample/sample-review.md`의 Blocker/High/Medium/Low 기준을 사용한다.
- 존재하지 않는 근거 문서 링크는 문서 정합성 문제인 Low로 판정한다.
## 4. 검토한 근거
- 계획: Task 1.1
- 문서: `prd.md`의 Related Documents, `plan-task.md`의 시나리오 계약·범위·Phase 분해
- 정적 검증: `test -e docs/prd/sample-prd.md`, `test -e docs/sample/sample-prd.md`, `git diff --check`
- 문서 명령 검증: `./gradlew tasks --all`은 sandbox 권한 실패 후 승인된 동일 명령에서 `BUILD SUCCESSFUL`
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P1-001` | Low | 보완 완료 | PRD의 샘플 문서 링크가 실제 경로와 다르다 | Task R1.1 | `P1-R1` |
## 6. 발견 사항 상세
### REV-P1-001 — PRD의 샘플 문서 링크가 실제 경로와 다르다
- **심각도:** Low
- **상태:** 확정
- **관련 요구사항:** 문서 유지보수 규칙의 샘플 PRD 참조
- **소유 Task:** Task R1.1
**관찰 내용**
`prd.md``docs/prd/sample-prd.md`를 관련 문서로 가리키지만 해당 파일은 없고, 가이드가 지정한 실제 파일은 `docs/sample/sample-prd.md`다.
**영향**
후속 요구사항 보강 시 잘못된 템플릿 경로를 따라가 문서 작성 절차가 중단될 수 있다. 런타임 영향은 없다.
**권장 조치**
요구사항 내용은 바꾸지 않고 관련 문서 경로 한 곳만 실제 파일로 정정한다.
**판정 기록**
- 2026-07-31 — 두 경로의 파일 존재 여부를 정적으로 확인해 확정했다.
- 2026-07-31 — `prd.md` 링크를 `docs/sample/sample-prd.md`로 정정하고 `test -f docs/sample/sample-prd.md`, `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 출력 없음으로 보완 완료했다.
## 7. 확정 항목의 plan·goal 전환
- `plan-task.md` Phase 1에 Task R1.1 / `P1-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | PRD·plan-task·가이드 대조 완료 |
| 후보 항목 판정 완료 | 충족 | `REV-P1-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task R1.1 |
| 검증 명령과 결과 기록 | 충족 | 파일 존재 확인과 diff 공백 검사 |
**최종 결론:** 보완 완료
**남은 항목:** 없음
---
## 9. 2차 리뷰 기록 — 2026-07-31
### 리뷰 정보·범위
- **기준:** `5123494e` 기준 미커밋 working tree, PRD·`plan-task.md`·문서 유지보수 가이드.
- **목적:** 후속 요구사항과 Phase 7 구현 이후 상단 작업 상태가 현재 근거와 일치하는지 점검한다.
- **검증:** `plan-task.md` 상태표·Phase 1~7·전체 검증 기록을 정적 대조했다. 사용자가 컨파일·테스트 통과 상태를 전제로 제공했으므로 Gradle compile/test는 실행하지 않았다.
### 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P1-002` | Low | 보완 완료 | 상단 후속 변경 상태가 현재 계획·검증 상태와 다르다 | Task R1.2 | `P1-R2` |
#### REV-P1-002 — 상단 후속 변경 상태가 현재 근거와 다르다
- **심각도:** Low
- **상태:** 보완 완료
- **관련 요구사항:** 문서 유지보수 규칙의 현재 상태·활성/다음 Goal 기록
- **근거:** `plan-task.md:7-12`는 요구사항 기준을 Feature D로만 표시하고 Phase 7을 현재 Phase에서 누락하며, 다음 Goal로 이전 XML 파일 쓰기 실패 조사를 유지한다. 반면 문서 본문은 Feature A~F와 Phase 7 완료를 기록하고, 사용자는 현재 컨파일·테스트가 통과했다고 명시했다.
- **영향:** 다음 작업자가 이미 종료된 환경 이슈를 다음 Goal로 오인하거나 전체 요구사항 범위를 Feature D로 잘못 판단할 수 있다. 런타임 영향은 없다.
- **권장 조치:** 상태표만 현재 PRD 전체, Phase 1~7, 미완료 후속 Goal 기준으로 정리한다.
- **판정 기록:** 2026-07-31 — 상태표와 Phase 7·전체 검증 기록을 정적 대조해 확정했다.
- **보완 기록:** 2026-07-31 — `plan-task.md` 상단 상태표를 PRD 전체, Phase 1~7 후속 보완 상태, 다음 Goal `P3-R2` 기준으로 정리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all``BUILD SUCCESSFUL`로 통과했다.
### plan·goal 전환과 종료 판정
- `plan-task.md` Phase 1에 Task R1.2 / `P1-R2`를 추가했다.
- **최종 결론:** 보완 완료.
- **남은 항목:** 없음.
---
## 10. 3차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 1 / Task 1.1·Task R1.1~R1.2, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `prd.md`, `plan-task.md`의 상태표·시나리오 계약·Phase 1~7·전체 검증 기록, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`.
- **검증 방법:** PRD·plan-task 동시 존재, 실제 샘플 문서 경로, 완료 Task와 현재 상태·활성/다음 Goal의 일치 여부를 정적으로 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음. 상단 상태만 3차 리뷰 완료로 갱신했다.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,79 @@
# Phase 2 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
| 기준 commit 또는 working tree | 구현 commit `c9e35f2e`, 현재 `5123494e` 기준 working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | PRD Feature A·C, `plan-task.md` Phase 2 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 응원 점수 가중치와 전용 신규 부스트가 PRD 값 및 경계일 계약과 일치하는지 확인한다.
- 기존 크리에이터·AI·커뮤니티 점수 정책 값의 회귀 여부를 정적으로 확인한다.
## 3. 판정 기준
- 확정 가중치 `0.45/0.30/0.10`, 부스트 `1.15/1.10/1.05/1.0`, 경계일 `0·10/11·20/21·30/31`을 기준으로 판정한다.
## 4. 검토한 근거
- 코드: `RecommendationScoreSpec.kt`, `RecommendationScorePolicy.kt`
- 테스트: `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays`
- 이력: `git show c9e35f2e`
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았으며 plan-task의 기존 통과 기록을 참조했다.
## 5. 발견 사항 요약
확정 발견 사항 없음.
## 6. 발견 사항 상세
- 점수 함수와 DB가 공유하는 상수 값이 PRD와 일치한다.
- 0일, 10/11일, 20/21일, 30/31일 단위 테스트가 모두 존재한다.
- 기존 `calculateCreatorNewBoost(...)` 값은 `1.5/1.3/1.2`로 유지된다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 코드·테스트·구현 이력 대조 완료 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | 정적 검토 및 기존 검증 기록 참조 |
**최종 결론:** 확정 발견 사항 없음
**남은 항목:** 없음
---
## 9. 2차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 2 / Task 2.1~2.2, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `RecommendationScoreSpec.kt:16-18,29-31`, `RecommendationScorePolicy.kt:21-29,67-78`, `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays`.
- **검증 방법:** PRD의 `0.45/0.30/0.10`, `1.15/1.10/1.05/1.0`, 10/20/30일 경계를 코드·테스트와 정적 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.
---
## 10. 3차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 2 / Task 2.1~2.2, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `RecommendationScoreSpec.kt:16-18,29-31`, `RecommendationScorePolicy.kt:21-29,67-78`, `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays`.
- **검증 방법:** 점수 가중치 `0.45/0.30/0.10`, 부스트 `1.15/1.10/1.05/1.0`, 0·10/11·20/21·30/31일 경계와 기존 크리에이터 부스트 유지 여부를 코드·테스트로 정적 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,130 @@
# Phase 3 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 3 / Task 3.1~3.5 |
| 기준 commit 또는 working tree | 구현 commit `4f348c36`, 보정 commit `6da2378b`, 현재 `5123494e` 기준 working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | PRD Feature A~C, `plan-task.md` Phase 3 |
| 리뷰 상태 | 보완 완료 |
## 2. 리뷰 목적과 범위
- 최근 7일 UTC half-open window, 후원·팬Talk 집계, DB-side 점수·부스트, 후보·정렬·limit 구현을 확인한다.
- 완료 처리된 Task의 테스트 증거가 명시된 경계 조건을 직접 고정하는지 확인한다.
## 3. 판정 기준
- 구현 결함과 완료 증거 누락을 구분한다. 이번 발견은 현재 코드 동작 위반이 아니라 회귀 테스트 근거 누락으로 Low다.
## 4. 검토한 근거
- 코드: `RecommendationSnapshotRefreshService.kt`, `RecommendationSnapshotWindowPolicy.kt`, `DefaultHomeRecommendationQueryRepository.kt`
- 테스트: `RecommendationSnapshotRefreshServiceTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotPersistenceAdapterTest`
- 이력: `git show 4f348c36`, `git show 6da2378b`
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P3-001` | Low | 보완 완료 | Task 3.5의 일부 후보·상위 16개·동점 정렬 완료 증거가 직접 테스트로 고정되지 않았다 | Task R3.1 | `P3-R1` |
## 6. 발견 사항 상세
### REV-P3-001 — Task 3.5의 일부 완료 증거가 직접 테스트로 고정되지 않았다
- **심각도:** Low
- **상태:** 확정
- **관련 요구사항:** Feature A·C Edge Cases, Task 3.5
- **소유 Task:** Task R3.1
**관찰 내용**
현재 테스트는 donation 종류·상태·half-open 경계, distinct 후원 수, 점수/부스트, 데뷔 이력 없음과 `limit = 1` 점수 우선순위를 검증한다. 그러나 Task 3.5에 명시된 다음 계약의 직접 테스트는 확인되지 않았다.
- 실제 데뷔 이력은 있으나 후원·팬Talk가 모두 0인 후보 제외
- 미래 데뷔 이력만 있는 후보와 비활성 크리에이터 제외
- 17개 이상 후보의 상위 16개 제한
- `CHEER_CREATOR` 저장 row의 동점 `randomTieBreaker` 오름차순 조회
코드는 해당 조건을 구현하고 있어 현재 런타임 결함으로 판정하지 않는다.
**영향**
후속 native SQL 수정에서 후보 조건·저장 수·동점 순서가 회귀해도 focused test가 이를 직접 잡지 못할 수 있다.
**권장 조치**
프로덕션 코드를 선제 변경하지 않고 누락된 경계 테스트를 먼저 추가하며, 실제 실패가 드러나는 조건만 최소 수정한다.
**판정 기록**
- 2026-07-31 — 테스트명·fixture·assertion과 Task 3.5 체크리스트를 대조해 확정했다.
- 2026-07-31 — 후보 제외, 상위 16개 제한, `CHEER_CREATOR` 동점 `randomTieBreaker` 오름차순 조회 테스트를 추가했다. focused repository/persistence test는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다.
## 7. 확정 항목의 plan·goal 전환
- `plan-task.md` Phase 3에 Task R3.1 / `P3-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | query·window·정책·테스트 대조 완료 |
| 후보 항목 판정 완료 | 충족 | `REV-P3-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task R3.1 |
| 검증 명령과 결과 기록 | 충족 | 정적 검색·구현 이력 확인 |
**최종 결론:** 보완 완료
**남은 항목:** 없음
---
## 9. 2차 리뷰 기록 — 2026-07-31
### 리뷰 정보·범위
- **기준:** `5123494e` 기준 미커밋 working tree, PRD Feature A~C, `plan-task.md` Phase 3, 선행 홈 추천 PRD Feature E.
- **목적:** 최종 SQL의 7일 window·집계·점수·데뷔일·후보 조건을 현재 요구사항과 다시 대조한다.
- **검증:** `findCheerCreatorSnapshots(...)` SQL과 관련 repository 테스트, `075ca88f` 종료 라이브 데뷔 판정 보강 이력을 정적 추적했다. Gradle compile/test는 실행하지 않았다.
### 발견 사항
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P3-002` | High | 보완 완료 | 종료된 라이브가 `CHEER_CREATOR` 데뷔 이력에서 제외된다 | Task R3.2 | `P3-R2` |
#### REV-P3-002 — 종료된 라이브가 `CHEER_CREATOR` 데뷔 이력에서 제외된다
- **심각도:** High
- **상태:** 보완 완료
- **관련 요구사항:** PRD Feature C, Task 3.4
- **근거:** 현재 PRD는 데뷔일을 선행 홈 추천 PRD와 동일하게 계산하도록 한다(`prd.md:102-109`). 선행 PRD는 `channel_name`이 있는 종료 라이브도 데뷔 이력으로 인정하고 `live_room.is_active`를 조건으로 사용하지 않도록 확정한다(`docs/20260529_메인_홈_추천_API/prd.md:157-159`). 그러나 `DefaultHomeRecommendationQueryRepository.kt:589-594``CHEER_CREATOR` 데뷔 CTE는 `lr.is_active = true`를 요구한다. 관련 스냅샷 테스트는 빈 채널명과 활성 라이브만 검증하고 종료 라이브 경계를 고정하지 않는다.
- **재현 경로:** 활성 콘텐츠는 없고 채널명이 있는 `is_active = false` 종료 라이브와 최근 7일 응원 활동만 있는 활성 크리에이터를 준비한다. 현재 SQL에서는 `creator_debut` row가 생성되지 않아 후보에서 제외된다.
- **영향:** 정상적으로 라이브를 종료한 크리에이터가 응원 점수가 있어도 스냅샷 후보에서 누락되고, 더 늦은 활성 콘텐츠가 있으면 실제 최초 데뷔일보다 높은 신규 부스트를 받을 수 있다.
- **권장 조치:** `CHEER_CREATOR` CTE의 라이브 branch에서 `lr.is_active = true`만 제거하고, 종료 라이브·빈 채널명 경계를 repository 회귀 테스트로 고정한다.
- **판정 기록:** 2026-07-31 — 요구사항·SQL·선행 보강 commit·테스트 누락을 정적 대조해 확정했다.
- **보완 기록:** 2026-07-31 — 채널명이 있는 종료 라이브와 최근 7일 응원 활동만 있는 크리에이터가 `CHEER_CREATOR` 후보에 포함되는 RED 테스트를 추가했고, 빈 `channel_name` 종료 라이브 제외 경계를 함께 고정했다. RED 확인에서 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut``AssertionFailedError`로 실패했고, `findCheerCreatorSnapshots(...)` 라이브 branch의 `lr.is_active = true`만 제거한 뒤 repository focused test가 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck``BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다.
### plan·goal 전환과 종료 판정
- `plan-task.md` Phase 3에 Task R3.2 / `P3-R2`를 추가했다.
- **최종 결론:** 보완 완료.
- **남은 항목:** 없음.
---
## 10. 3차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 3 / Task 3.1~3.5·Task R3.1~R3.2, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `RecommendationSnapshotWindowPolicy.kt:7-34`, `DefaultHomeRecommendationQueryRepository.kt:573-647,1185-1227`, `RecommendationSnapshotRefreshService.kt:91-127`, 산식·half-open 경계·후원 distinct·후보 상한·종료 라이브·동점 정렬 repository/persistence 테스트.
- **검증 방법:** 최근 7일 UTC half-open window, `CHANNEL_DONATION`·`DONATION`, `UseCanCalculate.useCan` distinct 후원 수, active 팬Talk, DB-side 점수·부스트, 미래/데뷔 없음/비활성 후보 제외, 종료 라이브 데뷔 이력, 점수순 상위 16개와 저장 정렬을 정적으로 추적했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,101 @@
# Phase 4 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 4 / Task 4.1~4.3 |
| 기준 commit 또는 working tree | 구현 commit `64b05dee`, `ce43cf2c`, 현재 `5123494e` 기준 working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | PRD Feature F·Metrics, `plan-task.md` Phase 4 |
| 리뷰 상태 | 보완 완료 |
## 2. 리뷰 목적과 범위
- empty marker 저장·조회 제외·존재 판정·실제 row 대체를 확인한다.
- `CHEER_CREATOR` refresh 성공·실패 관측성 완료 여부를 확인한다.
## 3. 판정 기준
- 응답/데이터 무결성 문제와 운영 관측성 누락을 구분한다. 섹션 실패 로그 누락은 Low다.
## 4. 검토한 근거
- 코드: `RecommendationSnapshotPersistenceAdapter.kt`, `RecommendationSnapshotRepository.kt`, `RecommendationSnapshotRefreshService.kt`
- 테스트: `RecommendationSnapshotPersistenceAdapterTest`, `RecommendationSnapshotRefreshServiceTest`
- 정적 검색: `cheer_creator_recommendation_snapshot_refresh_success|failure`
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P4-001` | Low | 보완 완료 | `CHEER_CREATOR` 섹션별 refresh 실패 로그가 없다 | Task R4.1 | `P4-R1` |
## 6. 발견 사항 상세
### REV-P4-001 — `CHEER_CREATOR` 섹션별 refresh 실패 로그가 없다
- **심각도:** Low
- **상태:** 확정
- **관련 요구사항:** PRD Metrics, Task 4.3 GREEN·기대 결과
- **소유 Task:** Task R4.1
**관찰 내용**
`refreshCheerCreatorSnapshots(...)`는 커밋 후 `event=cheer_creator_recommendation_snapshot_refresh_success`를 남긴다. 반면 query 또는 저장 실패를 같은 섹션 event로 기록하는 코드는 없고, 일괄 refresh의 공통 실패 로그 또는 fallback 공통 실패 로그만 남는다.
**영향**
운영에서 스케줄러·fallback 중 어느 경로에서 `CHEER_CREATOR` 생성이 실패했는지 섹션 event만으로 일관되게 집계하기 어렵다. 저장/응답 동작 자체의 결함은 확인되지 않았다.
**권장 조치**
성공 경로와 예외 전파를 유지하면서 섹션 실패 event와 window·오류 정보를 최소 추가한다.
**판정 기록**
- 2026-07-31 — 코드와 테스트 전체에서 섹션 실패 event가 없음을 정적 검색해 확정했다.
- 2026-07-31 — 실패 로그 RED 테스트를 추가해 `AssertionFailedError`를 확인한 뒤 `refreshCheerCreatorSnapshots`에 최소 실패 로그와 예외 재전파를 추가했다. `RecommendationSnapshotRefreshServiceTest``BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다.
## 7. 확정 항목의 plan·goal 전환
- `plan-task.md` Phase 4에 Task R4.1 / `P4-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | marker·로그 경로 확인 |
| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task R4.1 |
| 검증 명령과 결과 기록 | 충족 | 정적 검색과 코드 대조 |
**최종 결론:** 보완 완료
**남은 항목:** 없음
---
## 9. 2차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 4 / Task 4.1~4.3·Task R4.1, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `RecommendationSnapshotPersistenceAdapter.kt:38-50,73-96`, `RecommendationSnapshotRepository.kt:10-55`, `RecommendationSnapshotRefreshService.kt:91-127`, marker 대체·조회 제외·존재 판정·성공/실패 로그 테스트.
- **검증 방법:** marker가 응답 조회에서 제외되면서 대상일 refresh 존재 판정에는 포함되는지, 실제 row 재실행이 marker를 대체하는지, 성공·실패 event가 분리되는지를 정적 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.
---
## 10. 3차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 4 / Task 4.1~4.3·Task R4.1, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `RecommendationSnapshotPersistenceAdapter.kt:38-50,73-96`, `RecommendationSnapshotRepository.kt:10-55`, `RecommendationSnapshotRefreshService.kt:91-127`, marker 저장·조회 제외·존재 판정·실제 row 대체·성공/실패 로그 테스트.
- **검증 방법:** `CHEER_CREATOR` 빈 결과가 `targetId = 0` marker로 저장되고 응답 조회에서는 제외되는지, 대상일 존재 판정과 실제 row 재실행 대체가 유지되는지, 성공·실패 event가 구분되고 원 예외가 전파되는지 정적으로 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,118 @@
# Phase 5 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 5 / Task 5.1~5.4 |
| 기준 commit 또는 working tree | 구현 commit `391acf9e`, `7d0cf0a8`, 현재 `5123494e` 기준 working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | PRD Feature E·F, `plan-task.md` Phase 5 |
| 리뷰 상태 | 보완 완료 |
## 2. 리뷰 목적과 범위
- 대상일 조회, section lock, lock 내부 double-check, single-flight, timeout·실패 격리, refresh 후 재조회 흐름을 확인한다.
- 구현된 동시성 계약의 직접 회귀 테스트가 존재하는지 확인한다.
## 3. 판정 기준
- 구현에는 해당 경로가 존재하지만 직접 테스트가 없는 경우 완료 증거 누락인 Low로 판정한다.
## 4. 검토한 근거
- 코드: `RecommendationSnapshotFallbackService.kt`, `HomeRecommendationQueryService.kt`
- 테스트: `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`
- 정적 검색: `single|동시|refreshFutures|double-check`와 latch 기반 테스트
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `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. 발견 사항 상세
### REV-P5-001 — single-flight와 double-check의 직접 회귀 테스트가 없다
- **심각도:** Low
- **상태:** 확정
- **관련 요구사항:** Feature E, Task 5.1·5.3
- **소유 Task:** Task R5.1
**관찰 내용**
구현은 section별 `refreshFutures`와 lock 획득 후 `hasSnapshot(...)` 재확인을 수행한다. 현재 테스트는 lock miss, refresh 실패, timeout 후 worker 지속, marker 선존재, AI 작업 중 CHEER 독립 실행을 검증하지만 다음 경쟁 조건을 직접 재현하지 않는다.
- 동일 섹션의 동시 요청이 실제 refresh 1회만 공유하는지
- 최초 조회 뒤 lock 진입 전에 다른 실행 주체가 row/marker를 저장했을 때 refresh를 생략하는지
**영향**
향후 executor·future·lock 코드 변경에서 중복 refresh 방지의 핵심 경쟁 조건이 깨져도 focused test가 탐지하지 못할 수 있다.
**권장 조치**
sleep 대신 latch와 결정적 fake를 사용해 두 경쟁 조건을 고정하고, 실패가 확인될 때만 프로덕션 코드를 최소 수정한다.
**판정 기록**
- 2026-07-31 — fallback 테스트 전체와 구현의 동시성 분기를 대조해 확정했다.
- 2026-07-31 — 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 latch 기반 테스트로 추가했다. 현 구현이 테스트를 만족해 프로덕션 코드는 변경하지 않았고, `RecommendationSnapshotFallbackServiceTest``BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다.
## 7. 확정 항목의 plan·goal 전환
- `plan-task.md` Phase 5에 Task R5.1 / `P5-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | fallback 코드·테스트 분기 대조 완료 |
| 후보 항목 판정 완료 | 충족 | `REV-P5-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task R5.1 |
| 검증 명령과 결과 기록 | 충족 | 정적 검색과 기존 기록 참조 |
**최종 결론:** 보완 완료
**남은 항목:** 없음
---
## 9. 2차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 5 / Task 5.1~5.4·Task R5.1, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `RecommendationSnapshotFallbackService.kt:81-205`, `HomeRecommendationQueryService.kt:112-136`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`.
- **검증 방법:** 대상일 exact snapshot 조회, marker 존재 판정, section lock·double-check·single-flight, 300ms/1,500ms, timeout 후 worker 유지, refresh 실패 격리, 16명 후보 재조회를 코드·테스트와 정적 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.
---
## 10. 3차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 5 / Task 5.1~5.4·Task R5.1, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `RecommendationSnapshotFallbackService.kt:28-245`, `HomeRecommendationQueryService.kt:112-136`, fallback service와 query service의 대상일·marker·lock miss·timeout·실패·동시성 테스트.
- **검증 방법:** 대상일 exact snapshot, section별 lock key, 300ms lock 대기, 1,500ms 홈 대기, lock 내부 double-check, section별 single-flight, timeout 후 worker 유지, 실패 격리, refresh 후 재조회와 16명 후보 전달을 정적으로 추적했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **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,101 @@
# Phase 6 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 6 / Task 6.1~6.3 |
| 기준 commit 또는 working tree | 현재 `5123494e` 기준 working tree와 plan-task 기존 검증 기록 |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | PRD Non-Goals·Feature D, `plan-task.md` Phase 6 |
| 리뷰 상태 | 보완 완료 |
## 2. 리뷰 목적과 범위
- 홈 API URL과 `cheerCreators` item의 공개 필드가 유지되는지 확인한다.
- 완료 처리된 API 스키마 회귀 테스트가 비어 있지 않은 item 계약을 직접 고정하는지 확인한다.
## 3. 판정 기준
- DTO 구현은 맞지만 회귀 테스트가 필드 계약을 검출하지 못하는 경우 완료 증거 누락인 Low로 판정한다.
## 4. 검토한 근거
- 코드: `HomeRecommendationResponse.kt`, `HomeRecommendationFacade.kt`, `HomeRecommendationQueryPort.kt`
- 테스트: `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`
- 기존 실행 증거: plan-task의 2026-07-10, 2026-07-31 검증 기록
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
## 5. 발견 사항 요약
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|---|---|---|---|---|---|
| `REV-P6-001` | Low | 보완 완료 | `cheerCreators` item의 정확한 3개 필드 계약이 테스트로 고정되지 않았다 | Task R6.1 | `P6-R1` |
## 6. 발견 사항 상세
### REV-P6-001 — `cheerCreators` item의 정확한 3개 필드 계약이 테스트로 고정되지 않았다
- **심각도:** Low
- **상태:** 확정
- **관련 요구사항:** Feature D, Non-Goals, Task 6.1
- **소유 Task:** Task R6.1
**관찰 내용**
응답 DTO는 기존 `HomeCreatorItem`을 사용해 `creatorId`, `creatorNickname`, `creatorProfileImage`를 유지한다. 그러나 `HomeRecommendationResponseTest``cheerCreators = emptyList()`로 직렬화하고, controller 테스트는 배열 존재만 확인한다. 따라서 item 필드가 추가·삭제·개명되어도 현재 두 assertion은 통과할 수 있다.
**영향**
향후 DTO 변경에서 공개 API 스키마 회귀가 focused test를 빠져나갈 수 있다. 현재 DTO 자체의 계약 위반은 확인되지 않았다.
**권장 조치**
비어 있지 않은 item을 사용해 값과 정확한 필드 수 3을 직렬화 및 controller 계층에서 고정한다.
**판정 기록**
- 2026-07-31 — 관련 테스트 fixture와 JSON assertion을 대조해 확정했다.
- 2026-07-31 — `cheerCreators` item 직렬화와 홈 API 응답의 정확한 3개 필드 계약을 테스트로 추가했다. focused API test는 fixture 보정 후 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다.
## 7. 확정 항목의 plan·goal 전환
- `plan-task.md` Phase 6에 Task R6.1 / `P6-R1`을 추가했다.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | DTO·facade·API 테스트 대조 완료 |
| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 |
| 확정 항목 plan 반영 | 충족 | Task R6.1 |
| 검증 명령과 결과 기록 | 충족 | 정적 검토와 기존 실행 기록 참조 |
**최종 결론:** 보완 완료
**남은 항목:** 없음
---
## 9. 2차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 6 / Task 6.1~6.3·Task R6.1, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `HomeRecommendationResponseTest.kt:61-67,117-123`, `HomeRecommendationControllerTest.kt:529-549`, `HomeRecommendationFacade.kt`, `HomeRecommendationQueryService.kt`.
- **검증 방법:** `cheerCreators` 실제 item의 `creatorId`, `creatorNickname`, `creatorProfileImage` 값과 정확한 3개 필드 계약, 기존 API URL·DTO 유지, 대상일 snapshot fixture를 정적 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.
---
## 10. 3차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 6 / Task 6.1~6.3·Task R6.1, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `HomeRecommendationController.kt`, `HomeRecommendationResponse.kt:20-51`, `HomeRecommendationFacade.kt:50-95,294-298`, `HomeRecommendationControllerTest.shouldKeepCheerCreatorItemSchemaOnHomeRecommendations`, `HomeRecommendationResponseTest`의 정확한 필드 집합 assertion.
- **검증 방법:** 기존 `GET /api/v2/home/recommendations` URL과 `cheerCreators` item의 `creatorId`, `creatorNickname`, `creatorProfileImage` 3개 필드만 유지되는지, 대상일 스냅샷 fixture가 실제 조회 경로와 일치하는지 정적으로 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.

View File

@@ -0,0 +1,82 @@
# Phase 7 리뷰 보고서
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 7 / Task 7.1 / `P7-GATE` |
| 기준 commit 또는 working tree | `5123494e` 기준 미커밋 working tree |
| 리뷰 일자 | 2026-07-31 |
| 리뷰어 | Codex |
| 기준 문서 | PRD Feature D, `plan-task.md` Phase 7 |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
- 인증 회원 본인과 활성 팔로우 크리에이터만 제외되는지 확인한다.
- 비활성 팔로우 이력, 비회원, 기존 양방향 차단, 스냅샷 순서·후보 16개/응답 8개 정책이 유지되는지 확인한다.
## 3. 판정 기준
- 공개 API나 스냅샷 산식을 변경하지 않고 상세 조회의 `memberId != null` 조건에서만 개인화 필터를 적용해야 한다.
## 4. 검토한 근거
- 변경 diff: `DefaultHomeRecommendationQueryRepository.kt`, `DefaultHomeRecommendationQueryRepositoryTest.kt`, PRD, plan-task
- 코드: `notViewerCondition(...)`, `notActiveFollowedCreatorCondition(...)`, 기존 `notBlockedCreatorCondition(...)`
- 테스트: `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails`, `shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters`, 기존 양방향 차단 테스트
- 정적 검증: `git diff --check`, `git diff --name-status`
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았으며 plan-task의 기존 통과 기록을 참조했다.
## 5. 발견 사항 요약
확정 발견 사항 없음.
## 6. 발견 사항 상세
- `memberId == null`이면 신규 두 조건이 모두 생략된다.
- 인증 회원이면 `member.id != memberId`와 활성 `CreatorFollowing` row의 `not exists`가 적용된다.
- `isActive == false` 팔로우 이력은 제외 조건이 아니며 기존 차단 조건은 그대로 조합된다.
- 서비스는 기존처럼 스냅샷 순서로 상세를 재조립하고 최대 8개만 반환한다.
- 변경 범위는 PRD·plan-task·repository·repository test 네 파일로 한정되어 공개 DTO나 스냅샷 생성 경로를 수정하지 않았다.
## 7. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 8. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 요구사항·diff·테스트 정적 대조 완료 |
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | diff 검사 및 기존 검증 기록 참조 |
**최종 결론:** 확정 발견 사항 없음
**남은 항목:** 없음
---
## 9. 2차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 7 / Task 7.1·`P7-GATE`, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `DefaultHomeRecommendationQueryRepository.kt:804-828,1274-1304`, `HomeRecommendationQueryService.kt:112-120`, 본인·활성/비활성 팔로우·비회원·양방향 차단 repository 테스트.
- **검증 방법:** `memberId == null`의 필터 생략, 인증 회원의 본인 제외, `CreatorFollowing.isActive == true` 필터, 비활성 이력 유지, 기존 차단 조건 조합, snapshot 순서·최대 8명 재조립을 정적 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.
---
## 10. 3차 리뷰 기록 — 2026-07-31
- **리뷰 대상:** Phase 7 / Task 7.1·`P7-GATE`, `5123494e` 기준 미커밋 working tree.
- **검토 근거:** `DefaultHomeRecommendationQueryRepository.kt:803-827,1273-1303`, `HomeRecommendationQueryService.kt:112-120`, 본인·활성/비활성 팔로우·비회원·양방향 차단 repository 테스트와 service의 memberId·순서·limit 테스트.
- **검증 방법:** `memberId == null` 필터 생략, 인증 회원 본인 제외, `CreatorFollowing.isActive == true``not exists`, 비활성 이력 유지, 기존 양방향 차단, 16명 후보 안에서 스냅샷 순서대로 최대 8명 반환을 정적으로 대조했다. Gradle compile/test는 실행하지 않았다.
- **발견 사항:** 확정 발견 사항 없음.
- **plan·goal 전환:** 전환 항목 없음.
- **최종 결론:** 확정 발견 사항 없음.
- **남은 항목:** 없음.

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

@@ -207,8 +207,14 @@ class AudioContentService(
val audioContent = repository.findByIdAndCreatorId(audioContentId, member.id!!) val audioContent = repository.findByIdAndCreatorId(audioContentId, member.id!!)
?: throw SodaException(messageKey = "content.error.invalid_content_retry") ?: throw SodaException(messageKey = "content.error.invalid_content_retry")
val pinContent = pinContentRepository.findByContentIdAndMemberId(
contentId = audioContentId,
memberId = member.id!!
)
audioContent.isActive = false audioContent.isActive = false
audioContent.releaseDate = null audioContent.releaseDate = null
pinContent?.isActive = false
} }
@Transactional @Transactional
@@ -961,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

@@ -15,6 +15,7 @@ interface PinContentQueryRepository {
class PinContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : PinContentQueryRepository { class PinContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : PinContentQueryRepository {
override fun getPinContentList(memberId: Long, active: Boolean?): List<PinContent> { override fun getPinContentList(memberId: Long, active: Boolean?): List<PinContent> {
var where = pinContent.member.id.eq(memberId) var where = pinContent.member.id.eq(memberId)
.and(pinContent.content.isActive.eq(true))
if (active != null) { if (active != null) {
where = where.and(pinContent.isActive.eq(active)) where = where.and(pinContent.isActive.eq(active))
@@ -30,6 +31,7 @@ class PinContentQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) :
override fun findByContentIdAndMemberId(contentId: Long, memberId: Long, active: Boolean?): PinContent? { override fun findByContentIdAndMemberId(contentId: Long, memberId: Long, active: Boolean?): PinContent? {
var where = pinContent.content.id.eq(contentId) var where = pinContent.content.id.eq(contentId)
.and(pinContent.member.id.eq(memberId)) .and(pinContent.member.id.eq(memberId))
.and(pinContent.content.isActive.eq(true))
if (active != null) { if (active != null) {
where = where where = where

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

@@ -26,6 +26,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScoreSpec import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationScoreSpec
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
@@ -86,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")
@@ -114,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())
@@ -153,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,
@@ -168,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,
@@ -181,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
@@ -587,8 +593,7 @@ class DefaultHomeRecommendationQueryRepository(
union all union all
select lr.member_id as creator_id, lr.begin_date_time as debut_at select lr.member_id as creator_id, lr.begin_date_time as debut_at
from live_room lr from live_room lr
where lr.is_active = true where lr.channel_name is not null
and lr.channel_name is not null
and lr.channel_name <> '' and lr.channel_name <> ''
and lr.begin_date_time <= :snapshotAt and lr.begin_date_time <= :snapshotAt
) debut_events ) debut_events
@@ -816,7 +821,13 @@ class DefaultHomeRecommendationQueryRepository(
) )
) )
.from(member) .from(member)
.where(member.isActive.isTrue, member.id.`in`(creatorIds), notBlockedCreatorCondition(memberId, member.id)) .where(
member.isActive.isTrue,
member.id.`in`(creatorIds),
notViewerCondition(memberId, member.id),
notActiveFollowedCreatorCondition(memberId, member.id),
notBlockedCreatorCondition(memberId, member.id)
)
.fetch() .fetch()
} }
@@ -1256,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
} }
@@ -1278,6 +1293,24 @@ class DefaultHomeRecommendationQueryRepository(
.notExists() .notExists()
} }
private fun notViewerCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
return memberId?.let { Expressions.booleanTemplate("{0} <> {1}", creatorIdPath, it) }
}
private fun notActiveFollowedCreatorCondition(memberId: Long?, creatorIdPath: Expression<Long>): BooleanExpression? {
if (memberId == null) return null
val creatorFollowing = QCreatorFollowing("recommendationCreatorFollowing")
return JPAExpressions
.selectOne()
.from(creatorFollowing)
.where(
creatorFollowing.isActive.isTrue,
creatorFollowing.member.id.eq(memberId),
creatorFollowing.creator.id.eq(creatorIdPath)
)
.notExists()
}
private fun orderedCommunityPostCondition(memberId: Long?): BooleanExpression { private fun orderedCommunityPostCondition(memberId: Long?): BooleanExpression {
if (memberId == null) return Expressions.FALSE if (memberId == null) return Expressions.FALSE
return JPAExpressions return JPAExpressions

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

@@ -92,24 +92,38 @@ open class RecommendationSnapshotRefreshService(
open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int { open fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)): Int {
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc) val window = windowPolicy.previousKstSevenDayUtcWindow(nowUtc)
val snapshots = queryPort.findCheerCreatorSnapshots( try {
window.startUtc, val snapshots = queryPort.findCheerCreatorSnapshots(
window.endExclusiveUtc, window.startUtc,
CHEER_CREATOR_SNAPSHOT_LIMIT window.endExclusiveUtc,
) CHEER_CREATOR_SNAPSHOT_LIMIT
snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots) )
afterCommit { snapshotPort.replaceSnapshots(RecommendedSectionType.CHEER_CREATOR, window.snapshotAt, snapshots)
log.info( afterCommit {
"event=cheer_creator_recommendation_snapshot_refresh_success " + log.info(
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}", "event=cheer_creator_recommendation_snapshot_refresh_success " +
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} savedCount={} elapsedMs={}",
window.snapshotAt,
window.startUtc,
window.endExclusiveUtc,
snapshots.size,
System.currentTimeMillis() - startedAt
)
}
return snapshots.size
} catch (ex: Exception) {
log.warn(
"event=cheer_creator_recommendation_snapshot_refresh_failure " +
"snapshotAt={} windowStartUtc={} windowEndExclusiveUtc={} elapsedMs={} error={}",
window.snapshotAt, window.snapshotAt,
window.startUtc, window.startUtc,
window.endExclusiveUtc, window.endExclusiveUtc,
snapshots.size, System.currentTimeMillis() - startedAt,
System.currentTimeMillis() - startedAt ex.message,
ex
) )
throw ex
} }
return snapshots.size
} }
@Transactional @Transactional

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() {
@@ -572,6 +617,28 @@ class AudioContentServiceTest {
assertTrue(inactivePinContent.isActive) assertTrue(inactivePinContent.isActive)
} }
@Test
@DisplayName("콘텐츠 삭제 시 해당 콘텐츠의 상단 고정도 함께 비활성화한다")
fun shouldDeactivatePinContentWhenDeletingAudioContent() {
val creator = createMember(id = 2430L, nickname = "pin-delete-creator")
val audioContent = createAudioContent(creator = creator)
val pinContent = PinContent(isActive = true)
pinContent.member = creator
pinContent.content = audioContent
Mockito.`when`(repository.findByIdAndCreatorId(audioContent.id!!, creator.id!!)).thenReturn(audioContent)
Mockito.`when`(
pinContentRepository.findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
).thenReturn(pinContent)
service.deleteAudioContent(audioContentId = audioContent.id!!, member = creator)
assertEquals(false, audioContent.isActive)
assertEquals(false, pinContent.isActive)
}
private fun createMember(id: Long, nickname: String): Member { private fun createMember(id: Long, nickname: String): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",

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

@@ -14,6 +14,7 @@ import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade import kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacade
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
@@ -38,6 +39,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager import javax.persistence.EntityManager
@SpringBootTest @SpringBootTest
@@ -524,6 +526,28 @@ class HomeRecommendationControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.items[0].creatorId").value(character.creatorMember!!.id)) .andExpect(jsonPath("$.data.items[0].creatorId").value(character.creatorMember!!.id))
} }
@Test
@DisplayName("메인 홈 통합 조회는 응원 크리에이터 item의 기존 3개 필드만 노출한다")
fun shouldKeepCheerCreatorItemSchemaOnHomeRecommendations() {
val creator = saveMember("cheer-api-creator", MemberRole.CREATOR).apply {
profileImage = "cheer-api-profile.png"
}
val snapshotAt = RecommendationSnapshotWindowPolicy()
.previousKstDayUtcWindow(LocalDateTime.now(ZoneOffset.UTC))
.snapshotAt
saveCheerCreatorRecommendationSnapshot(creator.id!!, snapshotAt)
saveCheerCreatorRecommendationSnapshot(creator.id!!, snapshotAt.plusDays(1))
entityManager.flush()
entityManager.clear()
mockMvc.perform(get("/api/v2/home/recommendations"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.cheerCreators[0].creatorId").value(creator.id))
.andExpect(jsonPath("$.data.cheerCreators[0].creatorNickname").value("cheer-api-creator"))
.andExpect(jsonPath("$.data.cheerCreators[0].creatorProfileImage").value("/cheer-api-profile.png"))
.andExpect(jsonPath("$.data.cheerCreators[0].length()").value(3))
}
@Test @Test
@DisplayName("최근 활동 라이브 크리에이터는 creatorId와 라이브 상태별 targetId를 노출한다") @DisplayName("최근 활동 라이브 크리에이터는 creatorId와 라이브 상태별 targetId를 노출한다")
fun shouldExposeNavigationIdsForRecentlyActiveLiveCreators() { fun shouldExposeNavigationIdsForRecentlyActiveLiveCreators() {
@@ -619,4 +643,16 @@ class HomeRecommendationControllerTest @Autowired constructor(
) )
) )
} }
private fun saveCheerCreatorRecommendationSnapshot(creatorId: Long, snapshotAt: LocalDateTime) {
entityManager.persist(
RecommendationSnapshot(
sectionType = RecommendedSectionType.CHEER_CREATOR,
targetId = creatorId,
score = 100.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.1
)
)
}
} }

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

@@ -58,7 +58,13 @@ class HomeRecommendationResponseTest {
) )
), ),
genreCreators = emptyList(), genreCreators = emptyList(),
cheerCreators = emptyList(), cheerCreators = listOf(
HomeCreatorItem(
creatorId = 15L,
creatorNickname = "cheer-creator",
creatorProfileImage = "https://cdn.test/profile/cheer.png"
)
),
popularCommunityPosts = listOf( popularCommunityPosts = listOf(
HomePopularCommunityPostItem( HomePopularCommunityPostItem(
postId = 5L, postId = 5L,
@@ -108,6 +114,13 @@ class HomeRecommendationResponseTest {
assertEquals("https://cdn.test/profile/character.png", json["aiCharacters"][0]["profileImage"].asText()) assertEquals("https://cdn.test/profile/character.png", json["aiCharacters"][0]["profileImage"].asText())
assertEquals(13L, json["aiCharacters"][0]["creatorId"].asLong()) assertEquals(13L, json["aiCharacters"][0]["creatorId"].asLong())
assertEquals(true, json["aiCharacters"][1]["profileImage"].isNull) assertEquals(true, json["aiCharacters"][1]["profileImage"].isNull)
assertEquals(15L, json["cheerCreators"][0]["creatorId"].asLong())
assertEquals("cheer-creator", json["cheerCreators"][0]["creatorNickname"].asText())
assertEquals("https://cdn.test/profile/cheer.png", json["cheerCreators"][0]["creatorProfileImage"].asText())
assertEquals(
setOf("creatorId", "creatorNickname", "creatorProfileImage"),
json["cheerCreators"][0].fieldNames().asSequence().toSet()
)
assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong()) assertEquals(5L, json["popularCommunityPosts"][0]["postId"].asLong())
assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText()) assertEquals("https://cdn.test/community/image.png", json["popularCommunityPosts"][0]["imageUrl"].asText())
assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText()) assertEquals("https://cdn.test/community/audio.mp3", json["popularCommunityPosts"][0]["audioUrl"].asText())

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() {
@@ -940,6 +987,100 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(expectedScore, snapshots.single().score, 0.0001) assertEquals(expectedScore, snapshots.single().score, 0.0001)
} }
@Test
@DisplayName("최근 응원 스냅샷은 활동 점수가 없는 후보와 미래 데뷔 또는 비활성 크리에이터를 제외한다")
fun shouldExcludeIneligibleCheerCreatorSnapshotCandidates() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val donor = saveMember("cheer-boundary-donor", MemberRole.USER)
val zeroActivityCreator = saveMember("zero-activity-cheer", MemberRole.CREATOR)
val futureDebutCreator = saveMember("future-debut-cheer", MemberRole.CREATOR)
val inactiveCreator = saveMember("inactive-cheer-candidate", MemberRole.CREATOR, isActive = false)
val visibleCreator = saveMember("visible-cheer-candidate", MemberRole.CREATOR)
saveLiveRoom(zeroActivityCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "zero-activity")
saveLiveRoom(futureDebutCreator, snapshotAt.plusSeconds(1), channelName = "future-debut")
saveLiveRoom(inactiveCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "inactive-cheer")
saveLiveRoom(visibleCreator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "visible-cheer")
listOf(futureDebutCreator, inactiveCreator, visibleCreator).forEach { creator ->
saveUseCanCalculate(
donor,
creator,
CanUsage.CHANNEL_DONATION,
10,
UseCanCalculateStatus.RECEIVED,
false,
windowStart.plusDays(1)
)
}
flushAndClear()
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16)
assertEquals(listOf(visibleCreator.id), snapshots.map { it.targetId })
}
@Test
@DisplayName("최근 응원 스냅샷은 채널명이 있는 종료 라이브를 데뷔 이력으로 인정한다")
fun shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val donor = saveMember("ended-live-cheer-donor", MemberRole.USER)
val endedLiveCreator = saveMember("ended-live-cheer", MemberRole.CREATOR)
val blankChannelCreator = saveMember("blank-ended-live-cheer", MemberRole.CREATOR)
saveLiveRoom(
endedLiveCreator,
LocalDateTime.of(2026, 5, 1, 0, 0),
channelName = "ended-live-cheer",
isActive = false
)
saveLiveRoom(
blankChannelCreator,
LocalDateTime.of(2026, 5, 1, 0, 0),
channelName = "",
isActive = false
)
val endedLiveCheer = saveCreatorCheers(donor, endedLiveCreator, isActive = true)
val blankChannelCheer = saveCreatorCheers(donor, blankChannelCreator, isActive = true)
updateCreatedAt("CreatorCheers", endedLiveCheer.id!!, windowStart.plusDays(1))
updateCreatedAt("CreatorCheers", blankChannelCheer.id!!, windowStart.plusDays(1))
flushAndClear()
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16)
assertEquals(listOf(endedLiveCreator.id), snapshots.map { it.targetId })
}
@Test
@DisplayName("최근 응원 스냅샷은 점수 내림차순 상위 16개만 반환한다")
fun shouldReturnTopSixteenCheerCreatorSnapshotsByScore() {
val windowStart = LocalDateTime.of(2026, 5, 23, 0, 0)
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
val windowEndExclusive = snapshotAt.plusSeconds(1)
val donor = saveMember("cheer-top-donor", MemberRole.USER)
val creators = (1..17).map { index ->
val creator = saveMember("cheer-top-$index", MemberRole.CREATOR)
saveLiveRoom(creator, LocalDateTime.of(2026, 5, 1, 0, 0), channelName = "cheer-top-$index")
saveUseCanCalculate(
donor,
creator,
CanUsage.CHANNEL_DONATION,
index,
UseCanCalculateStatus.RECEIVED,
false,
windowStart.plusDays(1)
)
creator
}
flushAndClear()
val snapshots = repository.findCheerCreatorSnapshots(windowStart, windowEndExclusive, limit = 16)
assertEquals(16, snapshots.size)
assertEquals(creators.drop(1).reversed().map { it.id }, snapshots.map { it.targetId })
}
@Test @Test
@DisplayName("인기 커뮤니티 스냅샷은 좋아요와 댓글 수를 distinct로 집계하고 follower-only 게시글은 제외한다") @DisplayName("인기 커뮤니티 스냅샷은 좋아요와 댓글 수를 distinct로 집계하고 follower-only 게시글은 제외한다")
fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() { fun shouldFindPopularCommunitySnapshotsWithDistinctCounts() {
@@ -1686,6 +1827,42 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(visibleCreator.id), details.map { it.creatorId }) assertEquals(listOf(visibleCreator.id), details.map { it.creatorId })
} }
@Test
@DisplayName("최근 응원 크리에이터 상세는 조회자 본인과 활성 팔로우 크리에이터를 제외한다")
fun shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails() {
val viewer = saveMember("self-follow-cheer-viewer", MemberRole.CREATOR)
val activeFollowedCreator = saveMember("active-followed-cheer", MemberRole.CREATOR)
val inactiveFollowedCreator = saveMember("inactive-followed-cheer", MemberRole.CREATOR)
val visibleCreator = saveMember("unrelated-cheer", MemberRole.CREATOR)
saveFollowing(viewer, activeFollowedCreator, isActive = true)
saveFollowing(viewer, inactiveFollowedCreator, isActive = false)
flushAndClear()
val details = repository.findCheerCreatorRecommendationDetails(
listOf(viewer.id!!, activeFollowedCreator.id!!, inactiveFollowedCreator.id!!, visibleCreator.id!!),
memberId = viewer.id
)
assertEquals(listOf(inactiveFollowedCreator.id, visibleCreator.id), details.map { it.creatorId })
}
@Test
@DisplayName("최근 응원 크리에이터 상세는 비회원 조회에서 회원별 필터를 적용하지 않는다")
fun shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters() {
val viewer = saveMember("anonymous-filter-source", MemberRole.CREATOR)
val activeFollowedCreator = saveMember("anonymous-active-followed-cheer", MemberRole.CREATOR)
val visibleCreator = saveMember("anonymous-unrelated-cheer", MemberRole.CREATOR)
saveFollowing(viewer, activeFollowedCreator, isActive = true)
flushAndClear()
val details = repository.findCheerCreatorRecommendationDetails(
listOf(viewer.id!!, activeFollowedCreator.id!!, visibleCreator.id!!),
memberId = null
)
assertEquals(listOf(viewer.id, activeFollowedCreator.id, visibleCreator.id), details.map { it.creatorId })
}
@Test @Test
@DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다") @DisplayName("최근 응원 크리에이터 상세는 빈 id 목록이면 빈 배열을 반환한다")
fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() { fun shouldReturnEmptyCheerCreatorRecommendationDetailsWhenIdsAreEmpty() {
@@ -2511,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,
@@ -2521,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

@@ -108,6 +108,40 @@ class RecommendationSnapshotPersistenceAdapterTest @Autowired constructor(
assertEquals(listOf(snapshotAt, snapshotAt), snapshots.map { it.snapshotAt }) assertEquals(listOf(snapshotAt, snapshotAt), snapshots.map { it.snapshotAt })
} }
@Test
fun shouldFindCheerCreatorSnapshotsWithScoreDescendingAndTieBreakerAscending() {
val snapshotAt = LocalDateTime.of(2026, 5, 29, 23, 59, 59)
repository.saveAll(
listOf(
snapshot(
RecommendedSectionType.CHEER_CREATOR,
targetId = 1L,
score = 100.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.9
),
snapshot(
RecommendedSectionType.CHEER_CREATOR,
targetId = 2L,
score = 200.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.8
),
snapshot(
RecommendedSectionType.CHEER_CREATOR,
targetId = 3L,
score = 100.0,
snapshotAt = snapshotAt,
randomTieBreaker = 0.1
)
)
)
val snapshots = adapter.findSnapshots(RecommendedSectionType.CHEER_CREATOR, snapshotAt, offset = 0, limit = 10)
assertEquals(listOf(2L, 3L, 1L), snapshots.map { it.targetId })
}
@Test @Test
fun shouldCheckSnapshotExistenceByExactSnapshotAtIncludingEmptyMarker() { fun shouldCheckSnapshotExistenceByExactSnapshotAtIncludingEmptyMarker() {
val staleSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59) val staleSnapshotAt = LocalDateTime.of(2026, 5, 28, 23, 59, 59)

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

@@ -13,7 +13,9 @@ import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch 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.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class RecommendationSnapshotFallbackServiceTest { class RecommendationSnapshotFallbackServiceTest {
@Test @Test
@@ -263,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,
@@ -278,6 +288,77 @@ class RecommendationSnapshotFallbackServiceTest {
assertEquals(listOf(99L), second.map { it.targetId }) assertEquals(listOf(99L), second.map { it.targetId })
} }
@Test
@DisplayName("응원 크리에이터 fallback 동시 요청은 하나의 refresh future를 공유한다")
fun shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshStarted = CountDownLatch(1)
val allowRefreshComplete = CountDownLatch(1)
val refreshService = BlockingCheerRefreshService(snapshotPort, refreshStarted, allowRefreshComplete)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenReturn(true)
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val workerTaskCount = AtomicInteger()
val workerPool = Executors.newFixedThreadPool(2)
val workerExecutor = Executor { command ->
workerTaskCount.incrementAndGet()
workerPool.execute(command)
}
val requestExecutor = Executors.newFixedThreadPool(2)
val service = RecommendationSnapshotFallbackService(
snapshotPort,
refreshService,
redissonClient,
workerExecutor,
homeWaitMillis = 50
)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
try {
val first = requestExecutor.submitCheerRefresh(service, nowUtc)
val second = requestExecutor.submitCheerRefresh(service, nowUtc)
assertEquals(true, refreshStarted.await(5, TimeUnit.SECONDS))
assertEquals(emptyList<RecommendationSnapshotRecord>(), first.get(5, TimeUnit.SECONDS))
assertEquals(emptyList<RecommendationSnapshotRecord>(), second.get(5, TimeUnit.SECONDS))
assertEquals(1, workerTaskCount.get())
assertEquals(1, refreshService.cheerRefreshCount)
} finally {
allowRefreshComplete.countDown()
requestExecutor.shutdownNow()
workerPool.shutdownNow()
}
}
@Test
@DisplayName("응원 크리에이터 fallback은 lock 내부 double-check에서 최신 상태가 생기면 refresh를 생략한다")
fun shouldSkipCheerCreatorRefreshWhenSnapshotAppearsBeforeLockDoubleCheck() {
val snapshotPort = FakeRecommendationFallbackSnapshotPort()
val refreshService = Mockito.mock(RecommendationSnapshotRefreshService::class.java)
val redissonClient = Mockito.mock(RedissonClient::class.java)
val lock = Mockito.mock(RLock::class.java)
val nowUtc = LocalDateTime.of(2026, 7, 9, 21, 0)
val snapshotAt = LocalDateTime.of(2026, 7, 9, 14, 59, 59)
Mockito.`when`(redissonClient.getLock(RecommendationSnapshotFallbackService.CHEER_CREATOR_LOCK_KEY)).thenReturn(lock)
Mockito.`when`(lock.tryLock(300, -1, TimeUnit.MILLISECONDS)).thenAnswer {
snapshotPort.replaceSnapshots(
RecommendedSectionType.CHEER_CREATOR,
snapshotAt,
listOf(snapshot(RecommendedSectionType.CHEER_CREATOR, 88L))
)
true
}
Mockito.`when`(lock.isHeldByCurrentThread).thenReturn(true)
val service = RecommendationSnapshotFallbackService(snapshotPort, refreshService, redissonClient, directExecutor())
val snapshots = service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
assertEquals(listOf(88L), snapshots.map { it.targetId })
Mockito.verify(refreshService, Mockito.never()).refreshCheerCreatorSnapshots(nowUtc)
Mockito.verify(lock).unlock()
}
@Test @Test
@DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다") @DisplayName("AI fallback이 오래 걸려도 응원 크리에이터 fallback은 같은 worker queue에서 대기하지 않는다")
fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() { fun shouldRunCheerCreatorFallbackWithoutWaitingForBlockedAiFallback() {
@@ -321,6 +402,15 @@ class RecommendationSnapshotFallbackServiceTest {
} }
private fun directExecutor(): Executor = Executor { command -> command.run() } private fun directExecutor(): Executor = Executor { command -> command.run() }
private fun java.util.concurrent.ExecutorService.submitCheerRefresh(
service: RecommendationSnapshotFallbackService,
nowUtc: LocalDateTime
): Future<List<RecommendationSnapshotRecord>> {
return submit<List<RecommendationSnapshotRecord>> {
service.refreshCheerCreatorIfMissing(offset = 0, limit = 16, nowUtc = nowUtc)
}
}
} }
private class BlockingCheerRefreshService( private class BlockingCheerRefreshService(
@@ -331,9 +421,12 @@ private class BlockingCheerRefreshService(
snapshotPort, snapshotPort,
Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java) Mockito.mock(kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQueryPort::class.java)
) { ) {
var cheerRefreshCount: Int = 0
override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int { override fun refreshCheerCreatorSnapshots(nowUtc: LocalDateTime): Int {
cheerRefreshCount += 1
refreshStarted.countDown() refreshStarted.countDown()
allowRefreshComplete.await(1, TimeUnit.SECONDS) allowRefreshComplete.await()
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),

View File

@@ -6,6 +6,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeRecommendationQue
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
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.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
import org.junit.jupiter.api.extension.ExtendWith import org.junit.jupiter.api.extension.ExtendWith
@@ -197,6 +199,29 @@ class RecommendationSnapshotRefreshServiceTest {
assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId }) assertEquals(listOf(225L), snapshotPort.findLatestSnapshots(RecommendedSectionType.POPULAR_COMMUNITY).map { it.targetId })
} }
@Test
@DisplayName("응원 크리에이터 스냅샷 갱신 실패는 섹션 event와 window를 로그로 남기고 예외를 전파한다")
fun shouldLogCheerCreatorRefreshFailureWithWindow(output: CapturedOutput) {
val queryPort = Mockito.mock(HomeRecommendationQueryPort::class.java)
val service = service(queryPort = queryPort)
val now = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
val windowStart = LocalDateTime.of(2026, 5, 22, 15, 0, 0)
val windowEndExclusive = LocalDateTime.of(2026, 5, 29, 15, 0, 0)
Mockito.`when`(queryPort.findCheerCreatorSnapshots(windowStart, windowEndExclusive, 16))
.thenThrow(IllegalStateException("cheer refresh failed"))
val exception = assertThrows(IllegalStateException::class.java) {
service.refreshCheerCreatorSnapshots(now)
}
assertEquals("cheer refresh failed", exception.message)
assertTrue(output.out.contains("event=cheer_creator_recommendation_snapshot_refresh_failure"))
assertTrue(output.out.contains("snapshotAt=2026-05-29T14:59:59"))
assertTrue(output.out.contains("windowStartUtc=2026-05-22T15:00"))
assertTrue(output.out.contains("windowEndExclusiveUtc=2026-05-29T15:00"))
assertTrue(output.out.contains("error=cheer refresh failed"))
}
@Test @Test
@DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다") @DisplayName("일 스냅샷 갱신은 AI 캐릭터 섹션 lock을 획득한 경우에만 AI refresh를 실행한다")
fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() { fun shouldRefreshAiCharacterSectionOnlyWhenSectionLockAcquired() {