Compare commits
13 Commits
13e0b42375
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| b18e40d4fc | |||
| d99104d945 | |||
| af347a71a2 | |||
| 631b63f5f1 | |||
| 0193b77700 | |||
| 8538880d81 | |||
| 579a48c2f1 | |||
| 20f73a89f0 | |||
| 033044b4de | |||
| eb0ff7537e | |||
| 7b9213a9ea | |||
| ed54483cab | |||
| 5123494e0e |
@@ -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`에서 추적한다.
|
||||||
|
|||||||
@@ -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`에서 추적한다.
|
||||||
|
|||||||
@@ -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`에서 추적한다.
|
||||||
|
|||||||
@@ -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`에서 추적한다.
|
||||||
|
|||||||
@@ -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 미지원 실패를 확인했다.
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
125
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-1-review.md
Normal file
125
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-1-review.md
Normal 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차 리뷰 완료로 갱신했다.
|
||||||
|
- **최종 결론:** 확정 발견 사항 없음.
|
||||||
|
- **남은 항목:** 없음.
|
||||||
79
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-2-review.md
Normal file
79
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-2-review.md
Normal 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 전환:** 전환 항목 없음.
|
||||||
|
- **최종 결론:** 확정 발견 사항 없음.
|
||||||
|
- **남은 항목:** 없음.
|
||||||
130
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-3-review.md
Normal file
130
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-3-review.md
Normal 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 전환:** 전환 항목 없음.
|
||||||
|
- **최종 결론:** 확정 발견 사항 없음.
|
||||||
|
- **남은 항목:** 없음.
|
||||||
101
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-4-review.md
Normal file
101
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-4-review.md
Normal 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 전환:** 전환 항목 없음.
|
||||||
|
- **최종 결론:** 확정 발견 사항 없음.
|
||||||
|
- **남은 항목:** 없음.
|
||||||
118
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md
Normal file
118
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-5-review.md
Normal 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` 보완 완료.
|
||||||
|
- **남은 항목:** 없음.
|
||||||
101
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-6-review.md
Normal file
101
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-6-review.md
Normal 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 전환:** 전환 항목 없음.
|
||||||
|
- **최종 결론:** 확정 발견 사항 없음.
|
||||||
|
- **남은 항목:** 없음.
|
||||||
82
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-7-review.md
Normal file
82
docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/reviews/phase-7-review.md
Normal 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 전환:** 전환 항목 없음.
|
||||||
|
- **최종 결론:** 확정 발견 사항 없음.
|
||||||
|
- **남은 항목:** 없음.
|
||||||
410
docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md
Normal file
410
docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md
Normal 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·검증 기록
|
||||||
|
```
|
||||||
171
docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md
Normal file
171
docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md
Normal 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` | 확정 | 새 통합 문서를 기준으로 만들고 충돌하는 기존 추천·전체 탭 문서에는 정정 기록을 누적한다 | 사용자 승인 | 관련 문서 전체 |
|
||||||
229
docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md
Normal file
229
docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md
Normal 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` 수정 완료.
|
||||||
|
- 남은 항목: 없음.
|
||||||
111
docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md
Normal file
111
docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md
Normal 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 판정 유지.
|
||||||
|
- 남은 항목: 없음.
|
||||||
170
docs/20260804_관리자정산크리에이터번호적용/plan-task.md
Normal file
170
docs/20260804_관리자정산크리에이터번호적용/plan-task.md
Normal 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 계약 교체 |
|
||||||
73
docs/20260804_관리자정산크리에이터번호적용/prd.md
Normal file
73
docs/20260804_관리자정산크리에이터번호적용/prd.md
Normal 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 |
|
||||||
90
docs/20260805_추천탭_배너_조회조건/plan-task.md
Normal file
90
docs/20260805_추천탭_배너_조회조건/plan-task.md
Normal 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 회귀로 직접 영향 범위를 검증했다.
|
||||||
71
docs/20260805_추천탭_배너_조회조건/prd.md
Normal file
71
docs/20260805_추천탭_배너_조회조건/prd.md
Normal 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` |
|
||||||
@@ -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개가 유지됨을 재확인했다.
|
||||||
@@ -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` |
|
||||||
293
docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md
Normal file
293
docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md
Normal 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` |
|
||||||
|
|
||||||
|
## 발견된 문제
|
||||||
|
|
||||||
|
없음.
|
||||||
144
docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/prd.md
Normal file
144
docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/prd.md
Normal 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` |
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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? {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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") })
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user