Test #464
@@ -4,12 +4,12 @@
|
|||||||
|
|
||||||
| 문서 항목 | 내용 |
|
| 문서 항목 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 상태 | Phase 16 및 P16-R1 시각 정밀도 회귀 수정·검증 완료 |
|
| 상태 | Phase 17 통합 개인정보 정리 구현·Gate 완료 |
|
||||||
| 작성일 | 2026-09-29 |
|
| 작성일 | 2026-09-29 |
|
||||||
| 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` |
|
| 요구사항 기준 | `docs/20260929_크리에이터_선물하기/prd.md` |
|
||||||
| API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` |
|
| API 기준 | `docs/20260929_크리에이터_선물하기/prd.md`의 `8. API 계약` |
|
||||||
| 현재 Phase | Phase 16 누락 푸시와 가격 스냅샷 검증 |
|
| 현재 Phase | Phase 17 통합 배송 개인정보 파기 확장 |
|
||||||
| 현재 활성 Goal | 없음. `P16-R1` 완료, CI 재실행·배포는 별도 |
|
| 현재 활성 Goal | 없음. `P17-T1`, `P17-GATE` 완료 |
|
||||||
|
|
||||||
## 목표
|
## 목표
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
| 14 | 완료 | `4/4` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 |
|
| 14 | 완료 | `4/4` | 완료 | 전체 테스트 제외, 운영 MySQL/배포/별도 프론트엔드 검증 범위 밖 |
|
||||||
| 15 | 서버 구현·Gate 완료 | `4/4` Task, `P15-R1` 완료 | 없음 | 운영 schema/reader 배포 확인은 별도 |
|
| 15 | 서버 구현·Gate 완료 | `4/4` Task, `P15-R1` 완료 | 없음 | 운영 schema/reader 배포 확인은 별도 |
|
||||||
| 16 | 기존 구현·회귀 수정 완료 | `2/2` Task, `P16-R1` 완료 | 없음 | 최신 전체2403개 통과, CI 재실행·배포 별도 |
|
| 16 | 기존 구현·회귀 수정 완료 | `2/2` Task, `P16-R1` 완료 | 없음 | 최신 전체2403개 통과, CI 재실행·배포 별도 |
|
||||||
|
| 17 | 구현·Gate 완료 | `1/1` | 없음 | 전체 2,904개 통과, 운영 DB·배포는 별도 |
|
||||||
|
|
||||||
- 동시에 하나의 미완료 goal만 운용한다.
|
- 동시에 하나의 미완료 goal만 운용한다.
|
||||||
- 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다.
|
- 구현 중 요구사항이 바뀌면 이 문서의 체크박스와 범위를 먼저 갱신한 뒤 코드를 수정한다.
|
||||||
@@ -2079,3 +2080,73 @@ P15-T1 문서 검증 → 별도 구현 승인 → P15-T2 → P15-T3 → P15-T4
|
|||||||
- 검증 명령: `./gradlew --no-daemon test --rerun --tests '*GiftCommandServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftAdminServiceTest' ktlintCheck`.
|
- 검증 명령: `./gradlew --no-daemon test --rerun --tests '*GiftCommandServiceTest' --tests '*AdminGiftControllerTest' --tests '*GiftAdminServiceTest' ktlintCheck`.
|
||||||
- 결과: exit 0, `BUILD SUCCESSFUL in 1m 33s`. 최신 JUnit XML 3개에서 137개 테스트, 실패·오류·skip 0을 확인했다. 테스트는 재실행됐으며 컴파일과 lint는 동일 입력의 UP-TO-DATE 결과다. `git diff --check`도 통과했다.
|
- 결과: exit 0, `BUILD SUCCESSFUL in 1m 33s`. 최신 JUnit XML 3개에서 137개 테스트, 실패·오류·skip 0을 확인했다. 테스트는 재실행됐으며 컴파일과 lint는 동일 입력의 UP-TO-DATE 결과다. `git diff --check`도 통과했다.
|
||||||
- 범위: 수령확인 시각 정밀도 수정의 커밋 검증으로 관련 서비스·HTTP·DB 왕복 회귀를 재실행했다. 전체 테스트와 패키징 빌드는 반복하지 않았으며 위 2,403개 결과는 이전 검증 기록이다. 운영 MySQL·Jenkins 재실행·배포는 미검증이다.
|
- 범위: 수령확인 시각 정밀도 수정의 커밋 검증으로 관련 서비스·HTTP·DB 왕복 회귀를 재실행했다. 전체 테스트와 패키징 빌드는 반복하지 않았으며 위 2,403개 결과는 이전 검증 기록이다. 운영 MySQL·Jenkins 재실행·배포는 미검증이다.
|
||||||
|
|
||||||
|
### Phase 17: 통합 배송 개인정보 파기 확장
|
||||||
|
|
||||||
|
**승인 기준:** 2026-11-01 사용자 승인, PRD `GIFT-018`, `8.16`, `11.1`, `DEC-023`. Phase 15/16의 완료 체크박스·당시 symbol·검증 이력은 보존한다. 새 계획/스케줄/DDL/API/의존성 없이 기존 :20 작업만 확장한다.
|
||||||
|
|
||||||
|
#### Task 17.1 상태별 보존기간과 통합 파기 회귀 구현
|
||||||
|
|
||||||
|
- [x] **Task 17.1 (`P17-T1`):** 전달완료·전달불가의 짝인 종료 시각으로부터 7일 지난 배송 개인정보를 기존 작업에서 동일하게 정리한다.
|
||||||
|
- **시작 조건:** 승인 정책과 기존 문서 갱신을 production 수정 전에 완료한다. 기존 `GiftSchedulerTest` 52/52 통과는 parent가 확인한 baseline이며 이번 실행의 RED/GREEN과 구분한다.
|
||||||
|
- **완료 증거:** 아래 TDD 체크박스, 실제 repository/H2 왕복, 관련 query/controller의 기존 assertion을 보존한 focused 통과와 실행 기록.
|
||||||
|
- **범위 밖:** 전체 suite·독립 review·최종 QA/Gate 판정(parent 소유), 새 scheduler·DDL·API·dependency·전역 설정, 취소·상태 전이·결제·푸시 수정, 운영 DB·배포·커밋.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `docs/20260929_크리에이터_선물하기/prd.md`, `docs/20260929_크리에이터_선물하기/plan-task.md`.
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftScheduler.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/persistence/GiftRepository.kt`.
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/adapter/out/scheduler/GiftSchedulerTest.kt`.
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftQueryServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/gift/application/GiftAdminQueryServiceTest.kt`.
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/gift/adapter/in/web/GiftControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/gift/adapter/in/web/AdminGiftControllerTest.kt`.
|
||||||
|
|
||||||
|
**Interfaces와 불변조건:**
|
||||||
|
|
||||||
|
- `(DELIVERED AND deliveredAt IS NOT NULL AND deliveredAt <= cutoff) OR (UNDELIVERABLE AND undeliverableAt IS NOT NULL AND undeliverableAt <= cutoff)`, `cutoff=now.minusDays(7)`. 상태와 다른 시각 fallback/`COALESCE` 없음, `CANCELED` 제외.
|
||||||
|
- `runGiftPersonalDataErasure()`, `eraseGiftPersonalData(now: LocalDateTime): Int`, `findGiftsWithPersonalDataRetentionExpired(cutoff: LocalDateTime): List<Gift>`로 모든 실제 호출자를 함께 rename한다.
|
||||||
|
- 상수 `GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY`의 Redis literal은 계속 `lock:gift-delivered-personal-data-erasure`. Asia/Seoul `0 20 * * * *` 한 작업과 transaction/unlock은 불변이다.
|
||||||
|
- 기존 12개 필드만 null 처리한다. `undeliverableAt`, `undeliverableReason` 및 다른 업무·결제·회원·동의·리뷰는 보존하며 파기 자체의 push/refund는 0회다.
|
||||||
|
|
||||||
|
- [x] **RED:** 기존 production 이름을 유지한 채 UNDELIVERABLE의 정확한 7일 경계 파기 테스트를 추가한다.
|
||||||
|
- [x] **RED 확인:** `./gradlew --no-daemon --console=plain test --tests '*GiftSchedulerTest.shouldEraseGiftWhenMatchingTimestampEqualsSevenDayCutoff'`로 미구현 assertion 실패를 확인했다. compilation/fixture 오류는 RED 증거가 아니다.
|
||||||
|
- [x] **GREEN:** 최소 JPQL 조건 확장과 내부 symbol/caller rename만 적용했다. 두 상태의 경계·과거 이력·null/다른 시각 배제·unsupported 상태·부분 파기·재실행·보존 검증을 기존 테스트 parameterization으로 보강했다.
|
||||||
|
- [x] **GREEN 확인:** 같은 테스트 2개 및 `GiftSchedulerTest` 전체 59개를 재실행해 성공을 확인했다.
|
||||||
|
- [x] **REFACTOR:** 이번 변경의 assertion을 재사용하며 query/controller의 두 종료 상태와 기존 실제 JWT HTTP 파기 전후 검증을 확장했다. `./gradlew --no-daemon --console=plain test --tests '*GiftSchedulerTest' --tests '*GiftQueryServiceTest' --tests '*GiftAdminQueryServiceTest' --tests '*GiftControllerTest' --tests '*AdminGiftControllerTest' ktlintCheck`가 통과했다. LSP unavailable이므로 Kotlin compilation으로 타입을 검증했다.
|
||||||
|
|
||||||
|
#### Phase 17 Gate
|
||||||
|
|
||||||
|
- [x] **`P17-GATE`:** 통합 파기 정책과 전체 회귀·독립 review·최종 QA를 parent가 판정했다.
|
||||||
|
- **시작 조건:** `P17-T1` 완료와 focused 증거 인계.
|
||||||
|
- **완료 증거:** parent가 전체 회귀 및 독립 review/최종 QA 결과를 별도 누적하고 성공 기준을 판정한다. 구현 담당의 focused 통과만으로 Gate를 체크하지 않는다.
|
||||||
|
- **범위 밖:** 테스트 완화/삭제, 비관련 수정, 운영 DB·배포·커밋.
|
||||||
|
- **검증:** 기존 :20 등록/lock literal/transaction-unlock 불변, 상태별 predicate와 지정 외 데이터 보존, 기존 HTTP schema·권한·종료 액션을 대조한다. 전체 suite는 구현 담당이 실행하지 않는다.
|
||||||
|
|
||||||
|
#### Phase 17 Progress — 2026-11-01
|
||||||
|
|
||||||
|
- 구현 전: 저장소 규칙과 sample, 기존 PRD/plan 및 모든 erasure symbol 호출자를 확인했다. PRD 요구사항/수용 기준/추적표에 확장 정책과 `DEC-023`을 추가하고 기존 완료 이력은 보존했다.
|
||||||
|
- 문서 작성 시점: `P17-T1` RED 대기. 독립 작업 디렉터리는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/gift-erasure-implementation.USRpP1`이며 parent가 인계 후 정리한다. 공유 cache/parent ledger는 변경하지 않는다.
|
||||||
|
- RED: 위 focused 명령 `BUILD FAILED in 39s`, 2개 중 DELIVERED 통과·UNDELIVERABLE 1개 assertion 실패, 오류/skip 0. `GiftSchedulerTest.kt:623`의 기존 `eraseDeliveredGiftPersonalData(ERASURE_NOW)` 반환값이 `expected: <1> but was: <0>`였다. production 소스는 이 확인까지 변경하지 않았다. 로그는 작업 디렉터리 `red.log`, assertion 원본은 당시 JUnit XML로 확인했다.
|
||||||
|
- 단독 GREEN: RED와 같은 focused 명령 `BUILD SUCCESSFUL in 57s`, 두 상태 2개 실패·오류·skip 0. 로그 `green-boundary.log`, RED assertion 기록 `red-evidence.md`를 보존한다.
|
||||||
|
- 중간 영향 회귀: 위 5개 클래스 명령의 첫 실행은 165개 중 2개 fixture 오류로 실패했다. `GiftControllerTest` parameterization에서 신청번호가 `varchar(20)`을 넘은 것이 원인이며 짧고 고유한 `ERASE-$giftStatus`로 fixture만 고쳤다. 업무 RED와 구분하며 production 스키마/validation은 바꾸지 않았다. 로그 `green-focused.log`.
|
||||||
|
- 최종 GREEN/REFACTOR: 위 5개 클래스 + `ktlintCheck` 명령 `BUILD SUCCESSFUL in 1m 30s`, exit 0. 최신 XML 5개에서 scheduler 59 + 사용자 query 21 + 관리자 query 9 + 사용자 controller 16 + 관리자 controller 60 = 165개, 실패·오류·skip 0. Kotlin main/test compilation과 lint 통과. 로그 `green-focused-final.log`.
|
||||||
|
- 실제 HTTP: `AdminGiftControllerTest.shouldEraseGiftAndPreserveRealJwtHttpContracts`의 두 상태 모두 RANDOM_PORT Tomcat/실제 JWT로 파기 전후 SENT/RECEIVED/admin 상세·목록 200, 익명 401·타인 거부, 빈 문자열/null/닉네임/빈 액션/CTA 미노출과 UTC 종료 시각을 확인했다. DB 재조회로 12개 null, 최초 1건·재실행 0건, 결제·동의·리뷰·이력 및 전달불가 사유 불변을 확인했다. `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.admin.gift.adapter.in.web.AdminGiftControllerTest.xml`의 `P17_HTTP`/`P17_DB`와 `build/reports/tests/test/index.html`에 증거가 있다.
|
||||||
|
- 검증 경계: scheduler/query `@DataJpaTest`가 실제 datasource를 기본 embedded H2로 대체함을 로그에서 확인했다. 실제 운영 MySQL 또는 MySQL-mode 검증으로 부르지 않는다. HTTP 테스트의 H2 MySQL-mode와도 구분한다. 기존 비관련 scheduler의 H2 `SKIP LOCKED`·JSON/Netty/Security·Gradle 경고는 범위 밖으로 유지했다.
|
||||||
|
- 현재 상태: `P17-T1` 완료, `P17-GATE` 대기. 전체 suite·별도 packaging build·독립 review·최종 QA는 parent 소유이므로 실행하지 않았다. 운영 DB/배포/실제 FCM/Redis 다중 인스턴스/커밋·푸시는 수행하지 않았다. LSP 부재는 컴파일로 대체했다.
|
||||||
|
- 문서/범위 확인: 최종 `git diff --check` 통과. 변경은 위 문서 2개·production 2개·관련 test 5개뿐이며 `src`의 old erasure symbol은 0건이다. 기존 완료 이력과 Redis literal·정리 본문·transaction/unlock의 보존을 diff로 대조했다. test worker/Redis 잔류는 없고 작업 전부터 실행된 GradleDaemon PID 6061(12:11 시작)은 보존했다.
|
||||||
|
|
||||||
|
#### Phase 17 최종 Gate — 2026-11-01
|
||||||
|
|
||||||
|
- 직접 QA: lead가 `./gradlew --no-daemon --console=plain test --tests '*AdminGiftControllerTest.shouldEraseGiftAndPreserveRealJwtHttpContracts' --tests '*GiftSchedulerTest' ktlintCheck` 실행, exit 0, `BUILD SUCCESSFUL in 50s`. scheduler 59개 + 실제 JWT HTTP 2개 = 61개 실패·오류·skip 0. XML의 `P17_HTTP` 4행/`P17_DB` 2행으로 두 상태 모두 삭제 전후 HTTP 200, 개인정보 12개 null, 최초 1건·재실행 0건 및 업무 데이터 보존을 확인했다.
|
||||||
|
- 전체 검증: `./gradlew --no-daemon --console=plain test --rerun ktlintCheck build --info`를 필터 없이 1회 실행했다. exit 0, `BUILD SUCCESSFUL in 10m 9s`, 최신 JUnit XML 335개에서 2,904개 테스트 실패·오류·skip 0. lead도 별도 XML parser로 합계를 재확인했다. `:test`는 실제 재실행됐고 컴파일·lint task는 동일 입력의 UP-TO-DATE 결과다. clean 재컴파일로 표현하지 않는다.
|
||||||
|
- 빌드: `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 생성(148,263,130 bytes), 변경한 scheduler/repository class 포함 확인. SHA-256 `b4d54f9943004f90d77bb4a26e84417a16a17b69f9af85fa41f8c5678fa03dda`. 별도 JAR 기동은 하지 않았으며 실제 HTTP는 RANDOM_PORT Spring Boot로 검증했다.
|
||||||
|
- 리뷰: 단일 독립 reviewer가 전체 diff와 연결된 query/DTO/controller/인증/종료 상태·취소 경로를 읽고 APPROVE, 확정 결함·차단 사항 없음. 기록: `reviews/phase17-personal-data-erasure-review.md`. 운영 코드 추가 수정은 없었다.
|
||||||
|
- 증거: `build/reports/tests/test/index.html`, `build/test-results/test/TEST-*.xml`; 관리자 XML의 `P17_HTTP`/`P17_DB`는 전체 실행에서도 두 상태 모두 통과했다. 기존 자동취소 테스트와 예약 등록 검사도 전체 회귀에 포함된다.
|
||||||
|
- 한계: LSP 도구 없음으로 기존 Kotlin compile 및 ktlint 검증을 사용했다. H2 기반 검증이며 운영 MySQL/기존 nullable ALTER 적용, 실 Redis 다중 인스턴스, FCM 기기 수신·배포는 검증하지 않았다. 이미 7일 지난 과거 전달불가 건은 배포 후 적격 실행에서 삭제된다. 혼합 버전 중 구버전이 락을 먼저 얻으면 해당 회차는 전달완료만 처리할 수 있다.
|
||||||
|
- 자원 확인: 전체 실행의 wrapper 23146·daemon 23158·test worker 23261·Redis 23433 종료 확인. 기존 사용자 GradleDaemon 6061은 보존한다. 소유 임시 root 정리 결과는 아래에 누적한다. 커밋·스테이징·푸시·운영 데이터 조작 없음.
|
||||||
|
- 정리 완료: 최종 프로세스 재조회에서 기존 daemon 6061만 남았다. 소유 root `gift-erasure.Q703u2`, `gift-erasure-implementation.USRpP1`, `gift-erasure-verification.cDMqxU`, `gift-erasure-review.IWO3vO`의 소유 산출물과 비심볼릭링크 여부를 확인하고 삭제 후 네 경로 부재를 확인했다. 최초 정리 명령은 zsh 예약 변수 `path` 때문에 `rm`을 찾지 못해 삭제하지 않았으며, 변수명을 수정한 재실행으로 정상 정리했다. 공유 cache와 최신 build/JUnit/HTTP 산출물은 보존했다. 최종 문서 공백 검사와 신규 리뷰 기록 확인 후 종료한다.
|
||||||
|
|
||||||
|
#### Phase 17 커밋 전 검증 — 2026-11-01
|
||||||
|
|
||||||
|
- 실행: `./gradlew --no-daemon --console=plain test --rerun --tests '*GiftSchedulerTest' --tests '*GiftQueryServiceTest' --tests '*GiftAdminQueryServiceTest' --tests '*GiftControllerTest' --tests '*AdminGiftControllerTest' ktlintCheck`.
|
||||||
|
- 결과: exit 0, `BUILD SUCCESSFUL in 1m 24s`. 최신 JUnit XML 5개에서 165개 테스트, 실패·오류·skip 0을 확인했다. 테스트는 실제 재실행됐으며 컴파일·lint는 동일 입력의 UP-TO-DATE 결과다. `git diff --check`와 `src`의 이전 개인정보 정리 symbol 잔존 0건도 확인했다.
|
||||||
|
- 범위: 선물 개인정보 정리 변경의 직접 영향 범위만 재검증했다. 전체 suite·패키징 빌드는 위 최종 Gate 이후 코드 추가 변경이 없어 반복하지 않았다. 2,904개 전체 통과는 이전 실행 기록이며 이번 실행의 최신 XML/HTML은 165개 focused 결과다. 운영 MySQL·실제 Redis 다중 인스턴스·배포는 미검증이다.
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 문서 상태 | Phase 16 및 수령확인 시각 정밀도 회귀 수정·검증 완료 |
|
| 문서 상태 | Phase 17 통합 개인정보 정리 구현·검증 완료 |
|
||||||
| 작성일 | 2026-09-29 |
|
| 작성일 | 2026-09-29 |
|
||||||
| 최종 수정일 | 2026-10-26 |
|
| 최종 수정일 | 2026-11-01 |
|
||||||
| 대상 제품 | 크리에이터 선물하기 |
|
| 대상 제품 | 크리에이터 선물하기 |
|
||||||
| 작성자·결정권자 | 사용자 |
|
| 작성자·결정권자 | 사용자 |
|
||||||
| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 |
|
| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 |
|
||||||
@@ -258,9 +258,9 @@ INSPECTION_COMPLETED -> UNDELIVERABLE
|
|||||||
| `GIFT-015` | 확정 | 관리자는 전체 선물함 목록과 상세를 조회하고 현재 상태에서 가능한 운영 액션을 확인할 수 있다 | 목록은 상태/신청번호/닉네임 검색과 페이징을 지원하고, 상세는 발송인/수취인 개인정보와 상품/배송/상태 정보를 내려준다 | `P10` |
|
| `GIFT-015` | 확정 | 관리자는 전체 선물함 목록과 상세를 조회하고 현재 상태에서 가능한 운영 액션을 확인할 수 있다 | 목록은 상태/신청번호/닉네임 검색과 페이징을 지원하고, 상세는 발송인/수취인 개인정보와 상품/배송/상태 정보를 내려준다 | `P10` |
|
||||||
| `GIFT-016` | 확정 | 관리자는 운송장 등록 전 접수된 선물을 취소할 수 있다 | 관리자만 호출할 수 있고 `RECEIVED`에서만 `CANCELED`로 전이하며 `GiftDelivery.canceledAt`을 저장하고 발신자에게 사용 캔 전액을 리워드캔으로 환불한다 | `P14-T2` |
|
| `GIFT-016` | 확정 | 관리자는 운송장 등록 전 접수된 선물을 취소할 수 있다 | 관리자만 호출할 수 있고 `RECEIVED`에서만 `CANCELED`로 전이하며 `GiftDelivery.canceledAt`을 저장하고 발신자에게 사용 캔 전액을 리워드캔으로 환불한다 | `P14-T2` |
|
||||||
| `GIFT-017` | 확정 | 관리자 선물함 목록과 상세는 현재 가능한 액션으로 취소 가능 여부를 함께 제공한다 | `RECEIVED`에서만 `availableActions=["CANCEL"]`; 다른 상태의 기존 액션은 유지한다 | `P14-T3` |
|
| `GIFT-017` | 확정 | 관리자 선물함 목록과 상세는 현재 가능한 액션으로 취소 가능 여부를 함께 제공한다 | `RECEIVED`에서만 `availableActions=["CANCEL"]`; 다른 상태의 기존 액션은 유지한다 | `P14-T3` |
|
||||||
| `GIFT-018` | 확정 | 전달 완료 후 7일이 지난 선물의 배송 개인정보를 지운다 | 실행 시 기존 업무 시각 관례의 `now`에 대해 `status=DELIVERED`, `deliveredAt != null`, `deliveredAt <= now.minusDays(7)`인 과거 누적 건과 이후 조건을 충족하는 모든 건의 `GiftDelivery` 이름/연락처/우편번호/주소/상세주소, 택배사명/운송장번호 12개 필드를 null 처리한다. 식별자·상태·회원 닉네임·결제·동의·리뷰와 업무 lifecycle/deadline/reminder 시각은 보존하며 재실행은 추가 변경 없이 끝난다 | `P15-T2`, `P15-T3` |
|
| `GIFT-018` | 확정 | 전달 완료 또는 전달불가 후 7일이 지난 선물의 배송 개인정보를 지운다 | 기존 업무 시각 관례의 `now`에서 `cutoff=now.minusDays(7)`로 판단한다. `DELIVERED`와 non-null `deliveredAt <= cutoff` 또는 `UNDELIVERABLE`과 non-null `undeliverableAt <= cutoff`가 짝을 이루는 과거 누적 및 이후 적격 건의 기존 배송 개인정보 12개만 null 처리한다. 다른 시각 fallback 없이 `CANCELED`와 비대상 상태는 제외하며 식별자·상태·회원·결제·동의·리뷰·업무 시각·전달불가 사유는 보존한다. 재실행은 추가 변경 0건이다 | `P15-T2`, `P15-T3`, `P17-T1`, `P17-GATE` |
|
||||||
| `GIFT-019` | 확정 | 배송 개인정보를 nullable persistence로 저장한다 | `GiftDelivery`의 sender 다섯 Kotlin property는 모두 `var String?`; 현행 NOT NULL sender DB 열 네 개만 nullable로 완화한다. `sender_address_detail`은 이미 nullable이며 ALTER하지 않는다. 기존 CREATE 정의는 유지하고 `gift-schema.sql` 끝에 추가된 ALTER가 fresh install 최종 schema에도 적용된다 | `P15-T1`, `P15-T2` |
|
| `GIFT-019` | 확정 | 배송 개인정보를 nullable persistence로 저장한다 | `GiftDelivery`의 sender 다섯 Kotlin property는 모두 `var String?`; 현행 NOT NULL sender DB 열 네 개만 nullable로 완화한다. `sender_address_detail`은 이미 nullable이며 ALTER하지 않는다. 기존 CREATE 정의는 유지하고 `gift-schema.sql` 끝에 추가된 ALTER가 fresh install 최종 schema에도 적용된다 | `P15-T1`, `P15-T2` |
|
||||||
| `GIFT-020` | 확정 | 개인정보 null 상태에서도 기존 사용자/관리자 API 계약을 유지한다 | 기존 request 필수 타입/validation, response schema/바깥 객체/visibility와 flags/deadlines 계산 규칙을 유지한다. 데이터 없는 non-null 문자열은 `""`; 주소 전체가 null이면 주소 문자열은 정확히 `""`. nullable 바깥 객체와 tracking/admin courier/tracking은 기존 null 정책 유지 | `P15-T2`, `P15-GATE` |
|
| `GIFT-020` | 확정 | 개인정보 null 상태에서도 기존 사용자/관리자 API 계약을 유지한다 | 기존 request 필수 타입/validation, response schema/바깥 객체/visibility와 flags/deadlines 계산 규칙을 유지한다. 데이터 없는 non-null 문자열은 `""`; 주소 전체가 null이면 주소 문자열은 정확히 `""`. nullable 바깥 객체와 tracking/admin courier/tracking은 기존 null 정책 유지 | `P15-T2`, `P15-GATE`, `P17-T1`, `P17-GATE` |
|
||||||
| `GIFT-021` | 확정 | 배송지 기한 만료와 deadline reminder 업무 메서드를 시간별 스케줄로 등록한다 | 기존 매시 00분 자동취소 등록은 유지한다. Asia/Seoul 기준 매시 10분은 기존 recipient expiry, 20분은 신규 erasure, 40분은 기존 reminders를 각자 독립 `@Scheduled`/lock/transaction wrapper로 위임한다. 기존 업무 로직은 바꾸지 않고 작업 간 비중첩을 보장하지 않는다 | `P15-T3`, `P15-T4` |
|
| `GIFT-021` | 확정 | 배송지 기한 만료와 deadline reminder 업무 메서드를 시간별 스케줄로 등록한다 | 기존 매시 00분 자동취소 등록은 유지한다. Asia/Seoul 기준 매시 10분은 기존 recipient expiry, 20분은 신규 erasure, 40분은 기존 reminders를 각자 독립 `@Scheduled`/lock/transaction wrapper로 위임한다. 기존 업무 로직은 바꾸지 않고 작업 간 비중첩을 보장하지 않는다 | `P15-T3`, `P15-T4` |
|
||||||
|
|
||||||
## 8. API 계약
|
## 8. API 계약
|
||||||
@@ -932,19 +932,21 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다.
|
|||||||
스케줄러는 중복 실행되어도 같은 선물에 중복 환불 또는 중복 푸시가 발생하지 않아야 한다.
|
스케줄러는 중복 실행되어도 같은 선물에 중복 환불 또는 중복 푸시가 발생하지 않아야 한다.
|
||||||
배송지 입력 기한과 현재 시각이 같으면 아직 기한 초과가 아니며, `RECEIVED`, `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 상태는 배송지 미입력 전달불가 대상에서 제외한다.
|
배송지 입력 기한과 현재 시각이 같으면 아직 기한 초과가 아니며, `RECEIVED`, `DELIVERED`, `UNDELIVERABLE`, `CANCELED` 상태는 배송지 미입력 전달불가 대상에서 제외한다.
|
||||||
|
|
||||||
### 8.16 전달 완료 배송 개인정보 정리
|
### 8.16 통합 배송 개인정보 정리
|
||||||
|
|
||||||
- 매 실행 시각 `now` 기준 `Gift.status=DELIVERED`, `GiftDelivery.deliveredAt != null`, `deliveredAt <= now.minusDays(7)`인 모든 건을 처리한다. 7일 경계와 그 이전의 과거 누적 건을 포함한다.
|
- 매 실행 시각 `now`에서 `cutoff=now.minusDays(7)`로 판단한다. `(Gift.status=DELIVERED AND deliveredAt != null AND deliveredAt <= cutoff) OR (Gift.status=UNDELIVERABLE AND undeliverableAt != null AND undeliverableAt <= cutoff)`인 모든 건을 처리한다. 두 상태 모두 정확한 7일 경계와 과거 누적 건을 포함한다.
|
||||||
|
- 각 상태와 짝인 시각만 사용한다. 짝인 시각이 null이거나 아직 7일 미경과면 다른 시각이 오래됐어도 처리하지 않으며 `COALESCE` fallback을 두지 않는다. `CANCELED`를 비롯한 다른 상태는 제외한다.
|
||||||
- `GiftDelivery`의 다음 12개 값만 null로 지운다: `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `courierCompanyName`, `trackingNumber`.
|
- `GiftDelivery`의 다음 12개 값만 null로 지운다: `senderName`, `senderPhoneNumber`, `senderZipCode`, `senderAddress`, `senderAddressDetail`, `recipientName`, `recipientPhoneNumber`, `recipientZipCode`, `recipientAddress`, `recipientAddressDetail`, `courierCompanyName`, `trackingNumber`.
|
||||||
- 기존 입력 request의 필수/non-null 계약과 검증은 유지한다. 공개 DTO 스키마와 바깥 nullable 객체는 유지하고, 기존 non-null 응답 문자열에서 데이터가 없으면 `""`로 표현한다. 주소는 완전 삭제 상태에서 정확히 빈 문자열이며 `"() "`, `"(null) null"`, 문자열 `"null"`을 만들지 않는다.
|
- 기존 입력 request의 필수/non-null 계약과 검증은 유지한다. 공개 DTO 스키마와 바깥 nullable 객체는 유지하고, 기존 non-null 응답 문자열에서 데이터가 없으면 `""`로 표현한다. 주소는 완전 삭제 상태에서 정확히 빈 문자열이며 `"() "`, `"(null) null"`, 문자열 `"null"`을 만들지 않는다.
|
||||||
- `SENT`의 `senderInfo` 객체는 유지하고 각 문자열은 빈 문자열로 응답한다. `RECEIVED`의 `senderInfo`는 계속 null이다. 모든 받는 주소 값이 null이면 `recipientAddress`는 null이다. 사용자 tracking과 관리자 courier/tracking nullable 값은 null이다.
|
- `SENT`의 `senderInfo` 객체는 유지하고 각 문자열은 빈 문자열로 응답한다. `RECEIVED`의 `senderInfo`는 계속 null이다. 모든 받는 주소 값이 null이면 `recipientAddress`는 null이다. 사용자 tracking과 관리자 courier/tracking nullable 값은 null이다.
|
||||||
- 기존 `recipientAddressRegistered`는 실제 필드 완비 여부를 따르므로 삭제 뒤 false다. 별도 등록 이력을 만들지 않는다. `DELIVERED` 상태, 가시성, 기존 derived flags/deadlines, 관리자 액션(빈 목록)을 유지해 재입력/운영 액션을 요구하지 않는다.
|
- 기존 `recipientAddressRegistered`는 실제 필드 완비 여부를 따르므로 삭제 뒤 false다. 별도 등록 이력을 만들지 않는다. `DELIVERED`/`UNDELIVERABLE` 상태, 가시성, 기존 derived flags/deadlines, 관리자 액션(빈 목록)을 유지해 재입력/운영 액션을 요구하지 않는다.
|
||||||
- 발신/수신 배송 개인정보만 지운다. Member nickname/profile, Gift 및 GiftDelivery 식별자·status, 결제, 동의 여부/시각, 리뷰, `createdAt`, 업무 lifecycle/deadline/reminder 시각은 보존한다. `updatedAt`은 기존 `BaseEntity @PreUpdate` 동작을 따른다.
|
- 발신/수신 배송 개인정보만 지운다. Member nickname/profile, Gift 및 GiftDelivery 식별자·status, 결제, 동의 여부/시각, 리뷰, `createdAt`, 업무 lifecycle/deadline/reminder 시각 및 `undeliverableReason`은 보존한다. `updatedAt`은 기존 `BaseEntity @PreUpdate` 동작을 따른다.
|
||||||
- 비대상 상태, null `deliveredAt`, 아직 7일이 안 된 건은 유지한다. 부분 삭제된 적격 건은 나머지를 지우며 완전 null 건의 재실행 변경 수는 0이다.
|
- 비대상 상태, 상태와 짝인 null 시각, 아직 7일이 안 된 건은 유지한다. 부분 삭제된 적격 건은 나머지를 지우며 완전 null 건의 재실행 변경 수는 0이다.
|
||||||
- 이 작업은 푸시/환불/상태 전이를 추가하지 않는다. 앱 서버 내 DB 배송정보 정리이며 백업·로그·캐시의 삭제를 뜻하지 않는다.
|
- 이 작업은 푸시/환불/상태 전이를 추가하지 않는다. 앱 서버 내 DB 배송정보 정리이며 백업·로그·캐시의 삭제를 뜻하지 않는다.
|
||||||
- 기존 자동취소만 이미 `:00`에 예약되어 있다. 다음 3개 업무 메서드를 별도 Asia/Seoul 시간별 wrapper로 등록한다. 배송지 만료 `0 10 * * * *`, 개인정보 정리 `0 20 * * * *`, 기한 안내 `0 40 * * * *`. 업무 로직은 기존 메서드 또는 새 정리 메서드로 위임한다.
|
- 기존 자동취소만 이미 `:00`에 예약되어 있다. 다음 3개 업무 메서드를 별도 Asia/Seoul 시간별 wrapper로 등록한다. 배송지 만료 `0 10 * * * *`, 개인정보 정리 `0 20 * * * *`, 기한 안내 `0 40 * * * *`. 업무 로직은 기존 메서드 또는 새 정리 메서드로 위임한다.
|
||||||
- 세 신규 wrapper는 `tryLock(0, -1, SECONDS)`, `@Transactional`과 서로 다른 lock key를 사용한다. 획득한 lock은 `finally`에서 해제를 예약하고 DB commit/rollback 이후 `afterCompletion`에서 소유권 확인 후 해제한다. transaction 없는 직접 호출에서는 업무 종료 후 해제한다. 기존 `:00` 취소 wrapper 및 key는 변경하지 않는다. 다른 작업 간 비중첩은 보장하지 않는다.
|
- 세 신규 wrapper는 `tryLock(0, -1, SECONDS)`, `@Transactional`과 서로 다른 lock key를 사용한다. 획득한 lock은 `finally`에서 해제를 예약하고 DB commit/rollback 이후 `afterCompletion`에서 소유권 확인 후 해제한다. transaction 없는 직접 호출에서는 업무 종료 후 해제한다. 기존 `:00` 취소 wrapper 및 key는 변경하지 않는다. 다른 작업 간 비중첩은 보장하지 않는다.
|
||||||
- `LocalDateTime`에 KST를 자동 변환하지 않는다. 기존 작업의 clock convention을 따르고 고정 `now`를 전달해 판단한다.
|
- `LocalDateTime`에 KST를 자동 변환하지 않는다. 기존 작업의 clock convention을 따르고 고정 `now`를 전달해 판단한다.
|
||||||
|
- Phase 17은 기존 Asia/Seoul `0 20 * * * *` 작업 하나를 확장한다. 내부 이름은 `runGiftPersonalDataErasure()`, `eraseGiftPersonalData(now: LocalDateTime): Int`, `findGiftsWithPersonalDataRetentionExpired(cutoff: LocalDateTime): List<Gift>`로 통합한다. 상수 symbol은 `GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY`로 바꾸되 Redis literal `lock:gift-delivered-personal-data-erasure`와 transaction/unlock 처리는 유지한다. 새 스케줄·DDL·API 계약은 추가하지 않는다.
|
||||||
|
|
||||||
## 9. 푸시 알림 정책
|
## 9. 푸시 알림 정책
|
||||||
|
|
||||||
@@ -1070,11 +1072,12 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다.
|
|||||||
- 내부 운영 화면에서는 필요한 권한을 가진 사용자에게만 개인정보를 노출한다.
|
- 내부 운영 화면에서는 필요한 권한을 가진 사용자에게만 개인정보를 노출한다.
|
||||||
- 약관 동의 여부와 동의시각은 선물 신청 건 감사 근거로 보존한다.
|
- 약관 동의 여부와 동의시각은 선물 신청 건 감사 근거로 보존한다.
|
||||||
|
|
||||||
### 11.1 전달 완료 배송 개인정보 수명주기
|
### 11.1 통합 배송 개인정보 수명주기
|
||||||
|
|
||||||
- 정리 범위는 선물 도메인 DB의 지정된 `GiftDelivery` 열 12개다. 배송지 입력 당시 사용한 요청 필드와 검증 규칙은 그대로 필수다.
|
- 정리 범위는 선물 도메인 DB의 지정된 `GiftDelivery` 열 12개다. 배송지 입력 당시 사용한 요청 필드와 검증 규칙은 그대로 필수다.
|
||||||
|
- 보존기간은 `DELIVERED`의 `deliveredAt`, `UNDELIVERABLE`의 `undeliverableAt` 각각으로부터 7일이다. 정확한 경계를 포함하며 짝인 시각이 null인 이력은 다른 시각으로 대체하지 않는다. `CANCELED`는 이번 확장의 대상이 아니다.
|
||||||
- 이 정리는 회원 계정, `Member` nickname/profile, Gift 신청/상태/ID, 캔 결제 내역, 약관 동의 snapshot, 리뷰를 삭제하지 않는다.
|
- 이 정리는 회원 계정, `Member` nickname/profile, Gift 신청/상태/ID, 캔 결제 내역, 약관 동의 snapshot, 리뷰를 삭제하지 않는다.
|
||||||
- `GiftDelivery`의 업무 시각 및 기한은 운영 이력으로 보존한다. `createdAt`은 유지하고 `updatedAt`은 공통 entity auditing 규칙대로 갱신될 수 있다.
|
- `GiftDelivery`의 업무 시각 및 기한과 `undeliverableReason`은 운영 이력으로 보존한다. `createdAt`은 유지하고 `updatedAt`은 공통 entity auditing 규칙대로 갱신될 수 있다.
|
||||||
- 이 정책은 백업, 애플리케이션/인프라 로그, 외부 관측 시스템, 캐시의 삭제나 보존기간을 정의하지 않는다. 전체 개인정보 삭제 완료로 설명하지 않는다.
|
- 이 정책은 백업, 애플리케이션/인프라 로그, 외부 관측 시스템, 캐시의 삭제나 보존기간을 정의하지 않는다. 전체 개인정보 삭제 완료로 설명하지 않는다.
|
||||||
- 정리 작업 자체는 상태 전이·환불·알림을 일으키지 않는다. 기존 업무 정책이 정리 task를 통해 달라지지 않도록 한다.
|
- 정리 작업 자체는 상태 전이·환불·알림을 일으키지 않는다. 기존 업무 정책이 정리 task를 통해 달라지지 않도록 한다.
|
||||||
- `GiftDelivery`의 sender 다섯 Kotlin property를 nullable로 선언한다. `sender_address_detail`은 이미 nullable이다. 현재 NOT NULL인 `sender_name`, `sender_phone_number`, `sender_zip_code`, `sender_address`만 schema에서 nullable로 바꾼다.
|
- `GiftDelivery`의 sender 다섯 Kotlin property를 nullable로 선언한다. `sender_address_detail`은 이미 nullable이다. 현재 NOT NULL인 `sender_name`, `sender_phone_number`, `sender_zip_code`, `sender_address`만 schema에서 nullable로 바꾼다.
|
||||||
@@ -1098,11 +1101,11 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다.
|
|||||||
- 관리자 목록과 상세 모두 `RECEIVED`에서만 `CANCEL` 액션을 제공하며 기존 `COMPLETE_DELIVERY` 조건부 액션을 포함한 나머지 상태 매핑은 유지한다.
|
- 관리자 목록과 상세 모두 `RECEIVED`에서만 `CANCEL` 액션을 제공하며 기존 `COMPLETE_DELIVERY` 조건부 액션을 포함한 나머지 상태 매핑은 유지한다.
|
||||||
- 카테고리 `classificationNumber`, `receiptCode`, `representativeItem`의 관리자 등록/수정/조회와 사용자 폼 옵션의 `분류명/대표품목` 조합을 테스트한다.
|
- 카테고리 `classificationNumber`, `receiptCode`, `representativeItem`의 관리자 등록/수정/조회와 사용자 폼 옵션의 `분류명/대표품목` 조합을 테스트한다.
|
||||||
- 신청번호는 카테고리+날짜별 sequence, 4자리 padding, `9999` 초과 자리수 증가, 병렬 unique를 테스트한다.
|
- 신청번호는 카테고리+날짜별 sequence, 4자리 padding, `9999` 초과 자리수 증가, 병렬 unique를 테스트한다.
|
||||||
- 개인정보 정리는 7일 경계 포함/1초 미경과 제외, 더 오래된 이력 포함, null 완료시각 및 비전달완료 상태 제외, 부분 삭제/완전 삭제 재실행 0건을 실제 JPA 저장·flush/clear/reload로 검증한다. 보호할 결제/동의/리뷰/회원/업무 시각도 재조회해 불변임을 확인한다.
|
- 개인정보 정리는 `DELIVERED`/`UNDELIVERABLE` 각각의 7일 경계 포함/1초 미경과 제외, 오래된 이력 포함, 짝인 null 시각 제외, 다른 시각이 오래돼도 짝인 null/최근 시각을 대체하지 않음, `CANCELED`와 다른 비대상 상태 보존, 부분 삭제/완전 삭제 재실행 0건을 실제 JPA 저장·flush/clear/reload로 검증한다. 결제/동의/리뷰/회원/업무 시각과 `undeliverableReason`도 재조회해 불변임을 확인한다.
|
||||||
- `GiftDelivery` 매핑은 sender 다섯 property 모두 null 저장을 허용해야 한다. DDL review는 네 nonnull sender columns만 변경하고 기존 `gift-schema.sql` trailing ALTER를 확인한다.
|
- `GiftDelivery` 매핑은 sender 다섯 property 모두 null 저장을 허용해야 한다. DDL review는 네 nonnull sender columns만 변경하고 기존 `gift-schema.sql` trailing ALTER를 확인한다.
|
||||||
- 사용자 및 관리자 query/controller는 삭제 후 기존 DTO 형태, 빈 non-null 문자열의 정확한 주소 형식, nullable 필드, nickname, `DELIVERED`의 액션/CTA 미노출을 검증한다.
|
- 사용자 및 관리자 query/controller는 두 대상 상태의 삭제 후 기존 DTO 형태, 빈 non-null 문자열의 정확한 주소 형식, nullable 필드, nickname, 액션/CTA 미노출 및 전달불가 시각/사유 보존을 검증한다. 기존 실제 JWT HTTP의 인증/권한과 완전 일치 assertion을 유지한다.
|
||||||
- 실제 `GiftRepository` query를 기존 `@DataJpaTest`의 embedded H2에서 실행한다. 실제 datasource replacement를 확인해 MySQL-mode/운영 MySQL 검증과 구분한다. 고정 `now`에서 cutoff 동일 시각은 포함하고 cutoff보다 1초 늦은 시각은 제외한다.
|
- 실제 `GiftRepository` query를 기존 `@DataJpaTest`의 embedded H2에서 실행한다. 실제 datasource replacement를 확인해 MySQL-mode/운영 MySQL 검증과 구분한다. 고정 `now`에서 cutoff 동일 시각은 포함하고 cutoff보다 1초 늦은 시각은 제외한다.
|
||||||
- 오래된 과거 이력, null `deliveredAt`, 다른 Gift status도 각 fixture에서 독립 확인한다. 미래에 cutoff를 지나는 데이터도 매번 조회되어야 하며 첫 정리에서만 처리되는 신규 데이터 제한을 두지 않는다.
|
- 오래된 과거 이력, null `deliveredAt`/`undeliverableAt`, 다른 Gift status도 각 fixture에서 독립 확인한다. 미래에 cutoff를 지나는 데이터도 매번 조회되어야 하며 첫 정리에서만 처리되는 신규 데이터 제한을 두지 않는다.
|
||||||
- 정리 전후 DB를 flush, clear, reload해 null 저장이 실제 반영됐는지 확인한다. 재실행 후 반환 변경 수가 0인지 확인한다.
|
- 정리 전후 DB를 flush, clear, reload해 null 저장이 실제 반영됐는지 확인한다. 재실행 후 반환 변경 수가 0인지 확인한다.
|
||||||
- 매핑 test는 12개 필드를 모두 null로 저장할 수 있음을 확인하되 지정 외 상태 시각 필드는 그대로 남는지 검증한다.
|
- 매핑 test는 12개 필드를 모두 null로 저장할 수 있음을 확인하되 지정 외 상태 시각 필드는 그대로 남는지 검증한다.
|
||||||
- controller 테스트는 기존 JSON object shape를 비교하고 사용자 요청 DTO 필수 validation이 유지되는지 확인한다.
|
- controller 테스트는 기존 JSON object shape를 비교하고 사용자 요청 DTO 필수 validation이 유지되는지 확인한다.
|
||||||
@@ -1123,6 +1126,7 @@ Response `data`는 기존 `AdminGiftOperationStatusResponse`를 사용한다.
|
|||||||
- [ ] 카테고리 추가 필드와 새 신청번호 정책이 관리자 API, 사용자 폼 옵션, 선물 신청에 반영된다.
|
- [ ] 카테고리 추가 필드와 새 신청번호 정책이 관리자 API, 사용자 폼 옵션, 선물 신청에 반영된다.
|
||||||
- [x] 관리자 취소는 `RECEIVED`에서만 전액 환불과 함께 성공하고, 목록/상세의 `CANCEL` 액션은 상태에 맞게 제공된다.
|
- [x] 관리자 취소는 `RECEIVED`에서만 전액 환불과 함께 성공하고, 목록/상세의 `CANCEL` 액션은 상태에 맞게 제공된다.
|
||||||
- [x] 7일 이상 지난 전달 완료 건의 지정된 배송 개인정보만 DB에서 지워지고 기존 업무 데이터와 API 계약은 유지된다. 격리 DB 및 실제 HTTP로 검증했다. (`GIFT-018`, `P15-GATE`)
|
- [x] 7일 이상 지난 전달 완료 건의 지정된 배송 개인정보만 DB에서 지워지고 기존 업무 데이터와 API 계약은 유지된다. 격리 DB 및 실제 HTTP로 검증했다. (`GIFT-018`, `P15-GATE`)
|
||||||
|
- [x] 전달불가 후 7일인 건도 같은 작업에서 정리하며 상태별 짝인 시각·12개 필드·보존 계약과 사용자/관리자 응답을 검증했다. (`GIFT-018`, `DEC-023`, `P17-T1`, `P17-GATE`)
|
||||||
- [x] 다섯 sender property nullable mapping 및 네 DB 열의 ALTER 문서가 schema 요구를 충족한다. 운영 적용은 별도다. (`GIFT-019`, `P15-T1`, `P15-T2`)
|
- [x] 다섯 sender property nullable mapping 및 네 DB 열의 ALTER 문서가 schema 요구를 충족한다. 운영 적용은 별도다. (`GIFT-019`, `P15-T1`, `P15-T2`)
|
||||||
- [x] 삭제 후 빈 문자열/null 표현으로 기존 API schema와 사용자 입력 검증을 유지한다. (`GIFT-020`, `P15-T2`, `P15-GATE`)
|
- [x] 삭제 후 빈 문자열/null 표현으로 기존 API schema와 사용자 입력 검증을 유지한다. (`GIFT-020`, `P15-T2`, `P15-GATE`)
|
||||||
- [x] 만료/정리/reminder 업무 메서드가 각각 Asia/Seoul `:10`/`:20`/`:40` schedule wrapper로 등록된다. 실제 Spring 등록을 확인했다. (`GIFT-021`, `P15-T3`, `P15-T4`)
|
- [x] 만료/정리/reminder 업무 메서드가 각각 Asia/Seoul `:10`/`:20`/`:40` schedule wrapper로 등록된다. 실제 Spring 등록을 확인했다. (`GIFT-021`, `P15-T3`, `P15-T4`)
|
||||||
@@ -1145,9 +1149,9 @@ Phase 15 서버 구현 완료 판정에는 `P15-T2`~`P15-T4` focused test와 격
|
|||||||
| `GIFT-011` | 6 | 푸시 이벤트 테스트 | 딥링크와 수신자 확인 |
|
| `GIFT-011` | 6 | 푸시 이벤트 테스트 | 딥링크와 수신자 확인 |
|
||||||
| `GIFT-014` | 8 | 선물 사용자/관리자 controller 날짜 응답 테스트 | UTC ISO 문자열 확인 |
|
| `GIFT-014` | 8 | 선물 사용자/관리자 controller 날짜 응답 테스트 | UTC ISO 문자열 확인 |
|
||||||
| `GIFT-016`, `GIFT-017` | 14 | 관리자 취소 service/controller 및 query service 테스트 | `RECEIVED` 액션과 취소/환불 결과 확인 |
|
| `GIFT-016`, `GIFT-017` | 14 | 관리자 취소 service/controller 및 query service 테스트 | `RECEIVED` 액션과 취소/환불 결과 확인 |
|
||||||
| `GIFT-018` | 15 | repository predicate 및 erasure method test | 격리 fixture 이후 삭제 필드와 보존 데이터 재조회 |
|
| `GIFT-018` | 15, 17 | 두 상태별 repository predicate 및 통합 erasure method test | 격리 fixture 이후 삭제 필드와 보존 데이터 재조회 |
|
||||||
| `GIFT-019` | 15 | GiftEntityMappingTest, SQL section review | no production DB execution in Gate |
|
| `GIFT-019` | 15 | GiftEntityMappingTest, SQL section review | no production DB execution in Gate |
|
||||||
| `GIFT-020` | 15 | query/controller contract regression | fixture erase 후 실제 socket HTTP SENT/RECEIVED/admin 조회 |
|
| `GIFT-020` | 15, 17 | 두 상태의 query/controller contract regression | fixture erase 후 실제 socket HTTP SENT/RECEIVED/admin 조회 |
|
||||||
| `GIFT-021` | 15 | scheduler reflection/lock/delegation tests | schedule/zone/lock key 대조 |
|
| `GIFT-021` | 15 | scheduler reflection/lock/delegation tests | schedule/zone/lock key 대조 |
|
||||||
|
|
||||||
`GIFT-019` rollout은 운영 schema 확인, DDL review 및 별도 실행 승인, nullable schema와 호환 reader 준비를 거친다. Gate는 운영 DB에서 SQL 또는 정리 작업을 실행하지 않는다.
|
`GIFT-019` rollout은 운영 schema 확인, DDL review 및 별도 실행 승인, nullable schema와 호환 reader 준비를 거친다. Gate는 운영 DB에서 SQL 또는 정리 작업을 실행하지 않는다.
|
||||||
@@ -1178,9 +1182,14 @@ Phase 15 서버 구현 완료 판정에는 `P15-T2`~`P15-T4` focused test와 격
|
|||||||
| 2026-10-23 | `DEC-020` | 확정 | sender 다섯 Kotlin property 중 `senderAddressDetail` 포함 다섯 모두 nullable, DB상 현재 NOT NULL인 sender 열 네 개만 변경한다 | entity 필드와 DDL nullable 경계의 구별 | `GIFT-019`, `P15-T2` |
|
| 2026-10-23 | `DEC-020` | 확정 | sender 다섯 Kotlin property 중 `senderAddressDetail` 포함 다섯 모두 nullable, DB상 현재 NOT NULL인 sender 열 네 개만 변경한다 | entity 필드와 DDL nullable 경계의 구별 | `GIFT-019`, `P15-T2` |
|
||||||
| 2026-10-23 | `DEC-021` | 확정 | 신규 시간별 scheduler wrapper는 기존 만료/안내 업무 메서드에 위임하며, 기존 예약은 자동취소 `:00`뿐이다. 신규 등록은 `:10`, `:20`, `:40`이다 | 현재 scheduler 코드와 승인 일정 | `GIFT-021`, `P15-T3`~`P15-T4` |
|
| 2026-10-23 | `DEC-021` | 확정 | 신규 시간별 scheduler wrapper는 기존 만료/안내 업무 메서드에 위임하며, 기존 예약은 자동취소 `:00`뿐이다. 신규 등록은 `:10`, `:20`, `:40`이다 | 현재 scheduler 코드와 승인 일정 | `GIFT-021`, `P15-T3`~`P15-T4` |
|
||||||
| 2026-10-26 | `DEC-022` | 확정 | 기존 PRD의 수령확인·배송지 만료 발신자 알림 호출 누락 두 곳을 보완하고 관리자 가격 변경/카테고리 비활성화 후 기존 신청 스냅샷 보존 통합 테스트를 추가한다 | 사용자 후속 작업 승인 | `GIFT-008`, `GIFT-011`, `GIFT-013`, `P16-T1`~`P16-GATE` |
|
| 2026-10-26 | `DEC-022` | 확정 | 기존 PRD의 수령확인·배송지 만료 발신자 알림 호출 누락 두 곳을 보완하고 관리자 가격 변경/카테고리 비활성화 후 기존 신청 스냅샷 보존 통합 테스트를 추가한다 | 사용자 후속 작업 승인 | `GIFT-008`, `GIFT-011`, `GIFT-013`, `P16-T1`~`P16-GATE` |
|
||||||
|
| 2026-11-01 | `DEC-023` | 확정 | 기존 :20 파기를 `DELIVERED`+non-null `deliveredAt <= now.minusDays(7)` 또는 `UNDELIVERABLE`+non-null `undeliverableAt <= cutoff`로 확장한다. `COALESCE`/다른 시각 fallback 및 `CANCELED`는 제외한다. 기존 12개 필드와 API/업무 데이터/전달불가 사유, Redis literal·transaction/unlock을 보존하고 내부 명칭만 통합한다 | 사용자 승인된 통합 개인정보 파기 정책. `DEC-017`의 당시 전달완료 구현 기록은 보존한다 | `GIFT-018`, `GIFT-020`, `8.16`, `11.1`, `P17-T1`, `P17-GATE` |
|
||||||
|
|
||||||
## 문서 상태 안내
|
## 문서 상태 안내
|
||||||
|
|
||||||
Phase 14의 당시 전체 테스트 제외 및 검증 이력은 `plan-task.md`의 P14-GATE 기록을 보존한다. Phase 15는 후속 실행 승인에 따라 서버 구현과 최종 전체 테스트 2,396개·lint·build·실소켓 QA·리뷰를 완료했다. 운영 MySQL DDL 실행 및 배포는 별도 승인/조건이며 이번 완료 범위에 포함하지 않는다.
|
Phase 14의 당시 전체 테스트 제외 및 검증 이력은 `plan-task.md`의 P14-GATE 기록을 보존한다. Phase 15는 후속 실행 승인에 따라 서버 구현과 최종 전체 테스트 2,396개·lint·build·실소켓 QA·리뷰를 완료했다. 운영 MySQL DDL 실행 및 배포는 별도 승인/조건이며 이번 완료 범위에 포함하지 않는다.
|
||||||
|
|
||||||
Phase 16은 2026-10-26 후속 승인 범위의 서버 구현·검증을 완료했다. 최종 전체 테스트 2,400개·lint·build 통과 및 실제 localhost HTTP/DB 시나리오와 독립 리뷰 승인을 확인했다. Phase15의 과거 기록과 달리 이번 두 성공 기준은 새 검증 근거로 완료 표시했으며, 나머지 과거 성공 기준은 일괄 체크하지 않았다.
|
Phase 16은 2026-10-26 후속 승인 범위의 서버 구현·검증을 완료했다. 최종 전체 테스트 2,400개·lint·build 통과 및 실제 localhost HTTP/DB 시나리오와 독립 리뷰 승인을 확인했다. Phase15의 과거 기록과 달리 이번 두 성공 기준은 새 검증 근거로 완료 표시했으며, 나머지 과거 성공 기준은 일괄 체크하지 않았다.
|
||||||
|
|
||||||
|
Phase 17은 2026-11-01 기존 문서를 먼저 갱신한 후 UNDELIVERABLE 미처리 assertion RED와 통합 구현 GREEN을 확인했다. 관련 5개 클래스 165개와 lint가 통과했으며 두 상태의 실제 JWT HTTP 파기 전후 검증을 포함한다. 전체 suite·독립 review·최종 QA는 parent의 `P17-GATE` 대기이며 이번 focused 결과로 Gate나 운영 반영 완료를 주장하지 않는다.
|
||||||
|
|
||||||
|
Phase 17 최종 판정: 위 focused 인계 이후 전체 2,904개 테스트(실패·오류·skip 0), build·ktlintCheck와 단일 독립 리뷰 APPROVE를 확인해 `P17-GATE`를 완료했다. lead가 별도로 실행한 실제 JWT HTTP/DB 두 상태 검증과 scheduler 59개도 통과했다. 운영 MySQL·배포·실제 Redis 다중 인스턴스는 미검증이며 커밋·푸시는 하지 않았다.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Phase 17 통합 배송 개인정보 정리 리뷰
|
||||||
|
|
||||||
|
## 리뷰 정보
|
||||||
|
|
||||||
|
- 일자: 2026-11-01
|
||||||
|
- 대상: 승인된 Phase 17 working tree. 문서 2개, 운영 코드 2개, 테스트 5개.
|
||||||
|
- 기준: PRD `GIFT-018`, `GIFT-020`, `DEC-023`, 계획 `P17-T1`/`P17-GATE`.
|
||||||
|
- 방법: 구현 담당과 별개의 단일 read-only reviewer가 전체 diff와 연결된 repository, entity, query, DTO, controller, 인증 및 상태 전이 경로를 검토했다.
|
||||||
|
- 판정: **APPROVE. 확정 발견 사항 및 수정 차단 사항 없음.**
|
||||||
|
|
||||||
|
## 기준별 판정
|
||||||
|
|
||||||
|
| 기준 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 상태와 기준 시각 pairing | DELIVERED/deliveredAt 및 UNDELIVERABLE/undeliverableAt의 non-null 시각만 7일 inclusive cutoff로 판정. fallback·CANCELED 제외 |
|
||||||
|
| 삭제·보존 범위 | 기존 12개 필드 null 처리 본문 유지. 상태·전달불가 사유·업무 시각·회원·결제·동의·리뷰 보존 |
|
||||||
|
| 예약·잠금 | 매시 :20 Asia/Seoul, Redis literal, transaction/unlock 불변. 자동취소는 미변경 |
|
||||||
|
| 경계·반복 | 정확한 경계·과거 이력 포함, 1초 미경과·짝인 null 시각·비대상 상태 제외. 부분 삭제·재실행 0건 검증 |
|
||||||
|
| 조회 회귀 | 두 상태의 실제 JWT HTTP 사용자/관리자 상세·목록, 빈 문자열/null, 권한·액션·flags·시각·사유 보존 |
|
||||||
|
| 변경 최소성 | 새 스케줄·DDL·API·dependency 없음. 기존 assertion을 재사용한 parameterization, 내부 이름과 호출자 일치 |
|
||||||
|
|
||||||
|
## 검증 근거
|
||||||
|
|
||||||
|
- reviewer는 RED의 UNDELIVERABLE `expected 1 / actual 0`, 구현 담당의 focused 165개 통과 기록을 대조했다.
|
||||||
|
- lead 재실행의 scheduler 59개와 실제 HTTP 2개 XML을 reviewer가 직접 확인했다. 실패·오류·skip 0, 두 상태의 DB `erased=1`, `repeated=0`, `persistedNulls=12`와 보존 assertion 통과.
|
||||||
|
- 리뷰 당시 전체 실행은 진행 중이었다. 이후 lead가 별도 verifier 결과 및 XML 합계를 확인했다: 전체 2,904개, 실패·오류·skip 0, build·ktlintCheck 통과. 상세 명령과 결과는 기존 `plan-task.md`의 Phase 17 최종 Gate에 기록했다.
|
||||||
|
|
||||||
|
## 운영 경계
|
||||||
|
|
||||||
|
- 기존 nullable schema/reader 호환이 선행조건이다. 배포 후 오래된 전달불가 개인정보도 비가역적으로 삭제된다. 혼합 버전에서 구버전이 같은 락을 획득한 회차는 전달완료만 처리할 수 있다.
|
||||||
|
- 기존 전건 조회·건별 delivery 조회·단일 트랜잭션 구조는 유지된다. 운영 누적 건수/실행계획 정보 없이 부하 안전성을 단정하지 않으며 이번 범위에서 batching/index를 추가하지 않는다.
|
||||||
|
- 운영 MySQL·실제 Redis 다중 인스턴스·배포·FCM·백업/로그/캐시 삭제는 미검증 또는 범위 밖이다. LSP는 제공되지 않아 Kotlin compile/ktlint로 대체했다.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 추천 API 실제 HTTP 검증
|
||||||
|
|
||||||
|
## 실행 증거
|
||||||
|
|
||||||
|
- 실행일: 2026-10-31. 제품 변경 기준: `969f999a`와 동일한 제품 코드.
|
||||||
|
- 표면: `SpringBootTest(RANDOM_PORT)` + `TestRestTemplate`, loopback HTTP, 격리 H2 및 embedded Redis.
|
||||||
|
- 인증: 테스트 DB에 저장한 회원과 Auth, 실제 `TokenProvider.createToken`으로 발급한 Bearer JWT. MockMvc principal 주입이 아니다.
|
||||||
|
- fixture는 `TransactionTemplate`으로 commit한 뒤 HTTP 요청했다. 운영 DB 및 외부 API를 호출하지 않았다.
|
||||||
|
- 결과: HTTP 요청 **104건**, 상태·응답·정책 비교 판정 **216건**, 실패 **0건**, `BUILD SUCCESSFUL in 30s`.
|
||||||
|
- 두 추천 API 및 `/api/v2/events/{id}`를 함께 호출해 추천 배너의 이벤트 ID와 상세 조회 허용 ID를 비교했다.
|
||||||
|
|
||||||
|
실행 명령은 아래와 같다. QA_ROOT는 작업 소유 임시 디렉터리였으며 최종 자원 정리 대상이다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew --init-script "$QA_ROOT/socket-qa.init.gradle" test \
|
||||||
|
--tests kr.co.vividnext.sodalive.v2.qa.RecommendationSocketQaTest
|
||||||
|
```
|
||||||
|
|
||||||
|
원본 `evidence-second/summary.json`, `responses.json`을 직접 읽고 아래 결과를 발췌했다. 토큰·인증 헤더·Auth 개인정보는 보존하지 않는다. 아래 ID는 테스트 fixture ID이지 운영 이벤트 ID가 아니다.
|
||||||
|
|
||||||
|
## 실제 결과
|
||||||
|
|
||||||
|
| 국가 / Accept-Language | 조회자 | 홈 이벤트 ID 순서 | 오디오 이벤트 ID 순서 | 상세 허용 ID 집합 | 판정 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| KR / ja | 익명 | 1, 2 | 1, 2 | 1, 2 | PASS |
|
||||||
|
| KR / ja | 미인증 | 1, 2 | 1, 2 | 1, 2 | PASS |
|
||||||
|
| KR / ja | 인증 | 1, 3 | 1, 3 | 1, 3 | PASS |
|
||||||
|
| KR / ja | ADMIN | 1, 2, 3 | 1, 2, 3 | 1, 2, 3 | PASS |
|
||||||
|
| JP / ko | 익명 | 8, 9 | 8, 9 | 8, 9 | PASS |
|
||||||
|
| JP / ko | 미인증 | 8, 9, 8 | 8, 9, 8 | 8, 9 | PASS |
|
||||||
|
| JP / ko | 인증 | 8, 10 | 8, 10 | 8, 10 | PASS |
|
||||||
|
| JP / ko | ADMIN | 8, 9, 10 | 8, 9, 10 | 8, 9, 10 | PASS |
|
||||||
|
|
||||||
|
JP 미인증의 마지막 ID 8은 동일한 전체 대상 이벤트에 연결된 별도 성인 배너다. 해당 회원은 성인 표시 설정이 켜져 있어 배너 자체 조건은 통과하지만, 인증 전용 이벤트 ID 10은 노출되지 않는다. 이벤트 중복 제거를 추가하지 않고 기존 배너 단위 계약을 유지한다.
|
||||||
|
|
||||||
|
모든 행에서 성공 envelope, 기존 top-level 필드 집합, 정확한 배너 이미지 순서, LINK 유지, `subBanners: []`를 확인했다. 홈은 `aiCharacters: []`, 오디오는 해당 필드가 없다. 인증 회원의 성인 표시 설정은 꺼져 있었어도 인증 대상 이벤트 배너 자체가 비성인이면 정상 노출됐다.
|
||||||
|
|
||||||
|
## HTTP 응답 발췌
|
||||||
|
|
||||||
|
익명 KR 요청의 홈 응답에서 관찰한 부분이다. 전체 응답 예시가 아닌 실제 필드 발췌다.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"aiCharacters": [],
|
||||||
|
"subBanners": [],
|
||||||
|
"banners": [
|
||||||
|
{ "imageUrl": "https://cdn.test/qa/KR/HOME/all.png", "eventItem": { "id": 1 } },
|
||||||
|
{ "imageUrl": "https://cdn.test/qa/KR/HOME/false.png", "eventItem": { "id": 2 } },
|
||||||
|
{ "imageUrl": "https://cdn.test/qa/KR/HOME/retained-link.png", "eventItem": null },
|
||||||
|
{ "imageUrl": "https://cdn.test/qa/KR/HOME/creator-block.png", "creatorId": 4 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 경계 및 인접 표면
|
||||||
|
|
||||||
|
- 비활성·시작 전·종료 후·이벤트 언어 불일치·연결 이벤트 없는 EVENT 배너는 제외됐다. ADMIN도 이를 우회하지 못했다.
|
||||||
|
- 배너 자체의 비활성·탭·언어·예약·성인 조건을 유지했고, 로그인 회원에게 차단된 CREATOR 배너는 제외됐다.
|
||||||
|
- LINK 문자열에 이벤트처럼 보이는 URL이 있어도 임의 파싱하지 않고 기존 배너로 유지했다.
|
||||||
|
- fixture 이벤트 전체를 비활성화하고 비EVENT 배너도 비활성화한 후 4종 조회자 × KR/JP × 두 API에서 `banners: []`를 확인했다.
|
||||||
|
- 이 HTTP 검증은 넓은 시간창을 사용했다. 정확한 나노초 경계·limit·random tie·AI 조회 비호출·전용 AI endpoint 보존은 계획의 focused 테스트 증거로 별도 검증한다.
|
||||||
|
|
||||||
|
## 실행 중 보정 및 한계
|
||||||
|
|
||||||
|
- 첫 QA 실행은 HTTP 요청 이전에 Spring Redis `setConfig("save", "")`의 빈 값 검증에서 실패했다. 제품 결함이 아니며 임시 harness만 raw `CONFIG SET save` 호출로 바꾼 뒤 재실행했다.
|
||||||
|
- 두 번째 실행이 위 PASS 결과다. 임시 Redis의 snapshot 저장만 끄고 테스트 토큰을 종료 시 삭제하도록 구성했다.
|
||||||
|
- 운영 MySQL 데이터·Event 컬럼 저장 정밀도는 검증하지 않았다. LSP 도구가 없어 LSP 진단을 실행했다고 주장하지 않는다.
|
||||||
|
- 최종 전체 빌드·리뷰·임시 자원 정리 결과는 `plan-task.md`의 Gate 기록을 따른다.
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
# 추천 화면 AI 캐릭터 및 이벤트 배너 노출 정책 구현 계획
|
||||||
|
|
||||||
|
> 실행 전제: 구현을 시작하려면 사용자의 별도 실행 승인이 필요하다. 승인 전 본 계획의 모든 실행 체크박스는 미완료 상태로 둔다.
|
||||||
|
|
||||||
|
**Goal:** 홈 추천 aggregate에서는 AI 캐릭터를 항상 빈 배열로 반환하고, 홈·오디오 추천의 EVENT 배너에는 기존 이벤트 상세 노출 정책을 적용한다.
|
||||||
|
|
||||||
|
**Architecture:** 홈 aggregate는 AI 추천 query 경로를 호출하지 않는다. 각 추천 repository의 기존 배너 QueryDSL 조회에만 EVENT 적격 조건을 추가한다. 이벤트 시간은 상세 조회와 같은 system-default `LocalDateTime` 의미로 별도 평가하고, 기존 배너 예약용 UTC clock은 변경하지 않는다.
|
||||||
|
|
||||||
|
**Tech Stack:** Kotlin 1.6.21, Spring Boot 2.7.14, Java 17, JUnit 5, QueryDSL 5.0.0, Spring Data JPA, H2 MySQL mode 테스트.
|
||||||
|
|
||||||
|
**Spec:** [`prd.md`](prd.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문서 및 실행 상태
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 및 검증 완료 · 전체 빌드는 UTC 환경 기준 |
|
||||||
|
| 작성일 | 2026-10-31 |
|
||||||
|
| 요구사항 기준 | `docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/prd.md` |
|
||||||
|
| API 기준 | PRD §8, 기존 응답 schema 유지 |
|
||||||
|
| 현재 Phase | Phase 1 · 추천 응답 노출 정책 일치 |
|
||||||
|
| 현재 활성 Goal | 없음 · `P1-GATE` 완료 |
|
||||||
|
| 실행 순서 | `P1-T1` → `P1-T2` → `P1-T3` → `P1-GATE` |
|
||||||
|
|
||||||
|
계획 작성 당시에는 구현하지 않았으며, 이후 사용자의 `문서대로 진행` 및 `ulw` 승인에 따라 구현했다. 개별 실행 기록은 당시 상태를 보존하고, 현재 상태는 상단 표와 최신 Gate 기록을 따른다.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `REC-AI-001`에 따라 홈 aggregate의 `aiCharacters`는 익명·회원·ADMIN 모두 `[]`이고 null 또는 생략이 아니다. 해당 경로에서 AI query/fallback을 호출하지 않는다.
|
||||||
|
- 오디오 추천 DTO에 AI 캐릭터 필드를 추가하지 않는다. 전용 `/api/v2/home/recommendations/ai-characters` 흐름을 보존한다.
|
||||||
|
- EVENT 배너의 적격 조건은 연결 이벤트 존재, 활성, `startDate <= eventNow <= endDate`, 회원 해석 국가의 언어, 성인 대상 정책이다.
|
||||||
|
- 인증 대상은 `AuthRepository.getAuthIdByMemberId(memberId) != null`과 같다. 익명은 미인증이다. `isAdult == null`은 모두, `true`는 인증, `false`는 미인증만 적격이다.
|
||||||
|
- ADMIN은 이벤트 audience 조건만 우회하며 존재·활성·시간·언어 조건과 배너 자체 조건은 우회하지 않는다.
|
||||||
|
- 기존 banner-own 활성·tab·language·reservation·성인 노출 가능 여부·creator-block 조건은 이벤트 조건과 AND로 유지한다. 기존 정렬, 동순위 random tie, 적격 후보에 대한 limit 20을 유지한다.
|
||||||
|
- 기존 공개 service/port 서명은 보존한다. per-event `EventDetailService.getEvent` 호출, 신규 abstraction, N+1 또는 별도 application query를 만들지 않는다.
|
||||||
|
- 양 repository의 별도 clock 의존성은 `eventNowProvider: () -> LocalDateTime = { LocalDateTime.now() }`를 기존 `nowProvider` 바로 앞에 추가한다. event clock은 query당 한 번 캡처하고, 기존 trailing-lambda가 주입하는 banner `nowProvider`는 UTC 예약 판정 전용으로 보존한다.
|
||||||
|
- 이벤트 비교는 `EventDetailService`처럼 system-default `LocalDateTime.now()` 의미와 sub-second 정밀도를 사용한다. 기존 banner reservation의 UTC/초 정밀도, 전역 timezone 및 formatter는 바꾸지 않는다.
|
||||||
|
- 구현 Task는 각자 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR 순서다. Task마다 실패 재현, 최소 구현, focused 검증 증거를 남긴다.
|
||||||
|
- 전체 회귀는 공통 인증·보안·예외·설정 경계 변경, 여러 domain 변경, release/final 전수 증거 요구, 또는 targeted test로 영향 범위를 판정할 수 없는 실패 때만 수행한다. 그 외에는 생략 사유와 focused 대체 검증을 Progress에 기록한다.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- 인증 기록과 `canViewAdultContent`가 서로 다른 조회자: 이벤트는 인증 기록, 배너 자체는 기존 성인 노출 가능 여부를 각각 적용한다. `P1-T2`, `P1-T3`에서 인증된 `canViewAdultContent=false` 및 미인증 `canViewAdultContent=true` 조합을 고정한다.
|
||||||
|
- 이벤트 경계 시각의 clock/정밀도 차이: 두 clock을 서로 다르게 고정하고 nanos를 포함하여 정확한 양끝은 포함, 직전·직후는 제외한다. 기존 예약 UTC clock 값과 이벤트 clock 값을 다르게 설정한다. `P1-T2`, `P1-T3`에서 확인한다.
|
||||||
|
- 대상 없는 EVENT, 부적격 우선순위 후보, 전부 제외: 기존 다른 배너는 실패 없이 유지되고 필터가 limit 전에 실행되며 전부 제외 시 `[]`인지 각각 `P1-T2`, `P1-T3`에서 확인한다.
|
||||||
|
- ADMIN 권한 범위: 인증 audience만 우회하고 잘못된 언어·비활성·기간 밖 이벤트와 배너 자체 성인/활성 조건은 통과시키지 않는지 `P1-T2`, `P1-T3`에서 확인한다.
|
||||||
|
- request language와 국가 판정 불일치 및 오디오 schema: 홈·오디오 endpoint가 `resolveCountryCode`로 결정된 이벤트 언어를 적용하고 오디오 응답에 `aiCharacters`가 없음을 `P1-GATE`에서 확인한다.
|
||||||
|
|
||||||
|
## 파일 책임 및 작업 범위
|
||||||
|
|
||||||
|
| 파일 | 책임 |
|
||||||
|
|---|---|
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt` | 홈 aggregate의 AI 목록을 빈 목록으로 고정하고 aggregate AI 조회 호출 제거 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` | facade가 AI recommendation 경로를 호출하지 않으며 응답은 빈 목록임을 검증 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` | 홈 API AI 목록/전용 endpoint 유지 검증 및 홈 배너 정책 endpoint 회귀 검증 |
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` | 홈 배너 EVENT 조건과 별도 상세 정책 clock을 기존 QueryDSL 필터에 반영 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt` | 홈 배너 QueryDSL의 audience·시간·언어·교집합·정렬/limit 검증 |
|
||||||
|
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` | 콘텐츠 추천 배너 EVENT 조건과 별도 상세 정책 clock을 기존 QueryDSL 필터에 반영 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` | 오디오 배너 QueryDSL의 audience·시간·언어·교집합·정렬/limit 검증 |
|
||||||
|
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt` | 오디오 추천 응답에 AI 필드가 없고 배너 API 계약이 유지됨을 검증 |
|
||||||
|
|
||||||
|
## Phase 1: 추천 응답 노출 정책 일치
|
||||||
|
|
||||||
|
**Phase 결과:** 기존 응답 schema와 일반 배너 정책을 보존하면서 홈 aggregate AI 노출을 끄고 양쪽 추천 이벤트 배너의 상세 조회 노출 적격성을 일치시킨다.
|
||||||
|
|
||||||
|
**선행조건:** PRD 요구사항 확정 및 이 계획에 대한 별도 구현 실행 승인.
|
||||||
|
|
||||||
|
**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-T3` 및 `P1-GATE` 완료와 실제 검증 결과 기록.
|
||||||
|
|
||||||
|
### Task 1: 홈 aggregate AI 빈 배열
|
||||||
|
|
||||||
|
- [x] **Task 1.1: 홈 aggregate AI 빈 배열**
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T1`:** 홈 aggregate에서 AI recommendation 조회를 제거하고 응답의 기존 `aiCharacters` 필드를 모든 viewer에게 빈 배열로 유지한다.
|
||||||
|
|
||||||
|
- **시작 조건:** 사용자의 구현 실행 승인, `REC-AI-001~002` 확인.
|
||||||
|
- **완료 증거:** facade 단위 테스트에서 빈 배열과 query 비호출 확인, HTTP 테스트에서 seeded AI 대상에도 aggregate `[]`, 전용 AI 목록은 기존 식별자를 계속 반환.
|
||||||
|
- **범위 밖:** 전용 AI endpoint, snapshot/scheduler/data, 오디오 응답 schema.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 기존 `HomeRecommendationFacade.getHomeRecommendations(member: Member?, lang: Lang): HomeRecommendationResponse` 및 `HomeRecommendationResponse.aiCharacters: List<HomeAiCharacterItem>`.
|
||||||
|
- Produces: 동일한 외부 서명과 DTO schema. aggregate constructor에 `aiCharacters = emptyList()`; 전용 AI paging은 기존 `queryService.findAiCharacterRecommendations(...)` 호출을 보존.
|
||||||
|
|
||||||
|
- [x] **RED:** facade test `shouldReturnEmptyAiCharactersWithoutQueryingAiRecommendations`를 추가한다. fake query port의 snapshot/detail/random AI 호출을 계수 또는 fail-fast로 계측하고 응답의 `aiCharacters`가 비었으며 호출이 0회임을 assert한다. Controller test의 기존 `shouldReturnTwentyAiCharactersOnHomeRecommendations`도 aggregate가 seeded AI 22개에도 `[]`인지, 필드가 존재하는지 확인하도록 바꾼다. `shouldExposeCreatorIdOnAiCharacterRecommendations`는 aggregate의 빈 배열을 확인하고 `characterId`/`creatorId` 검증을 dedicated endpoint 호출로 옮긴다. seeded 데이터와 dedicated 검증은 유지한다.
|
||||||
|
- [x] **RED 확인:** 두 기존 assertion 변경과 새 facade test가 구현 전 의도대로 실패하는지 두 focused class를 실행한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest"
|
||||||
|
```
|
||||||
|
- [x] **GREEN:** facade aggregate에서 `aiCharacters = emptyList()`를 설정하고 aggregate의 `findAiCharacterRecommendations` 호출만 제거한다. 이제 쓰이지 않는 `HOME_AI_CHARACTER_LIMIT`만 제거한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 두 focused class를 다시 실행해 facade와 controller test가 통과하는지 확인한다. Controller 응답은 익명, 미인증 회원, 인증 회원, ADMIN 각각 `aiCharacters: []`이며 KO/JA 변형에서도 동일하고, dedicated endpoint는 식별자를 반환한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 위 두 focused class를 재실행해 결과를 기록한다.
|
||||||
|
|
||||||
|
#### P1-T1 검증 기록 — 2026-10-31
|
||||||
|
|
||||||
|
- 공통 명령은 위 두 focused class 명령을 그대로 사용했다. 각 실행에서 `TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/recommendation-ulw.nT00w2/p1-t1.UhWqcw`, `NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`를 export하고 `set -o pipefail` 및 `tee`로 로그를 보존했다.
|
||||||
|
- Java는 기본 OpenJDK `17.0.15`를 사용했다. `/usr/libexec/java_home -v17`은 등록 런타임을 찾지 못했지만 기본 Java가 이미 17이어서 변경하지 않았다.
|
||||||
|
- BASELINE: 제품/테스트 수정 전 facade 6개 + controller 28개 = 34개 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 50s`. 원문 `baseline.log`.
|
||||||
|
- RED: 제품 코드 변경 전에 동일 명령으로 42개 실행, 10개 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 46s`. facade 7개 중 신규 1개 실패, controller 35개 중 정책 변경 대상 9개만 실패했다. 원문 `red.log`.
|
||||||
|
- RED 원인: facade의 빈 응답 자체는 통과하지만 AI 최신 snapshot 조회·snapshot 존재 확인·상세 조회·fallback이 각각 1회여서 0회 assertion 4개가 실패했다. 기존 22개 seeded fixture의 익명/미인증/인증/ADMIN × KO/JA 8개와 기존 단일 fixture 집계는 비어 있지 않은 AI 배열 때문에 실패했다. dedicated `characterId`/`creatorId` assertion과 나머지 기존 검증은 삭제하지 않았다.
|
||||||
|
- GREEN: 집계의 AI query/map을 `emptyList()`로 교체하고 고아 `HOME_AI_CHARACTER_LIMIT`만 제거했다. 같은 focused 명령에서 facade 7개 + controller 35개 = 42개 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 44s`. `compileKotlin` 및 `compileTestKotlin`이 실제 실행되어 통과했다. 원문 `green.log`, 결과 XML/HTML은 기존 `build/test-results/test/`, `build/reports/tests/test/`에 남긴다.
|
||||||
|
- GREEN 확인 범위: fake의 AI snapshot/존재 확인/detail/random/fallback 호출은 모두 0회다. MockMvc에서 실제 seeded AI 22개에도 익명/미인증/인증/ADMIN × Accept-Language KO/JA 8개가 존재하는 빈 배열을 반환하고, 별도 단일 seeded AI는 dedicated endpoint에서 기존 `characterId`/`creatorId`를 반환했다. 기존 dedicated paging 및 익명 접근 거부 테스트도 통과했다. 인증 fixture는 실제 Auth row를 persist하고 flush/clear했다.
|
||||||
|
- REFACTOR/자체 검토: 수정한 facade·두 테스트를 다시 읽고 호출 경로와 assertion을 대조했다. 제품 변경은 집계 할당 및 고아 상수에만 한정되며 shared mapper, dedicated paging, DTO, snapshot/scheduler, 배너는 그대로다. 이번 변경으로 정리할 추가 중복이 없어 별도 코드 수정이나 추가 재실행은 하지 않고 위 GREEN 실행을 최종 focused 증거로 사용한다. scoped 자체 검토의 차단 이슈는 없다.
|
||||||
|
- 검증 제한: P1-T1 위임 범위와 중단 조건에 따라 전체 suite/build, `ktlintCheck`, 실제 서버 HTTP QA 및 P1-T2 이후 작업은 수행하지 않았다. 공유 경계 변경이 없는 단일 aggregate 변경이므로 두 focused class로 대체했고 최종 Gate 검증은 상위 실행자가 소유한다. `lsp_diagnostics` 도구는 없으며 실행했다고 주장하지 않는다. BASELINE부터 있던 Gradle deprecation과 embedded Redis 종료 reconnect 경고는 GREEN에도 존재하지만 실패는 아니다. commit/add/push 및 임시 증거 삭제는 하지 않았다.
|
||||||
|
|
||||||
|
### Task 2: 홈 추천 EVENT 배너 SQL 적격성
|
||||||
|
|
||||||
|
- [x] **Task 1.2: 홈 추천 EVENT 배너 SQL 적격성**
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T2`:** 기존 홈 배너 DB 조회에 이벤트 상세 적격 조건을 limit 전 적용하고 기존 banner-own 필터·정렬 계약을 보존한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1` 완료, `REC-EVT-001~007` 확인.
|
||||||
|
- **완료 증거:** repository integration test에서 QueryDSL `EXISTS` 정책과 clock 경계를 검증하고 controller integration test에서 정책에 맞는 응답 확인.
|
||||||
|
- **범위 밖:** 오디오 repository, per-event service 조회, public port 변경, 일반 배너 조건 변경.
|
||||||
|
|
||||||
|
**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`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 기존 `findHomeBanners(limit: Int, memberId: Long?, includeAdultBanners: Boolean, lang: Lang)` 및 기존 회원의 resolved banner `lang`.
|
||||||
|
- Produces: 공개 메서드 서명 불변. 생성자에 기본값 clock `eventNowProvider`를 `nowProvider` 바로 앞에 추가하고 EVENT predicates에서만 query당 한 번 사용한다. 기존 최종 `nowProvider` parameter/trailing lambda는 reservation 검증에 그대로 쓴다.
|
||||||
|
|
||||||
|
- [x] **RED:** `shouldApplyHomeEventVisibilityPolicyBeforeLimit` parameterized DB integration test와 경계 테스트를 추가한다. 익명·미인증·인증·ADMIN × `isAdult=null/false/true`, active/hidden/future/expired/wrong-language, EVENT 대상 없음, 비-EVENT 불변을 검증한다. Auth/Member fixture를 저장하고 flush/clear한다. 교차 검증으로 인증+`includeAdultBanners=false`, 미인증+`includeAdultBanners=true`를 포함한다. 후보 1이 숨겨지고 후보 2가 limit=1로 반환되는 경우 및 전체 후보 숨김 시 `[]`를 확인한다. 기존 positive EVENT fixtures도 고정 event clock에서 적격인 값으로 정렬하되 검증 강도는 유지한다. `HomeRecommendationControllerTest.shouldApplyEventBannerDetailPolicyForHomeAggregate`는 네 조회자와 KO/JA 국가, Accept-Language 불일치까지 확인한다.
|
||||||
|
- [x] **RED 확인:** repository와 새 controller endpoint 테스트를 함께 실행해 현 구현에서 기간·언어·대상·limit assertion이 실패함을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest"
|
||||||
|
```
|
||||||
|
- [x] **GREEN:** `DefaultHomeRecommendationQueryRepository`의 홈 배너 query에만 EVENT 분기 predicate를 추가한다. `QMember` alias와 `JPAExpressions`로 audience를 `isAdult == null || viewer is ADMIN || (isAdult == true && matching Auth exists) || (isAdult == false && matching Auth absent)`로 판정한다. 익명은 Auth 없음으로 본다. event 존재/활성·시간·resolved banner language 조건 및 audience는 기존 predicate와 함께 order/limit 전에 적용한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 두 focused class를 다시 실행해 repository 및 endpoint assertion의 성공을 확인한다. `eventNowProvider`와 기존 `nowProvider`를 서로 다른 시간으로 주입하여 event 시간의 포함 양끝·직전/직후, non-zero nanos 및 기존 UTC reservation 필터가 각각 독립됨을 검증한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest"
|
||||||
|
```
|
||||||
|
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 동순위 random 순서에 의존하지 않게 집합과 orders로 확인한다. 기존 positive/negative 배너 및 API 테스트를 다시 실행한다. 기존 두 홈 repository 직접 생성 위치의 기본 인자와 trailing-lambda 호환성도 유지한다.
|
||||||
|
|
||||||
|
#### P1-T2 검증 기록 — 2026-10-31
|
||||||
|
|
||||||
|
- 상태: 구현·두 focused class 전체 통과·작성자 자체 검토 완료. 상위 실행자의 독립 리뷰·commit·최종 Gate는 대기하며 `P1-T3`는 착수하지 않았다. 수정 범위는 위 제품 파일 1개·테스트 2개와 본 Task 체크리스트/증거 및 활성 Goal뿐이다.
|
||||||
|
- 모든 테스트 실행은 아래 동일 명령을 사용했다. `TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/recommendation-ulw.nT00w2/p1-t2.Z5xdEM`, `NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`, `JAVA_HOME=/Users/klaus/.local/share/mise/installs/java/liberica-17.0.15+10`를 매 shell에서 export했다. `set -o pipefail`과 `tee`로 원문을 보존했으며 확장 RED/GREEN의 화면 출력만 `rg --line-buffered '(^> Task|FAILED|tests completed|BUILD )'`로 요약했다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
|
||||||
|
```
|
||||||
|
|
||||||
|
- BASELINE: 수정 전 repository 98개 + controller 35개 = 133개, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 36s`. 원문 `baseline.log`.
|
||||||
|
- 최초 RED: 제품/생성자를 변경하지 않고 2000~2100의 넓은 event window와 실제 Member/Auth fixture로 실행했다. repository 120개 중 14개 + controller 43개 중 8개 = 163개 중 22개 assertion 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 57s`. 기간/언어 부적격 이벤트에 `expected [] / actual [hidden]`, 인증 대상에 `expected [all,false] 또는 [all,true] / actual [all,false,true]`, limit=1에 `expected visible-second / actual hidden-first`, endpoint 배너 수에 `expected 2/3 / actual 6` 등을 확인했다. 원문 `red.log`. 기존 AI 빈 배열·dedicated endpoint assertion은 그대로 유지됐다.
|
||||||
|
- 추가 RED: 최초 assertion RED 뒤에만 optional clock 인자를 추가하고 repository 테스트에 명시적으로 주입했다. event predicate 구현 전 nanos 경계 15개를 추가해 시작/종료/동일 양끝의 포함과 1ns 밖 제외를 검증했다. 첫 실행은 `compileTestKotlin` 중 shell 제한 120000ms로 중단됐으며 assertion 결과로 세지 않았다(`red-boundaries.log`). 수정 없이 360000ms 제한으로 재실행하여 repository 135개 중 20개 + controller 43개 중 8개 = 178개 중 28개 assertion 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 4m 3s`를 확인했다. BEFORE/AFTER 6개가 추가로 `expected [] / actual [boundary]`였다. 원문 `red-boundaries-retry.log`.
|
||||||
|
- GREEN: EVENT 분기에만 활성·`startDate <= eventNow <= endDate`·resolved `lang`·audience AND를 추가했다. 익명은 null/false, 회원은 null 또는 `QMember("eventViewer")` ADMIN EXISTS 또는 true/Auth EXISTS 또는 false/Auth NOT EXISTS다. Auth는 `auth.member.id == memberId`로 조회하여 기존 AuthRepository 판정과 같다. query당 event clock을 한 번 캡처하며 `LocalDateTime.now()` 기본값의 system-zone/nanos를 보존한다. 기존 `nowProvider`는 마지막 UTC lambda이고 배너 예약의 초 정밀도 판정은 변경하지 않았다.
|
||||||
|
- GREEN 결과: 위 동일 명령(600000ms 제한)에서 repository 135개 + controller 43개 = 178개 모두 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 1m 27s`. `compileKotlin`·`compileTestKotlin`·`:test`가 실제 실행됐다. 원문 `green.log`; 최종 XML은 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.xml`과 `TEST-kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.xml`, HTML은 `build/reports/tests/test/`다. 임시 root의 `evidence.md`에도 counts·경계·제한을 보존했다.
|
||||||
|
- DB 증거: flush/clear한 Member/Auth와 이벤트/배너로 4 viewer × null/false/true × KO/JA, 다른 회원의 Auth가 있는 미인증 조회자, 인증+배너 성인 노출 false / 미인증+true, ADMIN audience만 우회, 배너 자체 활성/탭/언어/예약/성인 조건, 누락·비활성·미래·만료·다른 언어 이벤트, hidden-first/eligible-second limit=1, 전부 제외 `[]`, LINK/CREATOR/SERIES 불변을 검증했다. 생성 SQL에서 EVENT의 시간/언어/audience 조건과 `member.id/role` EXISTS 및 `auth.member_id` EXISTS/NOT EXISTS가 `orders asc, rand() asc limit ?` 앞에 있음을 직접 확인했다. 운영 DB에는 접근하지 않았다.
|
||||||
|
- clock/저장 정밀도 증거: event clock `2026-10-31T23:59:59`의 nanos 0/123456789/999999999와 banner UTC clock `2026-10-27T23:59:59.999999999`를 별도로 사용했다. H2 테스트의 event start/end만 `timestamp(9)`로 설정해 flush/clear/reload 후 고정 기대 시각과 정확히 같음을 확인했다. event 1ns 비교와 기존 banner 시작=종료의 초 포함 경계가 함께 통과했다. 운영 Event DDL은 저장소에서 찾지 못했으며 이 증거는 query 비교 정밀도 검증이지 운영 MySQL 저장 정밀도 검증이 아니다. 운영 schema·timezone·formatter 변경은 없다. 기존 positive saveEvent도 고정 event clock ±1일로 맞췄다.
|
||||||
|
- endpoint 증거: 기존 `@SpringBootTest` MockMvc에서 익명/미인증/인증/ADMIN × `CloudFront-Viewer-Country` KR/JP와 반대 `Accept-Language` ja/ko 8개가 성공했고 적격 이벤트 ID의 전체 개수와 정확한 순서를 검증했다. 모두 `aiCharacters`가 존재하는 배열이면서 비었다. 실제 Auth와 preference row를 저장/flush/clear했고 국가 고정 매핑 대상 ID(16/17 등)는 정책 테스트 fixture에서 피했다. 기존 강제 국가 정책 자체는 변경하지 않았다.
|
||||||
|
- REFACTOR/자체 검토: PRD/Global Constraints·호출자(facade→query service→repository)·AuthRepository 및 EventDetailService와 제품 diff/테스트 전체를 대조했다. 조건은 EVENT OR 분기에만 있고 banner-own 조건·다른 target 분기·creator-block·정렬/limit는 보존됐다. 기존 3인자 기본 생성과 named event clock + trailing banner clock 생성 모두 컴파일됐고 전체 focused 검증을 통과했다. 추가 중복 정리가 필요하지 않아 GREEN 뒤 제품/테스트를 다시 변경하지 않았다. 작성자 자체 검토의 차단 이슈는 없다.
|
||||||
|
- 검증 제한: 위임 중단 조건에 따라 전체 suite/build·ktlint·실제 서버 HTTP QA·오디오/상세/subBanner 회귀 및 독립 리뷰는 상위 Gate에 남긴다. 이번 검증은 실제 H2 QueryDSL과 SpringBootTest MockMvc endpoint 증거다. `lsp_diagnostics` 도구는 없으며 실행했다고 주장하지 않는다. BASELINE부터의 Gradle deprecation 및 embedded Redis 종료 reconnect 경고는 GREEN에도 있으나 실패는 아니다. `git diff --check` 종료 코드 0; commit/add/push, subagent 호출 및 임시 증거 삭제는 하지 않았다.
|
||||||
|
|
||||||
|
#### P1-T2 커밋 훅 포맷 보정 — 2026-10-31
|
||||||
|
|
||||||
|
- 상위 실행자가 독립 기능 리뷰 후 시도한 commit은 ktlint 훅에서 실패했고 commit은 생성되지 않았다. `build/reports/ktlint/ktlintTestSourceSetCheck/ktlintTestSourceSetCheck.txt`의 신규 테스트 포맷 오류 17건을 확인했다. 두 테스트의 새 CSV 인자 줄바꿈, multiline if/else 중괄호, persist/apply 괄호 줄바꿈만 `apply_patch`로 수정했다. 제품 동작·fixture 값·assertion·테스트 수는 변경하지 않았으며 전체 formatter는 실행하지 않았다.
|
||||||
|
- 기존 P1-T2 임시 경로·Java 17·cache 환경 변수를 매 shell에서 동일하게 export하고 `set -o pipefail`/`tee`로 보존했다. `./gradlew ktlintCheck`는 종료 코드 0, `BUILD SUCCESSFUL in 29s`; main/test source check가 실제 실행됐고 test ktlint 보고서는 0줄이다. 원문 `p1-t2.Z5xdEM/lint-fix-ktlint.log`.
|
||||||
|
- 이어서 위 P1-T2의 동일한 두 클래스 focused 명령을 실행했다. repository 135개 + controller 43개 = 178개 모두 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 1m 8s`; `compileTestKotlin`과 `:test`가 실제 실행됐다. 원문 `p1-t2.Z5xdEM/lint-fix-tests.log`. 최종 XML timestamp는 repository `2026-10-31T11:31:59`, controller `2026-10-31T11:31:46`다.
|
||||||
|
- 보정 diff를 확인해 신규 테스트 포맷 변경만 있음을 대조했고 `git diff --check` 종료 코드 0을 확인했다. 이번 보정은 formatting-only로 별도 RED/신규 테스트를 추가하지 않고 기존 focused 테스트로 회귀를 확인했다. 전체 suite/build·subagent·git add/commit/amend는 실행하지 않았으며 상위 실행자가 수정분을 stage하고 새 commit을 재시도한다.
|
||||||
|
|
||||||
|
### Task 3: 오디오 추천 EVENT 배너 SQL 적격성
|
||||||
|
|
||||||
|
- [x] **Task 1.3: 오디오 추천 EVENT 배너 SQL 적격성**
|
||||||
|
|
||||||
|
**Goal 실행 `P1-T3`:** 오디오 추천의 기존 `findBanners` 조회에 홈과 같은 이벤트 상세 정책을 limit 전에 적용하고 현재 audio banner 조건과 응답 schema를 보존한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T2` 완료, PRD `REC-EVT-001~007` 확인.
|
||||||
|
- **완료 증거:** 오디오 repository integration test가 홈과 같은 eligibility matrix를 통과하고 `AudioRecommendationsResponse` schema가 유지됨.
|
||||||
|
- **범위 밖:** 홈 query 변경, `AudioRecommendationsResponse` 필드 추가, subBanner·LINK 동작 변경.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||||
|
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
|
||||||
|
- Consumes: 기존 `findBanners(limit: Int, memberId: Long?, canViewAdultContent: Boolean, lang: Lang)` 및 현재 banner language.
|
||||||
|
- Produces: 공개 repository/facade/DTO 서명 불변. `eventNowProvider`를 `nowProvider` 바로 앞 constructor parameter로 추가하고 EVENT 조건만 사용; 기존 `nowProvider` trailing lambda는 banner reservation 전용 유지.
|
||||||
|
|
||||||
|
- [x] **RED:** `shouldApplyAudioEventVisibilityPolicyBeforeLimit` parameterized DB integration test를 추가한다. Task 2와 같은 viewer × audience, 이벤트 active/time/lang, admin 제한, 누락 EVENT, non-EVENT, banner adult 교차 조합을 flush/clear 후 검사한다. 후보 1이 숨겨지고 후보 2가 limit=1로 반환되는 경우와 전부 숨겨져 `[]`인 경우를 검증한다. 기존 positive EVENT fixtures를 고정 event clock에서 적격인 값으로 맞추되 검증 강도는 유지한다. `AudioRecommendationEndToEndTest.shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema`를 추가해 KO/JA country와 Accept-Language 불일치 및 조회자별 결과, `aiCharacters` 부재를 확인한다.
|
||||||
|
- [x] **RED 확인:** repository와 새 end-to-end endpoint 테스트를 함께 실행해 현재 query가 기간·언어·성인 audience를 강제하지 않아 추가한 assertion이 실패함을 확인한다. 기존 audio 필드 부재 assertion은 현재도 통과하는 계약 보존 검사로 구분한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
|
||||||
|
```
|
||||||
|
- [x] **GREEN:** 오디오 repository의 기존 EVENT active branch에만 홈과 같은 `QMember`/`JPAExpressions` audience (`null || ADMIN || (true && Auth exists) || (false && Auth absent)`), event time, event language predicate를 추가한다. ADMIN은 audience만 제외한다. 기존 `adultBannerCondition(canViewAdultContent)`, tab/lang/reservation/target predicate 및 order/limit은 보존한다.
|
||||||
|
- [x] **GREEN 확인:** 같은 두 focused class를 다시 실행해 repository와 endpoint assertion의 성공을 확인한다. 별도 event clock의 system-default 시각 및 nanos 경계와 banner UTC reservation clock을 서로 다르게 설정해 독립성을 검증한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
|
||||||
|
```
|
||||||
|
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 random tie 고정 순서 assertion을 피한다. 아래 두 focused class를 실행하고 기존 DTO field set 및 repository 생성자의 trailing-lambda 호환성을 확인한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest"
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### P1-T3 검증 기록 — 2026-10-31
|
||||||
|
|
||||||
|
- 상태: 오디오 EVENT 정책 구현·두 focused class 전체·`ktlintCheck`·작성자 자체 검토 완료. 상위 실행자의 독립 리뷰·commit·최종 Gate는 대기한다. 변경은 본 Task의 제품 1개·테스트 2개와 본 문서의 활성 Goal/Task3 체크리스트·증거에 한정했다. 홈 구현 `1f24a4b7`은 의미상 기준으로 읽기만 했다.
|
||||||
|
- 각 shell에서 `TMPDIR=/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/recommendation-ulw.nT00w2/p1-t3.6bJWqa`, `NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`, `JAVA_HOME=/Users/klaus/.local/share/mise/installs/java/liberica-17.0.15+10`를 export했다. 아래 동일 명령을 매번 600000ms 제한으로 실행했고 `set -o pipefail`/`tee`로 원문을 보존했다. 화면만 `rg --line-buffered '(^> Task|FAILED|tests completed|BUILD )'`로 요약했다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest
|
||||||
|
```
|
||||||
|
|
||||||
|
- BASELINE: 제품·테스트 수정 전 repository 29개 + E2E 2개 = 31개 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 44s`. 원문 `baseline.log`.
|
||||||
|
- 최초 RED: 기존 생성자 그대로, 이벤트 기간 2000~2100 및 실제 Member/Auth를 저장·flush/clear한 테스트로 repository 51개 중 14개 + E2E 10개 중 8개 = 61개 중 22개 assertion 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 55s`. `expected all|false 또는 all|true / actual all|false|true`, 숨김 이벤트 `expected [] / actual [hidden]`, limit=1 `expected visible-second / actual hidden-first`, endpoint 배너 `expected 2 또는 3 / actual 6`을 확인했다. AI 필드 부재 assertion은 배너 수 assertion보다 먼저 통과했다. 원문 `red.log`.
|
||||||
|
- 경계 RED: 최초 assertion RED 이후에만 optional `eventNowProvider`를 추가하고 주입했다. predicate 구현 전 고정 event clock ±1일 fixture 및 nanos 경계 15개를 추가해 repository 66개 중 20개 + E2E 10개 중 8개 = 76개 중 28개 assertion 실패, errors/skipped 0, 종료 코드 1, `BUILD FAILED in 2m`. BEFORE/AFTER 6개가 추가로 `expected [] / actual [boundary]`였고 DB 시각의 정확한 왕복·포함 경계 9개는 통과했다. 원문 `red-boundaries.log`.
|
||||||
|
- GREEN: 홈과 같은 EVENT 활성·포함 기간·resolved `lang`·audience 조건을 추가했다. 익명은 null/false, 로그인 회원은 null 또는 `QMember("eventViewer")` ADMIN EXISTS 또는 true/Auth EXISTS 또는 false/Auth NOT EXISTS이며 Auth는 `auth.member.id == memberId`를 따른다. repository 66개 + E2E 10개 = 76개 모두 통과, failures/errors/skipped 0, 종료 코드 0, `BUILD SUCCESSFUL in 54s`. `compileKotlin`·`compileTestKotlin`·`:test`가 실제 실행됐다. 원문 `green.log`; XML timestamp는 E2E `2026-10-31T11:42:37`, repository `2026-10-31T11:42:45`다. 최종 결과는 `build/test-results/test/`의 두 클래스 XML 및 `build/reports/tests/test/`에 있고 임시 root의 `evidence.md`에도 요약했다.
|
||||||
|
- DB 검증: 4 viewer × KO/JA × null/false/true audience, 인증+`canViewAdultContent=false` / 미인증+true, 다른 회원의 인증 row, ADMIN audience만 우회, 누락·비활성·미래·만료·잘못된 언어 이벤트, hidden-first/eligible-second limit=1, 모두 숨김 `[]`, LINK/CREATOR/SERIES 불변과 배너 자체 활성·tab2·언어·UTC 예약·성인 조건을 확인했다. 생성 SQL에서도 EVENT 조건 및 `member.id/role` EXISTS와 `auth.member_id` EXISTS/NOT EXISTS가 `orders asc, rand() asc limit ?` 앞에 있음을 직접 읽었다.
|
||||||
|
- clock/저장 정밀도: event clock `2026-10-31T23:59:59`의 nanos 0/123456789/999999999와 banner UTC clock `2026-10-27T23:59:59.999999999`를 다르게 주입했다. H2 event start/end만 `timestamp(9)`로 설정하고 flush/clear/reload 값과 고정 시각을 정확히 비교했다. 이벤트 시작·종료·양끝 동일 포함, 1ns 직전/직후 제외 및 배너 예약 시작=종료의 기존 초 경계를 함께 검증했다. 이 H2 설정은 predicate 나노초 비교 증거이지 운영 저장 정밀도 증거가 아니다. 운영 Event DDL은 저장소에서 찾지 못했으며 운영 schema·timezone·formatter는 변경하지 않았다.
|
||||||
|
- endpoint 검증: 기존 `@SpringBootTest`/MockMvc와 별도 `TransactionTemplate` fixture로 실제 persistence를 거쳐 익명/미인증/인증/ADMIN × KR/JP 및 반대 Accept-Language ja/ko 8개가 적격 이벤트의 정확한 ID·개수·순서를 반환하고 `aiCharacters`는 필드 자체가 없음을 확인했다. Auth/preference row를 저장·flush/clear·commit하고 강제 국가 매핑 ID는 피했다. 새 fixture는 이 클래스 전용 H2 DB의 기존 배너를 정리해 parameterized 실행을 격리한다. 기존 추천 섹션·번역 테스트는 약화하지 않았다.
|
||||||
|
- lint/자체 검토: `./gradlew ktlintCheck` 종료 코드 0, `BUILD SUCCESSFUL in 24s`이며 test/main lint 실행이 성공했다. 원문 `ktlint.log`. 제품·테스트 diff와 facade→query service→repository의 memberId/resolved lang 흐름, 상세 서비스/AuthRepository, 기존 DTO를 대조했다. 공개 port 인자·DTO·홈·상세·subBanner·설정·schema는 불변이고 event clock은 query당 한 번, 절삭 없이 사용한다. 마지막 UTC `nowProvider` trailing lambda와 Spring 기본 인자 생성 모두 컴파일·실행됐다. 추가 중복 정리가 필요하지 않아 GREEN 후 제품·테스트를 다시 변경하지 않았으며 위 GREEN을 최종 focused 증거로 사용한다. 자체 검토 차단 이슈 없음, `git diff --check` 종료 코드 0.
|
||||||
|
- 검증 제한: 위임 중단 조건과 단일 오디오 조회 변경 범위에 따라 전체 suite/build·실제 서버 HTTP QA·홈/상세/subBanner 직접 회귀·독립 리뷰는 상위 Gate에 남긴다. 실행 증거는 H2 QueryDSL과 MockMvc이며 운영 MySQL 또는 실제 HTTP 검증으로 주장하지 않는다. LSP 도구는 없고 실행하지 않았다. BASELINE부터의 Gradle deprecation 및 embedded Redis 종료 reconnect 경고는 GREEN에도 있으며 테스트 실패는 없다. GREEN XML에는 오디오 변경과 무관한 H2 `chat_character_value` DDL 및 scheduler `skip locked` 로그도 있으나 suite errors/failures는 0이다. commit/add/push·subagent·임시 증거 삭제는 하지 않았다.
|
||||||
|
|
||||||
|
### Phase 1 Gate: 양 endpoint 정책 및 회귀 판정
|
||||||
|
|
||||||
|
**Goal 실행 `P1-GATE`:** 모든 확정 요구사항이 두 추천 endpoint의 응답과 repository 정책에서 일치하고 범위 밖 응답이 보존되는지 판정한다.
|
||||||
|
|
||||||
|
- **시작 조건:** `P1-T1`, `P1-T2`, `P1-T3` 완료 및 각 focused evidence 기록.
|
||||||
|
- **완료 증거:** 아래 회귀 테스트와 `ktlintCheck` 통과, 정책 매트릭스·AI 미호출·schema 유지 증거 Progress 기입.
|
||||||
|
- **범위 밖:** API 계약, 전용 AI endpoint, event detail 정책 자체 변경; 테스트 삭제·skip·완화.
|
||||||
|
- **TDD 예외 사유:** Gate는 새 동작을 구현하지 않는 validation-only 판정 goal이다. Gate에서 처음 발견된 요구사항 assertion은 소유 Task (`P1-T1`의 홈 테스트, `P1-T2`의 홈 테스트, `P1-T3`의 오디오 테스트)의 RED cycle에 추가한 뒤 gate를 재실행한다.
|
||||||
|
- **대체 검증 방법:** 아래 focused·직접 영향 회귀 명령 및 수용 기준 대조 결과를 Progress에 기록한다.
|
||||||
|
|
||||||
|
**수행 검증 명령:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest"
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest" --tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.RecommendationSubBannerIntegrationTest"
|
||||||
|
./gradlew test --tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest"
|
||||||
|
./gradlew ktlintCheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Event detail regression은 기존 상세 endpoint가 바뀌지 않았음을 검증한다. subBanner regression은 두 추천 API의 기존 subBanner 동작을 검증한다. API test는 익명·미인증·인증·ADMIN, KO/JA와 country/request-language 불일치, 배너 자체 성인/활성 조건, audio에 AI 필드 없음 및 dedicated AI paging 존속을 확인한다. 전체 suite/build는 위 변경으로 공통 경계까지 영향받거나 targeted 회귀로 실패 원인을 판정할 수 없을 때만 실행하고 생략 근거를 기록한다. 이 문서 작성에서는 어떤 명령도 실행하지 않는다.
|
||||||
|
|
||||||
|
- [x] 위 focused repository/API 및 직접 영향 회귀 범위를 최종 전체 테스트에서도 확인했다.
|
||||||
|
- [x] 두 endpoint의 이벤트 정책 매트릭스, 시간 clock 독립성, limit 전 필터, 빈 결과 동작을 기록했다.
|
||||||
|
- [x] 홈 aggregate AI empty/query 미호출과 dedicated AI endpoint 보존을 검증했다.
|
||||||
|
- [x] 오디오 schema 및 양쪽 `subBanners`가 변하지 않았음을 검증했다.
|
||||||
|
- [x] `ktlintCheck`와 전체 빌드 결과를 기록했다. 전체 회귀를 생략하지 않았으며 기본 환경 실패 및 UTC 재검증 조건을 구분했다.
|
||||||
|
|
||||||
|
## Dependency Order
|
||||||
|
|
||||||
|
| 순서 | Goal | 의존성 | 상태 |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 1 | `P1-T1` 홈 aggregate AI empty | 구현 승인 | 완료 · `4a6b2ee9` |
|
||||||
|
| 2 | `P1-T2` 홈 EVENT query | `P1-T1` | 완료 · `1f24a4b7` |
|
||||||
|
| 3 | `P1-T3` 오디오 EVENT query | `P1-T2` | 완료 · `969f999a` |
|
||||||
|
| 4 | `P1-GATE` endpoint parity/regression | `P1-T1`~`P1-T3` | 완료 · UTC 전체 빌드 및 실제 HTTP PASS |
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### 구현 실행 승인 — 2026-10-31
|
||||||
|
|
||||||
|
- 사용자 `문서대로 진행` 및 `ulw` 지시에 따라 `P1-T1`부터 순서대로 실행한다. 아래 기존 문서 작성 기록은 당시 증거로 보존한다.
|
||||||
|
- 현재 `test` 브랜치에서 진행하며 새 worktree를 자동 생성하지 않는다. 구현 전 tracked 파일 변경은 없고 기존 문서 두 개만 untracked였다.
|
||||||
|
- 최종 Gate는 사용자 요청에 따라 전체 테스트·빌드·실제 HTTP QA를 포함한다. `lsp_diagnostics`는 현재 도구에 없으므로 실행했다고 보고하지 않고 Kotlin 컴파일·ktlint 결과로 가능한 진단을 기록한다.
|
||||||
|
|
||||||
|
### 초기 문서 작성 당시 기록 (구현 승인 전)
|
||||||
|
|
||||||
|
- 문서 작성: PRD와 plan-task를 정합시켰고 sample plan, EventDetailService, 두 repository, DTO 및 관련 테스트 경로를 확인했다.
|
||||||
|
- 구현 상태: 미착수. 제품 코드·테스트·설정 변경 없음.
|
||||||
|
- 검증 상태: PRD 독립 검토, 계획 자체 검토 및 두 문서 공백 검사를 완료했다. Gradle test, `ktlintCheck`, build는 실행하지 않았다.
|
||||||
|
- 다음 조건: 사용자의 별도 구현 실행 승인 후 `P1-T1`부터 하나씩 활성화한다.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-10-31 | `PLAN-001` | 범위 확정 | 기존 public service/port signature를 유지하고 두 repository의 기존 QueryDSL EVENT 조건에만 SQL predicate를 적용한다. | 기존 repository 구조를 따른 구현 계획 선택 | `P1-T2`, `P1-T3` |
|
||||||
|
| 2026-10-31 | `PLAN-002` | 범위 확정 | 이벤트 시간용 system-default clock을 각 repository에 분리 주입하며 기존 UTC `nowProvider`는 예약 필터에 유지한다. 시간은 query당 한 번 캡처하고 nano를 절삭하지 않는다. | `EventDetailService`의 local `LocalDateTime.now()`와 repository의 UTC reservation clock 사용처 대조 | `P1-T2`, `P1-T3` |
|
||||||
|
| 2026-10-31 | `PLAN-003` | 보존 | 인증은 `AuthRepository.getAuthIdByMemberId`와 같은 `Auth.member.id` 존재 subquery로 판단하며 ADMIN은 이벤트 audience predicate만 우회한다. | AuthRepository의 inner join 및 member ID 필터와 제품 결정 | `P1-T2`, `P1-T3` |
|
||||||
|
|
||||||
|
## 문서 검증 기록
|
||||||
|
|
||||||
|
### P1-GATE 실행 기록 — 2026-10-31
|
||||||
|
|
||||||
|
- 세 구현 Task의 독립 리뷰가 모두 명세·품질 승인으로 종료됐다. T2 포맷만 보정한 후 178개 회귀와 ktlint를 재검증했고 커밋 훅도 통과했다.
|
||||||
|
- 실제 HTTP 검증: `http-qa.md`에 기록한 테스트 서버 요청 104건·판정 216건 모두 PASS. 임시 harness의 Redis 설정 호출 오류를 고친 후 제품 코드 수정 없이 30초에 통과했다.
|
||||||
|
- 최초 `./gradlew build`는 bootJar 생성 및 lint 단계를 거친 뒤 `:test` 중 도구 제한 600초로 종료됐다. 테스트 assertion 실패나 성공으로 기록하지 않는다. 남은 test executor가 없음을 확인하고 1800초 제한의 전체 빌드로 재실행 중이다.
|
||||||
|
- 최종 Oracle 리뷰 및 전체 빌드 결과, 자원 정리 확인은 완료 후 아래에 누적한다.
|
||||||
|
|
||||||
|
#### 전체 실행 실패 분리 및 최종 구현 리뷰
|
||||||
|
|
||||||
|
- 1800초 제한의 기본 환경 전체 `./gradlew build`는 18분 20초에 종료 코드 1로 끝났다. XML 336개, 테스트 2892개 중 2890개 통과·2개 실패·오류/skip 0이다.
|
||||||
|
- 실패 1: `LegacyCurrentNameReadIntegrationTest.shouldLocalizeCreatorDonorsIndependentlyOfTheChannelOwner`의 `totalCount`는 기대 2, 실제 0이었다. 해당 기존 테스트와 후원 서비스는 이번 변경 대상이 아니다. `ChannelDonationService`는 KST 월 범위를 UTC 값으로 비교하지만 fixture는 기본 시간대 `LocalDateTime`으로 생성한다. 월말 KST 늦은 시각에 나타나는 환경 의존성을 확인했다.
|
||||||
|
- 실패 2: init script 없이 일반 빌드에서 발견된 임시 `RecommendationSocketQaTest` 컴파일 잔존물이었다. QA 전용 system property가 없어 HTTP 실행 전 NPE가 났다. 작업 소유 `RecommendationSocketQaTest*.class`만 삭제하고 `build/classes`·`build/tmp/kapt3`에서 해당 클래스가 남지 않았음을 확인했다. 테스트 제외 옵션이나 skip을 추가하지 않았다.
|
||||||
|
- 후원 테스트 한 개를 기본 환경에서 분리 실행해 동일한 기대 2/실제 0을 재현했다. 같은 코드·assertion을 `JAVA_TOOL_OPTIONS=-Duser.timezone=UTC ./gradlew test --rerun --tests 'kr.co.vividnext.sodalive.v2.creatorname.application.LegacyCurrentNameReadIntegrationTest.shouldLocalizeCreatorDonorsIndependentlyOfTheChannelOwner'`로 실행하면 42초, 종료 코드 0이다. 범위 밖 테스트나 서비스 코드를 고치지 않았다.
|
||||||
|
- Ruling: 운영·저장소 시간대 설정은 보존하고 최종 전체 검증 명령에만 UTC JVM 환경을 명시한다. 근거는 위 단일 실패의 환경 분리 결과다. 한계는 기본 KST 환경의 월말 기존 테스트 결함이 해결되는 것은 아니라는 점이며 최종 보고에 남긴다.
|
||||||
|
- Oracle 작업은 완료 알림 후 결과 조회가 두 번 실패하고 세션도 조회되지 않아 승인 근거로 사용할 수 없었다. 사용 가능한 고성능 reviewer로 한 번 대체한 최종 읽기 전용 리뷰는 세 커밋 전체 제품 3개·테스트 5개를 검토해 명세·품질을 무조건 승인했고 구현 차단 사항은 없었다. 변경된 테스트 261개 및 인접 회귀 94개, 총 355개가 직전 전체 실행에서 통과했음을 XML로 대조했다.
|
||||||
|
- QA 판정과 기본 환경 전체 실행의 실패 사실은 보존한다. UTC 환경의 전체 재검증은 별도 결과로 아래에 누적하며 기본 환경의 전체 성공으로 바꾸어 기록하지 않는다.
|
||||||
|
|
||||||
|
#### 최종 UTC 검증 완료
|
||||||
|
|
||||||
|
- 명령: `JAVA_TOOL_OPTIONS=-Duser.timezone=UTC ./gradlew build`. init script·테스트 skip/filter·rerun 옵션 없이 실행했다. 실행 후 환경 변수가 후속 셸에 남지 않았음을 확인했다.
|
||||||
|
- 결과: `BUILD SUCCESSFUL in 16m 34s`, 종료 코드 0. XML 335개·테스트 2891개 모두 이번 실행에서 갱신됐고 failures/errors/skipped 0이다. 임시 QA 클래스는 실행 대상에 없다.
|
||||||
|
- `LegacyCurrentNameReadIntegrationTest`도 UTC에서 8개 전부 통과했다. 기본 KST 월말 환경의 기존 실패는 별도 후속 사항으로 남긴다.
|
||||||
|
- main ktlint 검사 완료·위반 0. test 및 Kotlin script 검사는 변경 없는 입력으로 UP-TO-DATE이며 해당 보고서도 위반 0이다. 앞선 Task3의 명시적 `ktlintCheck` 역시 통과했다.
|
||||||
|
- bootJar: `build/libs/sodalive-0.0.1-SNAPSHOT.jar`, 148263078 bytes, SHA-256 `627ae298d255d00586b217b00e481f419484426bcfd4728e2de119ff13d44366`. 최초 기본 빌드에서 생성한 동일 제품 산출물이며 마지막 빌드에서는 UP-TO-DATE였다.
|
||||||
|
- 실제 HTTP 104건·216판정 PASS, 개별 및 최종 구현 리뷰 승인으로 요청 범위의 Gate를 완료한다. LSP 도구 없음 및 운영 MySQL Event 저장 정밀도 미확인은 그대로 명시한다.
|
||||||
|
- 마지막 빌드의 runner 76003·Gradle client 76012·test executor 76029가 모두 종료됐음을 확인했다. 공유 Gradle/Kotlin daemon은 건드리지 않았다. 임시 디렉터리 정리 영수증은 아래에 이어 기록한다.
|
||||||
|
- 정리 완료: 작업 소유 `recommendation-ulw.nT00w2`와 그 아래 6개 Task/QA 디렉터리 및 notepad를 삭제하고 부재를 확인했다. 임시 QA 컴파일 클래스도 제거됐다. 마지막 프로세스 조회에서 Gradle wrapper·test executor·Redis 서버는 남지 않았다. 공유 캐시·daemon 및 기존 다른 작업 디렉터리는 변경하지 않았다.
|
||||||
|
- 원문 임시 로그를 정리하기 전에 실행 명령·집계·실패 원인·재검증 조건은 본 계획에, 실제 HTTP 응답 발췌는 `http-qa.md`에 보존했다. 영구 테스트 XML/HTML과 bootJar는 기존 `build/` 산출물로 유지한다.
|
||||||
|
|
||||||
|
### 초기 문서 전용 검증 기록 (구현 승인 전)
|
||||||
|
|
||||||
|
- 2026-10-31 문서 전용 검증: `REC-AI-001`은 `P1-T1`, `REC-AI-002`는 `P1-T3` 및 Gate, `REC-EVT-001~007`은 `P1-T2`·`P1-T3` 및 Gate로 추적함을 확인했다. 파일 책임표·검증 명령·기존 API 계약도 대조했다.
|
||||||
|
- PRD 독립 builder check는 중요한 미결정 사항 및 소스 모순이 없다고 판정했다. 계획은 자체 검토로 RED/GREEN 순서, 기존 테스트 보존, 시각 기준 분리 및 요구사항 추적을 확인했다. 최종 계획에 대한 별도 독립 리뷰나 구현 동작 검증으로 확대 해석하지 않는다.
|
||||||
|
- TDD 예외 사유: 현재 산출물은 문서 두 개뿐이다. 대체 검증은 정합성 대조와 신규 파일 각각의 공백 검사다.
|
||||||
|
- 아래 명령에서 공백 오류 출력은 없었다. 최종 재확인한 `--no-index` 종료 코드는 신규 파일 차이를 뜻하는 1이며, 이전의 종료 코드 0 기록은 정정한다. 신규 파일은 일반 `git diff --check`만으로 검사되지 않으므로 `/dev/null`과 직접 비교했고, staging 후 `git diff --cached --check`로 다시 검증한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_MASTER=1 git diff --no-index --check /dev/null docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/prd.md
|
||||||
|
GIT_MASTER=1 git diff --no-index --check /dev/null docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/plan-task.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- 문서 전용 지침에 따라 Gradle test/build/lint와 실행 QA를 생략했다. 위 Phase 명령은 향후 구현 검증 계획일 뿐 실행 결과가 아니다. 구현 체크박스와 기능 수용 기준은 미완료다.
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# 추천 화면 AI 캐릭터 및 이벤트 배너 노출 정책 PRD
|
||||||
|
|
||||||
|
## 문서 정보
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| 문서 상태 | 구현 및 검증 완료 · 운영 배포 제외 · 전체 빌드는 UTC 환경 기준 |
|
||||||
|
| 작성일 | 2026-10-31 |
|
||||||
|
| 최종 수정일 | 2026-10-31 |
|
||||||
|
| 대상 제품 | 홈 추천 및 콘텐츠 추천 API의 AI 캐릭터·이벤트 배너 노출 |
|
||||||
|
| 작성자·결정권자 | 사용자 결정 반영 |
|
||||||
|
| 관련 API Contract | 별도 문서 없음. 기존 응답 계약 유지 |
|
||||||
|
| 관련 구현 계획 | [`docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/plan-task.md`](plan-task.md) |
|
||||||
|
| 관련 review | 없음 |
|
||||||
|
|
||||||
|
### 요구사항 상태
|
||||||
|
|
||||||
|
사용자 정책은 확정이며 기술 범위 선택은 별도 Decision Log에 분리했다. 사용자 승인으로 오인하지 않는다. 미결 정책 질문 없음, 모호성 0.00.
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
홈 추천 aggregate 응답에서 AI 캐릭터 목록은 모든 조회자에게 빈 배열로 제공한다. 홈 및 오디오 추천의 기존 이벤트 배너는 배너 자체의 현재 노출 조건을 유지하면서 연결된 이벤트 상세 조회와 같은 대상자 정책을 만족할 때만 반환한다. 응답 구조와 비이벤트 추천 콘텐츠는 변경하지 않는다.
|
||||||
|
|
||||||
|
## 2. 문제 정의
|
||||||
|
|
||||||
|
- 홈 추천 aggregate에 AI 캐릭터 목록이 포함되어 있어 해당 노출을 중단해도 응답 계약은 유지할 필요가 있다.
|
||||||
|
- 이벤트 배너는 배너 자체 조건만으로는 연결된 이벤트의 활성 상태, 기간, 국가 기반 언어, 인증 성인 대상 정책을 모두 보장하지 못한다.
|
||||||
|
- 이벤트 상세 화면에서 볼 수 없는 이벤트 배너가 추천 응답에 노출되지 않도록 두 조회 경로의 정책을 일치시킨다.
|
||||||
|
|
||||||
|
## 3. 목표
|
||||||
|
|
||||||
|
- 홈 aggregate 응답에서 `aiCharacters`를 항상 `[]`로 반환한다.
|
||||||
|
- EVENT 유형이며 event FK가 있는 추천 배너는 기존 이벤트 상세 조회의 노출 정책을 만족할 때만 반환한다.
|
||||||
|
- 기존 endpoint, response schema, 다른 배너 조건, subBanner 응답을 보존한다.
|
||||||
|
|
||||||
|
## 4. 비목표
|
||||||
|
|
||||||
|
- `AudioRecommendationsResponse`에 `aiCharacters` 필드를 추가하지 않는다.
|
||||||
|
- 전용 AI 캐릭터 endpoint의 정책이나 구현을 바꾸지 않는다.
|
||||||
|
- AI 추천 snapshot, scheduler, 저장 데이터를 바꾸지 않는다.
|
||||||
|
- LINK 유형 URL 파싱, 일반 배너, `subBanners`(event FK가 없는 데이터 포함)의 정책은 바꾸지 않는다.
|
||||||
|
- 이벤트 상세 endpoint의 응답, 오류 정책, 보안 설정을 바꾸지 않는다.
|
||||||
|
- legacy 배너 endpoint, DB schema, dependency, 전역 timezone 또는 formatter를 바꾸지 않는다.
|
||||||
|
|
||||||
|
## 5. 사용자와 권한
|
||||||
|
|
||||||
|
| 조회자 | 인증 판정 | 이벤트의 `isAdult` 노출 대상 |
|
||||||
|
|---|---|---|
|
||||||
|
| 익명 | 미인증 | `null`, `false` |
|
||||||
|
| 로그인했으나 AuthRepository 인증 기록 없음 | 미인증 | `null`, `false` |
|
||||||
|
| 인증 기록이 있는 회원 | 인증 | `null`, `true` |
|
||||||
|
| ADMIN | AuthRepository 인증 여부와 무관 | 이벤트 대상자 조건만 우회 |
|
||||||
|
|
||||||
|
인증 여부는 `AuthRepository.getAuthIdByMemberId(memberId) != null`로 판정한다. `canViewAdultContent`는 preference·country·auth 규칙에 따라 계산된 성인 노출 가능 여부이며, raw preference나 이벤트 인증 여부와 동일하지 않다. 두 판정은 다를 수 있으므로 이벤트의 성인 대상 정책은 반드시 인증 기록으로 판단한다.
|
||||||
|
|
||||||
|
## 6. 핵심 동작
|
||||||
|
|
||||||
|
1. `GET /api/v2/home/recommendations`를 조회하면 기존 응답에 `aiCharacters: []`가 포함된다.
|
||||||
|
2. 두 추천 endpoint에서 기존 배너 조건으로 후보 배너를 조회한다.
|
||||||
|
3. EVENT 배너의 연결 이벤트가 존재하고 이벤트 상세 노출 정책을 통과하면 배너 후보를 유지한다.
|
||||||
|
4. 정책에 맞지 않거나 연결 이벤트가 없는 EVENT 배너는 해당 배너만 제외한다. 응답의 나머지 영역은 정상 반환한다.
|
||||||
|
5. 적격 배너 수가 0이면 `banners: []`를 반환한다.
|
||||||
|
|
||||||
|
## 7. 기능 요구사항
|
||||||
|
|
||||||
|
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `REC-AI-001` | 확정 | 홈 aggregate에서 AI 캐릭터를 제공하지 않고 기존 목록 필드를 빈 배열로 유지한다. | 익명, 일반 회원, ADMIN 요청 모두 `data.aiCharacters`가 존재하고 JSON 배열 `[]`이며 null이 아니고 생략되지 않는다. aggregate 경로에서 AI 추천 query/fallback이 호출되지 않는다. | `GET /api/v2/home/recommendations`, `P1-T1` |
|
||||||
|
| `REC-AI-002` | 확정 | 오디오 추천 응답의 현재 schema를 유지한다. | `AudioRecommendationsResponse`에 `aiCharacters`가 없으며 이 변경으로 필드가 추가되지 않는다. | `GET /api/v2/audio/recommendations`, `P1-T3`, `P1-GATE` |
|
||||||
|
| `REC-EVT-001` | 확정 | 두 추천 endpoint의 EVENT 배너는 연결 이벤트가 존재하고 활성일 때만 노출한다. | 연결 이벤트 부재 또는 비활성 이벤트이면 배너 전체를 제외한다. 다른 배너와 API 처리는 계속된다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `REC-EVT-002` | 확정 | 이벤트 시작·종료 시각 경계는 양끝을 포함한다. | 비교 의미는 상세 조회와 동일하다. `startDate == now` 및 `endDate == now`는 적격, `now`가 범위 밖이면 제외한다. 검증은 시각 정밀도를 포함해 결정적으로 수행한다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `REC-EVT-003` | 확정 | 연결 이벤트의 언어 적격성은 회원의 해석 국가로 판정한다. | `resolveCountryCode(member) == JP`이면 연결 이벤트 `Lang.JA`, 그 외는 `Lang.KO`여야 한다. request `LangContext`가 결과를 덮어쓰지 않는다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `REC-EVT-004` | 확정 | 이벤트 성인 대상은 인증 기록 여부로 판정한다. | `isAdult == null`은 모두, `true`는 인증 회원만, `false`는 미인증 조회자만 적격이다. 익명은 미인증이다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `REC-EVT-005` | 확정 | ADMIN은 이벤트 대상자 제한만 우회한다. | ADMIN도 이벤트 존재, 활성, 시간 범위, 국가 기반 언어 조건을 통과해야 한다. ADMIN 우회가 배너 자체 조건을 우회하지 않는다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `REC-EVT-006` | 확정 | 이벤트 정책은 기존 배너 정책과 교집합으로 적용한다. | banner-own 활성, tab, language, reservation, 성인 노출 가능 여부, creator-block 조건이 모두 기존대로 유지된다. 이벤트가 부적격이면 배너를 제외하고 기존 정렬 후 limit 20 적용 순서 및 동순위 random tie 동작을 유지한다. | `P1-T2`, `P1-T3` |
|
||||||
|
| `REC-EVT-007` | 확정 | 이벤트 정책 필터링은 개별 후보에 적용한다. | 부적격 EVENT 배너 하나 때문에 전체 API가 실패하지 않는다. 모든 후보 제외 시 빈 배열을 반환한다. LINK 및 subBanner 결과는 이 필터로 변경되지 않는다. | `P1-T2`, `P1-T3` |
|
||||||
|
|
||||||
|
## 8. API 계약
|
||||||
|
|
||||||
|
| 요구사항 | Method | Path | 응답 계약 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `REC-AI-001`, `REC-EVT-001~007` | GET | `/api/v2/home/recommendations` | 기존 aggregate schema 유지. `aiCharacters`는 `[]`; `banners`에는 기존 조건과 이벤트 정책을 모두 통과한 배너만 포함 |
|
||||||
|
| `REC-AI-002`, `REC-EVT-001~007` | GET | `/api/v2/audio/recommendations` | 기존 `AudioRecommendationsResponse` schema 유지. `aiCharacters` 필드 없음. 기존 배너 중 이벤트 정책 부적격 배너만 제외 |
|
||||||
|
|
||||||
|
응답 envelope, 오류 계약, 인증 공개 범위는 변경하지 않는다. 이벤트 대상자 판단에 필요한 이벤트 정보가 없거나 정책을 만족하지 않으면 해당 배너를 노출하지 않는다. 기존 상세 조회 endpoint의 오류 응답을 추천 API 오류로 전파하지 않는다.
|
||||||
|
|
||||||
|
## 9. 데이터 및 정책 규칙
|
||||||
|
|
||||||
|
- 배너별 기존 필터는 유지하며, 이벤트 필터는 EVENT 유형에만 적용한다.
|
||||||
|
- 이벤트 상세 정책은 이벤트 존재, `isActive`, `startDate <= now <= endDate`, 해석 국가의 언어, 인증 상태에 따른 성인 대상 검사를 뜻한다.
|
||||||
|
- 국가 해석은 `MemberContentPreferenceService.resolveCountryCode(member)`를 기준으로 한다. 요청 언어와 별개다.
|
||||||
|
- 이벤트 `isAdult == null`은 인증 여부와 관계없이 허용한다. `true`와 `false`는 인증 기록의 존재 여부와 정확히 일치해야 한다.
|
||||||
|
- 기존 배너 자체 필터에는 preference·country·auth 규칙으로 계산되는 `canViewAdultContent` 성인 노출 가능 여부를 그대로 사용한다. 이는 raw preference나 이벤트 인증 판정과 동일하지 않으며 이벤트 인증 검사를 대체하지 않는다.
|
||||||
|
- 상세 조회와 같은 시간 비교 의미를 사용하되 전역 시간대·formatter 및 기존 banner reservation 시간 정밀도 의미는 변경하지 않는다.
|
||||||
|
- 기존 배너 정렬과 무작위 동순위 처리를 보존하고 적격 결과에 현재 limit 20을 적용한다.
|
||||||
|
|
||||||
|
## 10. 보안 및 데이터 취급
|
||||||
|
|
||||||
|
새 인증 경로나 저장 값은 만들지 않는다. 이벤트 공개 판정은 기존 `AuthRepository` 기록을 사용한다. 익명 조회의 인증 상태는 false로 취급한다. 추천 응답에 이벤트 상세 오류나 다른 사용자의 민감 데이터를 추가하지 않는다.
|
||||||
|
|
||||||
|
## 11. 품질 및 검증 기준
|
||||||
|
|
||||||
|
- 이벤트의 시작/종료 정확한 경계, 한쪽이라도 범위를 벗어난 경우를 결정적으로 검증한다.
|
||||||
|
- 인증 상태와 성인 선호 설정이 서로 다른 경우를 포함해 대상자 정책을 검증한다. 예: 비인증 JP 회원이 성인 콘텐츠 선호를 켠 경우와 인증 회원이 이를 끈 경우에도 이벤트 인증 판정은 Auth 기록을 따른다.
|
||||||
|
- JP/비JP와 request `LangContext` 불일치를 검증한다.
|
||||||
|
- EVENT 부적격 배너가 제거되면서 정상 배너, 순서, limit 20, 빈 목록 동작이 유지되는지 확인한다.
|
||||||
|
- 홈 aggregate AI query/fallback 비호출, 모든 viewer의 빈 배열, audio schema 불변을 검증한다.
|
||||||
|
- 구현 시 focused 테스트와 영향 범위 회귀 테스트를 수행한다. 이 PRD 작성 자체는 문서 검증만 대상으로 한다.
|
||||||
|
|
||||||
|
## 12. 성공 기준
|
||||||
|
|
||||||
|
### 12.1 기능 수용 기준
|
||||||
|
|
||||||
|
- [x] `REC-AI-001`: 모든 viewer의 홈 응답에 `aiCharacters: []`가 존재하며 aggregate AI query/fallback은 호출되지 않는다.
|
||||||
|
- [x] `REC-AI-002`: 오디오 추천 schema에 AI 캐릭터 필드가 추가되지 않는다.
|
||||||
|
- [x] `REC-EVT-001~005`: 이벤트 존재, 활성, 포함 경계 시간, 국가 언어, 인증 대상자 규칙이 검증된다.
|
||||||
|
- [x] `REC-EVT-006~007`: 기존 배너 조건 교집합, limit/order 보존, 개별 제외 및 빈 배열이 검증된다.
|
||||||
|
- [x] 배너 자체 성인 선호와 이벤트 인증 여부가 다른 조합에서도 각자의 조건이 독립적으로 적용된다.
|
||||||
|
|
||||||
|
### 12.2 추적성 완료 기준
|
||||||
|
|
||||||
|
- [x] 모든 확정 요구사항이 `plan-task.md`의 구현 Task와 자동 검증 증거에 연결된다.
|
||||||
|
- [x] 기존 API schema, subBanner, LINK 및 상세 endpoint 비변경이 검증된다.
|
||||||
|
- [x] 문서 간 정합성과 diff 공백 검사가 완료된다.
|
||||||
|
|
||||||
|
## 13. Open Questions
|
||||||
|
|
||||||
|
없음. 확정 요구사항의 최종 모호성은 0.00이다.
|
||||||
|
|
||||||
|
요구사항 명확성 자체 평가: Goal 1.00 · Scope 1.00 · Constraints 1.00 · Success 1.00 · Context 1.00.
|
||||||
|
|
||||||
|
## 14. 요구사항 추적표
|
||||||
|
|
||||||
|
| 요구사항 범위 | API | 계획 Goal | 자동 검증 | 수동 검증 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `REC-AI-001` | 홈 추천 | `P1-T1` | aggregate 응답/호출 검증 | 홈 aggregate와 전용 목록 비교 |
|
||||||
|
| `REC-AI-002` | 오디오 추천 | `P1-T3`, `P1-GATE` | audio DTO 계약 검증 | 오디오 endpoint 응답에서 필드 부재 확인 |
|
||||||
|
| `REC-EVT-001~007` | 홈·오디오 추천 | `P1-T2`, `P1-T3`, `P1-GATE` | 양쪽 repository 및 API 정책 회귀 검증 | viewer/국가/경계 조합별 응답 확인 |
|
||||||
|
|
||||||
|
## 15. Decision Log
|
||||||
|
|
||||||
|
### 15.1 사용자 결정
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-10-31 | `UD-001` | 확정 | 홈 aggregate 응답의 `aiCharacters`는 빈 배열로 둔다. null로 만들거나 필드를 생략하지 않는다. | 사용자 확인 | `REC-AI-001` |
|
||||||
|
| 2026-10-31 | `UD-002` | 확정 | 이벤트 배너에도 기존 이벤트 상세 조회의 노출 정책을 적용한다. | 사용자 확인 | `REC-EVT-001~007` |
|
||||||
|
|
||||||
|
### 15.2 최소 기술 범위 선택
|
||||||
|
|
||||||
|
아래는 구현 범위 선택이며 사용자 승인으로 인용하지 않는다.
|
||||||
|
|
||||||
|
| 날짜 | ID | 상태 | 선택 | 근거 | 영향 요구사항·범위 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 2026-10-31 | `TD-001` | 범위 확정 | `AudioRecommendationsResponse`에 현재 없는 AI 필드를 만들지 않는다. 두 추천 endpoint의 배너 정책만 다루고 기존 스키마를 유지한다. | 현행 DTO와 API 계약 확인 | `REC-AI-002`, `REC-EVT-*` |
|
||||||
|
| 2026-10-31 | `TD-002` | 범위 확정 | 이벤트 조건은 각 추천 배너 후보의 필터로 적용하고 부적격 배너만 제외한다. 기존 배너 조건과 결합하며 정렬·limit 전 적격성을 판정한다. | 기존 추천 쿼리의 배너 조건 및 limit 흐름 | `REC-EVT-006~007` |
|
||||||
|
| 2026-10-31 | `TD-003` | 제외 | 전용 AI endpoint, AI snapshot/scheduler/data, LINK URL 파싱, subBanner, legacy endpoint, 상세 endpoint 오류·응답, 보안 설정, DB 및 dependency 변경은 이 문서의 구현 대상으로 삼지 않는다. | 요청 범위와 독립된 기존 동작 보호 | Non-Goals |
|
||||||
|
| 2026-10-31 | `TD-004` | 보존 | 이벤트 상세 시간 비교 의미만 따른다. 전역 timezone/formatter와 기존 배너 reservation 정밀도는 바꾸지 않는다. | 상세 서비스 비교식 및 프로젝트 시간 규칙 | `REC-EVT-002`, `REC-EVT-006` |
|
||||||
|
|
||||||
|
## 16. 근거 및 관련 문서
|
||||||
|
|
||||||
|
### 16.1 저장소 근거
|
||||||
|
|
||||||
|
- `docs/sample/sample-prd.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`: PRD 형식, 사전 문서 절차, 문서 전용 검증.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt`: 이벤트 존재, 활성·기간·언어, 관리자 대상 예외와 인증 기록 판정.
|
||||||
|
- `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`: aggregate 조립, AI query/fallback, 배너 limit 20.
|
||||||
|
- `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`: 기존 추천 배너 조건과 정렬/limit.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`: 현재 응답 필드와 오디오 응답의 AI 필드 부재.
|
||||||
|
- `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/member/auth/AuthRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`: 배너 속성, 인증 기록, 국가 해석 계약.
|
||||||
|
|
||||||
|
### 16.2 관련 작업 문서
|
||||||
|
|
||||||
|
- `docs/20261028_일반이벤트상세조회/prd.md`
|
||||||
|
- `docs/20261028_일반이벤트상세조회/plan-task.md`
|
||||||
|
- `docs/20261027_콘텐츠배너예약기간/prd.md`
|
||||||
|
- `docs/20261027_콘텐츠배너예약기간/plan-task.md`
|
||||||
|
- `docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md`
|
||||||
|
- `docs/20260819_추천탭_배너_접속국가별_언어필터/plan-task.md`
|
||||||
|
|
||||||
|
## 17. 문서 검증 기록
|
||||||
|
|
||||||
|
- 2026-10-31: PRD 샘플, 작업 절차, 문서 유지보수 가이드 및 관련 구현 근거를 대조했다.
|
||||||
|
- PRD 독립 검토 결과: 중요한 미결정 사항 및 명확한 소스 모순 없음. 구현 계획은 별도 자체 검토로 요구사항·파일·테스트·Goal 연결을 대조했다.
|
||||||
|
- 두 신규 문서를 각각 `git diff --no-index --check /dev/null`의 비교 대상으로 검사했고 공백 오류 출력이 없었다. 최종 재확인한 종료 코드는 신규 파일 차이를 뜻하는 1이며, 이전의 종료 코드 0 기록은 정정한다. staging 후 `git diff --cached --check`로 별도 확인한다.
|
||||||
|
- 구현 테스트와 Gradle 빌드·테스트·lint는 실행하지 않았다. 문서 전용 변경이므로 실행 검증은 구현 단계에 남기며 기능 수용 기준은 미완료로 유지한다.
|
||||||
|
|
||||||
|
### 구현 후 검증 — 2026-10-31
|
||||||
|
|
||||||
|
- 위 문서 전용 기록 이후 사용자 승인으로 구현했다. 제품 커밋은 `4a6b2ee9`, `1f24a4b7`, `969f999a`이며 개별 TDD·리뷰 기록은 `plan-task.md`를 따른다.
|
||||||
|
- 실제 JWT 및 HTTP 검증은 [http-qa.md](http-qa.md): 104개 요청·216개 판정 모두 PASS.
|
||||||
|
- `JAVA_TOOL_OPTIONS=-Duser.timezone=UTC ./gradlew build`는 16분 34초에 종료 코드 0. 전체 테스트 2891개, failures/errors/skipped 0이며 lint와 bootJar 산출물도 확인했다.
|
||||||
|
- 최종 독립 구현 리뷰는 명세·품질 승인, 차단 사항 없음이다. 기본 KST의 월말 기존 후원 이름 테스트 실패는 수정 범위 밖으로 남겼고 UTC 검증을 기본 환경 성공으로 확대하지 않는다.
|
||||||
|
- 운영 배포·운영 DB 정밀도 및 도구가 없는 LSP 진단은 수행하지 않았다. 운영/저장소 시간대 설정은 변경하지 않았다.
|
||||||
+1
-5
@@ -97,10 +97,7 @@ class HomeRecommendationFacade(
|
|||||||
lang = lang
|
lang = lang
|
||||||
)
|
)
|
||||||
.map { it.toItem() },
|
.map { it.toItem() },
|
||||||
aiCharacters = queryService.findAiCharacterRecommendations(
|
aiCharacters = emptyList(),
|
||||||
limit = HOME_AI_CHARACTER_LIMIT,
|
|
||||||
backfillRandom = true
|
|
||||||
).map { it.toItem() },
|
|
||||||
genreCreators = queryService.findGenreCreatorRecommendations(
|
genreCreators = queryService.findGenreCreatorRecommendations(
|
||||||
memberId = member?.id,
|
memberId = member?.id,
|
||||||
includeAdultGenres = includeAdult,
|
includeAdultGenres = includeAdult,
|
||||||
@@ -357,7 +354,6 @@ class HomeRecommendationFacade(
|
|||||||
private const val HOME_ACTIVE_CREATOR_LIMIT = 10
|
private const val HOME_ACTIVE_CREATOR_LIMIT = 10
|
||||||
private const val HOME_RECENT_DEBUT_CREATOR_LIMIT = 10
|
private const val HOME_RECENT_DEBUT_CREATOR_LIMIT = 10
|
||||||
private const val HOME_FIRST_AUDIO_CONTENT_LIMIT = 10
|
private const val HOME_FIRST_AUDIO_CONTENT_LIMIT = 10
|
||||||
private const val HOME_AI_CHARACTER_LIMIT = 20
|
|
||||||
private const val HOME_GENRE_CREATOR_GENRE_LIMIT = 5
|
private const val HOME_GENRE_CREATOR_GENRE_LIMIT = 5
|
||||||
private const val HOME_GENRE_CREATOR_CREATOR_LIMIT = 8
|
private const val HOME_GENRE_CREATOR_CREATOR_LIMIT = 8
|
||||||
private const val HOME_CHEER_CREATOR_LIMIT = 8
|
private const val HOME_CHEER_CREATOR_LIMIT = 8
|
||||||
|
|||||||
+31
-3
@@ -18,8 +18,10 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri
|
|||||||
import kr.co.vividnext.sodalive.event.EventItem
|
import kr.co.vividnext.sodalive.event.EventItem
|
||||||
import kr.co.vividnext.sodalive.event.QEvent.event
|
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
|
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.auth.QAuth.auth
|
||||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||||
@@ -45,6 +47,7 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
private val contentTranslationRepository: ContentTranslationRepository,
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val cloudFrontHost: String,
|
private val cloudFrontHost: String,
|
||||||
|
private val eventNowProvider: () -> LocalDateTime = { LocalDateTime.now() },
|
||||||
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||||
) : AudioRecommendationQueryRepository {
|
) : AudioRecommendationQueryRepository {
|
||||||
override fun findBanners(
|
override fun findBanners(
|
||||||
@@ -79,7 +82,7 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
audioContentBanner.lang.eq(lang),
|
audioContentBanner.lang.eq(lang),
|
||||||
audioContentBanner.isVisibleAt(nowProvider()),
|
audioContentBanner.isVisibleAt(nowProvider()),
|
||||||
adultBannerCondition(canViewAdultContent),
|
adultBannerCondition(canViewAdultContent),
|
||||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner, lang)
|
||||||
)
|
)
|
||||||
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
||||||
.limit(limit.toLong())
|
.limit(limit.toLong())
|
||||||
@@ -560,7 +563,19 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun activeBannerTargetCondition(memberId: Long?, bannerCreator: QMember, seriesOwner: QMember): BooleanExpression {
|
private fun activeBannerTargetCondition(
|
||||||
|
memberId: Long?,
|
||||||
|
bannerCreator: QMember,
|
||||||
|
seriesOwner: QMember,
|
||||||
|
lang: Lang
|
||||||
|
): BooleanExpression {
|
||||||
|
val eventNow = eventNowProvider()
|
||||||
|
val eventCondition = audioContentBanner.type.eq(AudioContentBannerType.EVENT)
|
||||||
|
.and(event.isActive.isTrue)
|
||||||
|
.and(event.startDate.loe(eventNow))
|
||||||
|
.and(event.endDate.goe(eventNow))
|
||||||
|
.and(event.lang.eq(lang))
|
||||||
|
.and(eventAudienceCondition(memberId))
|
||||||
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
|
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
|
||||||
.and(bannerCreator.isActive.isTrue)
|
.and(bannerCreator.isActive.isTrue)
|
||||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
|
||||||
@@ -570,11 +585,24 @@ class DefaultAudioRecommendationQueryRepository(
|
|||||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
|
||||||
|
|
||||||
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
|
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
|
||||||
.or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue))
|
.or(eventCondition)
|
||||||
.or(creatorCondition)
|
.or(creatorCondition)
|
||||||
.or(seriesCondition)
|
.or(seriesCondition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun eventAudienceCondition(memberId: Long?): BooleanExpression {
|
||||||
|
if (memberId == null) return event.isAdult.isNull.or(event.isAdult.isFalse)
|
||||||
|
val eventViewer = QMember("eventViewer")
|
||||||
|
val isAdmin = JPAExpressions.selectOne().from(eventViewer)
|
||||||
|
.where(eventViewer.id.eq(memberId), eventViewer.role.eq(MemberRole.ADMIN))
|
||||||
|
.exists()
|
||||||
|
val viewerAuth = JPAExpressions.selectOne().from(auth).where(auth.member.id.eq(memberId))
|
||||||
|
return event.isAdult.isNull
|
||||||
|
.or(isAdmin)
|
||||||
|
.or(event.isAdult.isTrue.and(viewerAuth.exists()))
|
||||||
|
.or(event.isAdult.isFalse.and(viewerAuth.notExists()))
|
||||||
|
}
|
||||||
|
|
||||||
private fun Tuple.toEventItem(): EventItem? {
|
private fun Tuple.toEventItem(): EventItem? {
|
||||||
val eventId = get(event.id) ?: return null
|
val eventId = get(event.id) ?: return null
|
||||||
val thumbnailImage = get(event.thumbnailImage) ?: return null
|
val thumbnailImage = get(event.thumbnailImage) ?: return null
|
||||||
|
|||||||
+12
-3
@@ -108,17 +108,26 @@ interface GiftRepository : JpaRepository<Gift, Long>, GiftLockRepository {
|
|||||||
"""
|
"""
|
||||||
select g
|
select g
|
||||||
from Gift g
|
from Gift g
|
||||||
where g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.DELIVERED
|
where exists (
|
||||||
and exists (
|
|
||||||
select d.id
|
select d.id
|
||||||
from GiftDelivery d
|
from GiftDelivery d
|
||||||
where d.giftId = g.id
|
where d.giftId = g.id
|
||||||
|
and (
|
||||||
|
(
|
||||||
|
g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.DELIVERED
|
||||||
and d.deliveredAt is not null
|
and d.deliveredAt is not null
|
||||||
and d.deliveredAt <= :cutoff
|
and d.deliveredAt <= :cutoff
|
||||||
)
|
)
|
||||||
|
or (
|
||||||
|
g.status = kr.co.vividnext.sodalive.v2.gift.domain.GiftStatus.UNDELIVERABLE
|
||||||
|
and d.undeliverableAt is not null
|
||||||
|
and d.undeliverableAt <= :cutoff
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun findDeliveredGiftsWithDeliveryBeforeOrAt(@Param("cutoff") cutoff: LocalDateTime): List<Gift>
|
fun findGiftsWithPersonalDataRetentionExpired(@Param("cutoff") cutoff: LocalDateTime): List<Gift>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"""
|
"""
|
||||||
|
|||||||
+6
-6
@@ -92,23 +92,23 @@ class GiftScheduler(
|
|||||||
|
|
||||||
@Scheduled(cron = "0 20 * * * *", zone = "Asia/Seoul")
|
@Scheduled(cron = "0 20 * * * *", zone = "Asia/Seoul")
|
||||||
@Transactional
|
@Transactional
|
||||||
fun runDeliveredGiftPersonalDataErasure() {
|
fun runGiftPersonalDataErasure() {
|
||||||
val lock = redissonClient.getLock(DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY)
|
val lock = redissonClient.getLock(GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY)
|
||||||
if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
|
if (!lock.tryLock(0, -1, TimeUnit.SECONDS)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
eraseDeliveredGiftPersonalData(LocalDateTime.now())
|
eraseGiftPersonalData(LocalDateTime.now())
|
||||||
} finally {
|
} finally {
|
||||||
unlockAfterTransaction(lock)
|
unlockAfterTransaction(lock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun eraseDeliveredGiftPersonalData(now: LocalDateTime): Int {
|
fun eraseGiftPersonalData(now: LocalDateTime): Int {
|
||||||
val cutoff = now.minusDays(7)
|
val cutoff = now.minusDays(7)
|
||||||
val expiredGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(cutoff)
|
val expiredGifts = giftRepository.findGiftsWithPersonalDataRetentionExpired(cutoff)
|
||||||
|
|
||||||
return expiredGifts.count { gift ->
|
return expiredGifts.count { gift ->
|
||||||
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
val delivery = requireNotNull(giftDeliveryRepository.findByGiftId(requireNotNull(gift.id)))
|
||||||
@@ -218,7 +218,7 @@ class GiftScheduler(
|
|||||||
const val DEADLINE_REMINDER_HOURS = 24L
|
const val DEADLINE_REMINDER_HOURS = 24L
|
||||||
const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation"
|
const val EXPIRED_TRACKING_REGISTRATION_GIFTS_LOCK_KEY = "lock:gift-expired-tracking-registration-cancellation"
|
||||||
const val EXPIRED_RECIPIENT_ADDRESS_GIFTS_LOCK_KEY = "lock:gift-expired-recipient-address-undeliverable"
|
const val EXPIRED_RECIPIENT_ADDRESS_GIFTS_LOCK_KEY = "lock:gift-expired-recipient-address-undeliverable"
|
||||||
const val DELIVERED_GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY = "lock:gift-delivered-personal-data-erasure"
|
const val GIFT_PERSONAL_DATA_ERASURE_LOCK_KEY = "lock:gift-delivered-personal-data-erasure"
|
||||||
const val DEADLINE_REMINDER_GIFTS_LOCK_KEY = "lock:gift-deadline-reminders"
|
const val DEADLINE_REMINDER_GIFTS_LOCK_KEY = "lock:gift-deadline-reminders"
|
||||||
const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과"
|
const val EXPIRED_RECIPIENT_ADDRESS_REASON = "배송지 미입력 기한 초과"
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-3
@@ -30,6 +30,7 @@ import kr.co.vividnext.sodalive.member.MemberKind
|
|||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
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.auth.QAuth.auth
|
||||||
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.member.following.QCreatorFollowing
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
@@ -58,6 +59,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
private val queryFactory: JPAQueryFactory,
|
private val queryFactory: JPAQueryFactory,
|
||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
private val contentTranslationRepository: ContentTranslationRepository,
|
private val contentTranslationRepository: ContentTranslationRepository,
|
||||||
|
private val eventNowProvider: () -> LocalDateTime = { LocalDateTime.now() },
|
||||||
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||||
) : HomeRecommendationQueryRepository {
|
) : HomeRecommendationQueryRepository {
|
||||||
override fun findLiveRecommendations(
|
override fun findLiveRecommendations(
|
||||||
@@ -137,7 +139,7 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
audioContentBanner.lang.eq(lang),
|
audioContentBanner.lang.eq(lang),
|
||||||
audioContentBanner.isVisibleAt(nowProvider()),
|
audioContentBanner.isVisibleAt(nowProvider()),
|
||||||
includeAdultBannerCondition(includeAdultBanners),
|
includeAdultBannerCondition(includeAdultBanners),
|
||||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner, lang)
|
||||||
)
|
)
|
||||||
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
|
||||||
.limit(limit.toLong())
|
.limit(limit.toLong())
|
||||||
@@ -1298,8 +1300,16 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
private fun activeBannerTargetCondition(
|
private fun activeBannerTargetCondition(
|
||||||
memberId: Long?,
|
memberId: Long?,
|
||||||
bannerCreator: QMember,
|
bannerCreator: QMember,
|
||||||
seriesOwner: QMember
|
seriesOwner: QMember,
|
||||||
|
lang: Lang
|
||||||
): BooleanExpression {
|
): BooleanExpression {
|
||||||
|
val eventNow = eventNowProvider()
|
||||||
|
val eventCondition = audioContentBanner.type.eq(AudioContentBannerType.EVENT)
|
||||||
|
.and(event.isActive.isTrue)
|
||||||
|
.and(event.startDate.loe(eventNow))
|
||||||
|
.and(event.endDate.goe(eventNow))
|
||||||
|
.and(event.lang.eq(lang))
|
||||||
|
.and(eventAudienceCondition(memberId))
|
||||||
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
|
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
|
||||||
.and(bannerCreator.isActive.isTrue)
|
.and(bannerCreator.isActive.isTrue)
|
||||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
|
||||||
@@ -1309,11 +1319,24 @@ class DefaultHomeRecommendationQueryRepository(
|
|||||||
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
|
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
|
||||||
|
|
||||||
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
|
return audioContentBanner.type.eq(AudioContentBannerType.LINK)
|
||||||
.or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue))
|
.or(eventCondition)
|
||||||
.or(creatorCondition)
|
.or(creatorCondition)
|
||||||
.or(seriesCondition)
|
.or(seriesCondition)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun eventAudienceCondition(memberId: Long?): BooleanExpression {
|
||||||
|
if (memberId == null) return event.isAdult.isNull.or(event.isAdult.isFalse)
|
||||||
|
val eventViewer = QMember("eventViewer")
|
||||||
|
val isAdmin = JPAExpressions.selectOne().from(eventViewer)
|
||||||
|
.where(eventViewer.id.eq(memberId), eventViewer.role.eq(MemberRole.ADMIN))
|
||||||
|
.exists()
|
||||||
|
val viewerAuth = JPAExpressions.selectOne().from(auth).where(auth.member.id.eq(memberId))
|
||||||
|
return event.isAdult.isNull
|
||||||
|
.or(isAdmin)
|
||||||
|
.or(event.isAdult.isTrue.and(viewerAuth.exists()))
|
||||||
|
.or(event.isAdult.isFalse.and(viewerAuth.notExists()))
|
||||||
|
}
|
||||||
|
|
||||||
private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression {
|
private fun BooleanExpression.withOptionalAnd(condition: BooleanExpression?): BooleanExpression {
|
||||||
return if (condition == null) this else and(condition)
|
return if (condition == null) this else and(condition)
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-18
@@ -162,7 +162,7 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
mapOf(
|
mapOf(
|
||||||
"runExpiredTrackingRegistrationGiftCancellation" to "0 0 * * * *",
|
"runExpiredTrackingRegistrationGiftCancellation" to "0 0 * * * *",
|
||||||
"runExpiredRecipientAddressGiftExpiration" to "0 10 * * * *",
|
"runExpiredRecipientAddressGiftExpiration" to "0 10 * * * *",
|
||||||
"runDeliveredGiftPersonalDataErasure" to "0 20 * * * *",
|
"runGiftPersonalDataErasure" to "0 20 * * * *",
|
||||||
"runDeadlineReminderGifts" to "0 40 * * * *"
|
"runDeadlineReminderGifts" to "0 40 * * * *"
|
||||||
),
|
),
|
||||||
scheduledJobs.associate {
|
scheduledJobs.associate {
|
||||||
@@ -311,12 +311,13 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE"))
|
.andExpect(jsonPath("$.data.availableActions[1]").value("MARK_UNDELIVERABLE"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
@DisplayName("관리자는 개인정보가 지워진 전달 완료 선물을 기존 빈 문자열과 null 응답으로 조회한다")
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
fun shouldReturnCompatibleErasedAdminGiftResponses() {
|
@DisplayName("관리자는 개인정보가 지워진 종료 선물을 기존 빈 문자열과 null 응답으로 조회한다")
|
||||||
|
fun shouldReturnCompatibleErasedAdminGiftResponses(giftStatus: GiftStatus) {
|
||||||
val (gift, delivery) = saveAdminGift(
|
val (gift, delivery) = saveAdminGift(
|
||||||
applicationNo = "A-ERASED-CONTROLLER",
|
applicationNo = "A-ERASED-CONTROLLER",
|
||||||
status = GiftStatus.DELIVERED,
|
status = giftStatus,
|
||||||
senderNickname = "erased-admin-fan",
|
senderNickname = "erased-admin-fan",
|
||||||
recipientNickname = "erased-admin-creator",
|
recipientNickname = "erased-admin-creator",
|
||||||
withTracking = false
|
withTracking = false
|
||||||
@@ -331,7 +332,7 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
mockMvc.perform(get("/api/v2/admin/gifts/{applicationNo}", gift.applicationNo).with(user("admin").roles("ADMIN")))
|
mockMvc.perform(get("/api/v2/admin/gifts/{applicationNo}", gift.applicationNo).with(user("admin").roles("ADMIN")))
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.data.status").value("DELIVERED"))
|
.andExpect(jsonPath("$.data.status").value(giftStatus.name))
|
||||||
.andExpect(jsonPath("$.data.senderInfo.nickname").value("erased-admin-fan"))
|
.andExpect(jsonPath("$.data.senderInfo.nickname").value("erased-admin-fan"))
|
||||||
.andExpect(jsonPath("$.data.senderInfo.name").value(""))
|
.andExpect(jsonPath("$.data.senderInfo.name").value(""))
|
||||||
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value(""))
|
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value(""))
|
||||||
@@ -359,9 +360,10 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.items[0].availableActions").isEmpty)
|
.andExpect(jsonPath("$.data.items[0].availableActions").isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("실제 Spring 개인정보 파기는 JWT HTTP 선물함 계약과 결제·동의·리뷰·이력을 보존한다")
|
@DisplayName("실제 Spring 개인정보 파기는 JWT HTTP 선물함 계약과 결제·동의·리뷰·이력을 보존한다")
|
||||||
fun shouldEraseDeliveredGiftAndPreserveRealJwtHttpContracts() {
|
fun shouldEraseGiftAndPreserveRealJwtHttpContracts(status: GiftStatus) {
|
||||||
val now = NOW.plusDays(14)
|
val now = NOW.plusDays(14)
|
||||||
val recipient = memberRepository.saveAndFlush(
|
val recipient = memberRepository.saveAndFlush(
|
||||||
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-recipient")
|
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "erasure-recipient")
|
||||||
@@ -376,7 +378,7 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
val review = requireNotNull(
|
val review = requireNotNull(
|
||||||
transaction.execute {
|
transaction.execute {
|
||||||
val gift = giftRepository.findById(giftId).orElseThrow()
|
val gift = giftRepository.findById(giftId).orElseThrow()
|
||||||
gift.status = GiftStatus.DELIVERED
|
gift.status = status
|
||||||
gift.recipientTermsAgreed = true
|
gift.recipientTermsAgreed = true
|
||||||
gift.recipientPrivacyAgreed = true
|
gift.recipientPrivacyAgreed = true
|
||||||
gift.recipientTermsAgreedAt = NOW
|
gift.recipientTermsAgreedAt = NOW
|
||||||
@@ -393,7 +395,9 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
delivery.trackingRegisteredAt = NOW.minusDays(2)
|
delivery.trackingRegisteredAt = NOW.minusDays(2)
|
||||||
delivery.arrivedAtMailboxAt = NOW.minusDays(1)
|
delivery.arrivedAtMailboxAt = NOW.minusDays(1)
|
||||||
delivery.inspectionCompletedAt = NOW.plusDays(1)
|
delivery.inspectionCompletedAt = NOW.plusDays(1)
|
||||||
delivery.deliveredAt = now.minusDays(7)
|
delivery.deliveredAt = now.minusDays(7).takeIf { status == GiftStatus.DELIVERED }
|
||||||
|
delivery.undeliverableAt = now.minusDays(7).takeIf { status == GiftStatus.UNDELIVERABLE }
|
||||||
|
delivery.undeliverableReason = "검수 실패".takeIf { status == GiftStatus.UNDELIVERABLE }
|
||||||
delivery.recipientAddressDeadlineAt = NOW.plusDays(5)
|
delivery.recipientAddressDeadlineAt = NOW.plusDays(5)
|
||||||
delivery.trackingDeadlineReminderSentAt = NOW.minusDays(3)
|
delivery.trackingDeadlineReminderSentAt = NOW.minusDays(3)
|
||||||
delivery.recipientAddressDeadlineReminderSentAt = NOW.plusDays(4)
|
delivery.recipientAddressDeadlineReminderSentAt = NOW.plusDays(4)
|
||||||
@@ -437,8 +441,8 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
listOf(false, true).forEach { erased ->
|
listOf(false, true).forEach { erased ->
|
||||||
if (erased) {
|
if (erased) {
|
||||||
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
|
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
|
||||||
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(now))
|
assertEquals(1, scheduler.eraseGiftPersonalData(now))
|
||||||
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(now))
|
assertEquals(0, scheduler.eraseGiftPersonalData(now))
|
||||||
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
|
assertFalse(TransactionSynchronizationManager.isActualTransactionActive())
|
||||||
}
|
}
|
||||||
listOf(detailPath, adminPath, adminListPath, "/api/v2/gifts?type=SENT", "/api/v2/gifts?type=RECEIVED").forEach {
|
listOf(detailPath, adminPath, adminListPath, "/api/v2/gifts?type=SENT", "/api/v2/gifts?type=RECEIVED").forEach {
|
||||||
@@ -476,16 +480,27 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
originalTimeline = originalTimeline ?: sentData.path("statusTimeline")
|
originalTimeline = originalTimeline ?: sentData.path("statusTimeline")
|
||||||
assertTrue(requireNotNull(originalTimeline).isArray)
|
assertTrue(requireNotNull(originalTimeline).isArray)
|
||||||
assertEquals(5, requireNotNull(originalTimeline).size())
|
assertEquals(5, requireNotNull(originalTimeline).size())
|
||||||
assertEquals(json.textNode("2026-10-10T12:00:00Z"), requireNotNull(originalTimeline).last().path("occurredAt"))
|
assertEquals(
|
||||||
|
if (status == GiftStatus.DELIVERED) json.textNode("2026-10-10T12:00:00Z") else json.nullNode(),
|
||||||
|
requireNotNull(originalTimeline).last().path("occurredAt")
|
||||||
|
)
|
||||||
listOf(sentData, receivedData).forEach { data ->
|
listOf(sentData, receivedData).forEach { data ->
|
||||||
assertEquals(originalTimeline, data.path("statusTimeline"))
|
assertEquals(originalTimeline, data.path("statusTimeline"))
|
||||||
|
assertEquals(
|
||||||
|
if (status == GiftStatus.UNDELIVERABLE) json.textNode("2026-10-10T12:00:00Z") else json.nullNode(),
|
||||||
|
data.path("delivery").path("undeliverableAt")
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
if (status == GiftStatus.UNDELIVERABLE) json.textNode("검수 실패") else json.nullNode(),
|
||||||
|
data.path("delivery").path("undeliverableReason")
|
||||||
|
)
|
||||||
assertEquals(json.booleanNode(false), data.path("trackingRequired"))
|
assertEquals(json.booleanNode(false), data.path("trackingRequired"))
|
||||||
assertEquals(json.booleanNode(false), data.path("recipientAddressRequired"))
|
assertEquals(json.booleanNode(false), data.path("recipientAddressRequired"))
|
||||||
assertEquals(json.nullNode(), data.path("mailbox"))
|
assertEquals(json.nullNode(), data.path("mailbox"))
|
||||||
}
|
}
|
||||||
listOf(sentData, receivedData, adminData).forEach { data ->
|
listOf(sentData, receivedData, adminData).forEach { data ->
|
||||||
assertEquals(json.textNode(APPLICATION_NO), data.path("applicationNo"))
|
assertEquals(json.textNode(APPLICATION_NO), data.path("applicationNo"))
|
||||||
assertEquals(json.textNode("DELIVERED"), data.path("status"))
|
assertEquals(json.textNode(status.name), data.path("status"))
|
||||||
}
|
}
|
||||||
listOf("senderInfo" to senderAddress, "recipientInfo" to recipientAddress).forEach { (field, address) ->
|
listOf("senderInfo" to senderAddress, "recipientInfo" to recipientAddress).forEach { (field, address) ->
|
||||||
val nickname = if (field == "senderInfo") fixture.sender.nickname else recipient.nickname
|
val nickname = if (field == "senderInfo") fixture.sender.nickname else recipient.nickname
|
||||||
@@ -499,7 +514,7 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
assertEquals(json.numberNode(1), adminList.path("data").path("totalCount"))
|
assertEquals(json.numberNode(1), adminList.path("data").path("totalCount"))
|
||||||
val adminItem = adminList.path("data").path("items").single()
|
val adminItem = adminList.path("data").path("items").single()
|
||||||
assertEquals(json.textNode(APPLICATION_NO), adminItem.path("applicationNo"))
|
assertEquals(json.textNode(APPLICATION_NO), adminItem.path("applicationNo"))
|
||||||
assertEquals(json.textNode("DELIVERED"), adminItem.path("status"))
|
assertEquals(json.textNode(status.name), adminItem.path("status"))
|
||||||
assertEquals(json.textNode(fixture.sender.nickname), adminItem.path("senderNickname"))
|
assertEquals(json.textNode(fixture.sender.nickname), adminItem.path("senderNickname"))
|
||||||
assertEquals(json.textNode(recipient.nickname), adminItem.path("recipientNickname"))
|
assertEquals(json.textNode(recipient.nickname), adminItem.path("recipientNickname"))
|
||||||
listOf(adminData.path("inboundDeliveryInfo"), adminItem).forEach { data ->
|
listOf(adminData.path("inboundDeliveryInfo"), adminItem).forEach { data ->
|
||||||
@@ -514,13 +529,13 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
assertEquals(json.numberNode(1), list.path("data").path("totalCount"))
|
assertEquals(json.numberNode(1), list.path("data").path("totalCount"))
|
||||||
val item = list.path("data").path("items").single()
|
val item = list.path("data").path("items").single()
|
||||||
assertEquals(json.textNode(APPLICATION_NO), item.path("applicationNo"))
|
assertEquals(json.textNode(APPLICATION_NO), item.path("applicationNo"))
|
||||||
assertEquals(json.textNode("DELIVERED"), item.path("status"))
|
assertEquals(json.textNode(status.name), item.path("status"))
|
||||||
assertEquals(json.textNode(direction), item.path("direction"))
|
assertEquals(json.textNode(direction), item.path("direction"))
|
||||||
assertEquals(json.textNode(nickname), item.path("counterpartNickname"))
|
assertEquals(json.textNode(nickname), item.path("counterpartNickname"))
|
||||||
assertEquals(json.booleanNode(!erased), item.path("recipientAddressRegistered"))
|
assertEquals(json.booleanNode(!erased), item.path("recipientAddressRegistered"))
|
||||||
}
|
}
|
||||||
println(
|
println(
|
||||||
"P15_HTTP erased=$erased status=200 success=true " +
|
"P17_HTTP giftStatus=$status erased=$erased status=200 success=true " +
|
||||||
"senderStringsEmpty=${sentData.path("senderInfo").all { it.isTextual && it.textValue().isEmpty() }} " +
|
"senderStringsEmpty=${sentData.path("senderInfo").all { it.isTextual && it.textValue().isEmpty() }} " +
|
||||||
"recipientAddressNull=${receivedData.path("recipientAddress").isNull} " +
|
"recipientAddressNull=${receivedData.path("recipientAddress").isNull} " +
|
||||||
"trackingNull=${sentData.path("giftInfo").path("tracking").isNull} " +
|
"trackingNull=${sentData.path("giftInfo").path("tracking").isNull} " +
|
||||||
@@ -549,7 +564,8 @@ class AdminGiftControllerTest @Autowired constructor(
|
|||||||
.refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
|
.refundGiftCan(requireNotNull(fixture.sender.id), fixture.gift.canUsageId)
|
||||||
Mockito.verifyNoInteractions(giftPushService)
|
Mockito.verifyNoInteractions(giftPushService)
|
||||||
println(
|
println(
|
||||||
"P15_DB erased=1 repeated=0 persistedNulls=12 giftRows=1 deliveryRows=1 paymentConsentReviewHistoryPreserved=true"
|
"P17_DB giftStatus=$status erased=1 repeated=0 persistedNulls=12 giftRows=1 deliveryRows=1 " +
|
||||||
|
"paymentConsentReviewHistoryPreserved=true"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+128
@@ -3,21 +3,32 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web
|
|||||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||||
|
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.tab.AudioContentMainTab
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslation
|
||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
||||||
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
|
||||||
|
import kr.co.vividnext.sodalive.event.Event
|
||||||
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
|
import kr.co.vividnext.sodalive.member.MemberAdapter
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||||
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.domain.RecommendedSectionType
|
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||||
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.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
|
||||||
import org.springframework.test.context.ContextConfiguration
|
import org.springframework.test.context.ContextConfiguration
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
@@ -41,6 +52,95 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
|||||||
private val entityManager: EntityManager,
|
private val entityManager: EntityManager,
|
||||||
private val transactionTemplate: TransactionTemplate
|
private val transactionTemplate: TransactionTemplate
|
||||||
) {
|
) {
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ANONYMOUS, KR, ja, KO, all|false",
|
||||||
|
"ANONYMOUS, JP, ko, JA, all|false",
|
||||||
|
"UNVERIFIED, KR, ja, KO, all|false",
|
||||||
|
"UNVERIFIED, JP, ko, JA, all|false",
|
||||||
|
"VERIFIED, KR, ja, KO, all|true",
|
||||||
|
"VERIFIED, JP, ko, JA, all|true",
|
||||||
|
"ADMIN, KR, ja, KO, all|false|true",
|
||||||
|
"ADMIN, JP, ko, JA, all|false|true"
|
||||||
|
)
|
||||||
|
@DisplayName("오디오 EVENT 응답은 요청 언어가 아닌 국가와 인증 대상 정책을 따르고 AI 필드를 추가하지 않는다")
|
||||||
|
fun shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema(
|
||||||
|
viewerType: String,
|
||||||
|
country: String,
|
||||||
|
requestLanguage: String,
|
||||||
|
eventLang: Lang,
|
||||||
|
expected: String
|
||||||
|
) {
|
||||||
|
val fixture = transactionTemplate.execute {
|
||||||
|
entityManager.createQuery("delete from AudioContentBanner").executeUpdate()
|
||||||
|
if (entityManager.find(AudioContentMainTab::class.java, 2L) == null) {
|
||||||
|
entityManager.createNativeQuery(
|
||||||
|
"insert into audio_content_main_tab (id, title, is_active) values (2, '추천', true)"
|
||||||
|
).executeUpdate()
|
||||||
|
}
|
||||||
|
val tab = entityManager.getReference(AudioContentMainTab::class.java, 2L)
|
||||||
|
val viewer = if (viewerType == "ANONYMOUS") {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
|
||||||
|
var candidate = saveMember("audio-event-viewer", role)
|
||||||
|
while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) {
|
||||||
|
candidate = saveMember("audio-event-viewer-${candidate.id}", role)
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
if (viewerType == "VERIFIED") {
|
||||||
|
entityManager.persist(
|
||||||
|
Auth("verified", "19900101", "audio-event-ci-${viewer?.id}", "audio-event-di-${viewer?.id}", 1).apply {
|
||||||
|
member = viewer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
viewer?.let {
|
||||||
|
entityManager.persist(
|
||||||
|
MemberContentPreference(isAdultContentVisible = viewerType == "UNVERIFIED").apply {
|
||||||
|
member = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val events = mutableMapOf<String, Event>()
|
||||||
|
listOf(Lang.KO, Lang.JA).forEach { lang ->
|
||||||
|
listOf(null, false, true).forEachIndexed { index, audience ->
|
||||||
|
val name = audience?.toString() ?: "all"
|
||||||
|
events["$lang-$name"] = saveAudioEventBanner(name, lang, audience, index, tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveAudioEventBanner("inactive", eventLang, null, 3, tab).isActive = false
|
||||||
|
saveAudioEventBanner("future", eventLang, null, 4, tab).startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
|
||||||
|
saveAudioEventBanner("expired", eventLang, null, 5, tab).endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
|
||||||
|
saveAudioEventBanner("wrong-event-language", eventLang, null, 6, tab).lang =
|
||||||
|
if (eventLang == Lang.KO) Lang.JA else Lang.KO
|
||||||
|
entityManager.persist(
|
||||||
|
AudioContentBanner("missing-event", AudioContentBannerType.EVENT, lang = eventLang, orders = 7).apply {
|
||||||
|
this.tab = tab
|
||||||
|
}
|
||||||
|
)
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
viewer to events
|
||||||
|
}!!
|
||||||
|
|
||||||
|
val request = get("/api/v2/audio/recommendations")
|
||||||
|
.header("CloudFront-Viewer-Country", country)
|
||||||
|
.header("Accept-Language", requestLanguage)
|
||||||
|
fixture.first?.let { request.with(user(MemberAdapter(it))) }
|
||||||
|
val response = mockMvc.perform(request)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.aiCharacters").doesNotHaveJsonPath())
|
||||||
|
.andExpect(jsonPath("$.data.banners.length()").value(expected.split("|").size))
|
||||||
|
expected.split("|").forEachIndexed { index, name ->
|
||||||
|
response.andExpect(
|
||||||
|
jsonPath("$.data.banners[$index].eventItem.id").value(fixture.second.getValue("$eventLang-$name").id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다")
|
@DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다")
|
||||||
fun shouldReturnRecommendationsThroughControllerServiceAndRepository() {
|
fun shouldReturnRecommendationsThroughControllerServiceAndRepository() {
|
||||||
@@ -114,6 +214,34 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveAudioEventBanner(
|
||||||
|
name: String,
|
||||||
|
lang: Lang,
|
||||||
|
audience: Boolean?,
|
||||||
|
order: Int,
|
||||||
|
tab: AudioContentMainTab
|
||||||
|
): Event {
|
||||||
|
val event = Event(
|
||||||
|
thumbnailImage = "$lang-$name.png",
|
||||||
|
detailImage = null,
|
||||||
|
popupImage = null,
|
||||||
|
link = null,
|
||||||
|
title = name,
|
||||||
|
lang = lang,
|
||||||
|
isAdult = audience,
|
||||||
|
startDate = LocalDateTime.of(2000, 1, 1, 0, 0),
|
||||||
|
endDate = LocalDateTime.of(2100, 1, 1, 0, 0)
|
||||||
|
)
|
||||||
|
entityManager.persist(event)
|
||||||
|
entityManager.persist(
|
||||||
|
AudioContentBanner("$lang-$name", AudioContentBannerType.EVENT, lang = lang, orders = order).apply {
|
||||||
|
this.event = event
|
||||||
|
this.tab = tab
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
private fun createFixture(): Fixture {
|
private fun createFixture(): Fixture {
|
||||||
return transactionTemplate.execute {
|
return transactionTemplate.execute {
|
||||||
val now = LocalDateTime.now().minusHours(1)
|
val now = LocalDateTime.now().minusHours(1)
|
||||||
|
|||||||
+18
-6
@@ -33,6 +33,7 @@ import org.hamcrest.Matchers.nullValue
|
|||||||
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.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource
|
||||||
import org.junit.jupiter.params.provider.ValueSource
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
@@ -222,12 +223,13 @@ class GiftControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z"))
|
.andExpect(jsonPath("$.data.statusTimeline[1].occurredAt").value("2026-09-30T12:00:00Z"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("배송 개인정보가 지워져도 보낸 선물과 받은 선물의 상세 응답 계약을 유지한다")
|
@DisplayName("배송 개인정보가 지워져도 보낸 선물과 받은 선물의 상세 응답 계약을 유지한다")
|
||||||
fun shouldReturnCompatibleErasedGiftDetailsForSenderAndRecipient() {
|
fun shouldReturnCompatibleErasedGiftDetailsForSenderAndRecipient(giftStatus: GiftStatus) {
|
||||||
val sender = memberRepository.save(Member(password = "password", nickname = "erased-fan"))
|
val sender = memberRepository.save(Member(password = "password", nickname = "erased-fan"))
|
||||||
val recipient = memberRepository.save(Member(password = "password", nickname = "erased-creator"))
|
val recipient = memberRepository.save(Member(password = "password", nickname = "erased-creator"))
|
||||||
val gift = saveGift("G-CONTROLLER-ERASED", sender.id!!, recipient.id!!, GiftStatus.DELIVERED)
|
val gift = saveGift("ERASE-$giftStatus", sender.id!!, recipient.id!!, giftStatus)
|
||||||
val delivery = GiftDelivery(
|
val delivery = GiftDelivery(
|
||||||
giftId = gift.id!!,
|
giftId = gift.id!!,
|
||||||
senderName = null,
|
senderName = null,
|
||||||
@@ -236,7 +238,9 @@ class GiftControllerTest @Autowired constructor(
|
|||||||
senderAddress = null,
|
senderAddress = null,
|
||||||
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0),
|
trackingDeadlineAt = LocalDateTime.of(2026, 10, 2, 12, 0),
|
||||||
recipientAddressDeadlineAt = LocalDateTime.of(2026, 10, 7, 12, 0),
|
recipientAddressDeadlineAt = LocalDateTime.of(2026, 10, 7, 12, 0),
|
||||||
deliveredAt = LocalDateTime.of(2026, 10, 3, 12, 0)
|
deliveredAt = LocalDateTime.of(2026, 10, 3, 12, 0).takeIf { giftStatus == GiftStatus.DELIVERED },
|
||||||
|
undeliverableAt = LocalDateTime.of(2026, 10, 3, 12, 0).takeIf { giftStatus == GiftStatus.UNDELIVERABLE },
|
||||||
|
undeliverableReason = "검수 실패".takeIf { giftStatus == GiftStatus.UNDELIVERABLE }
|
||||||
)
|
)
|
||||||
giftDeliveryRepository.saveAndFlush(delivery)
|
giftDeliveryRepository.saveAndFlush(delivery)
|
||||||
|
|
||||||
@@ -244,7 +248,7 @@ class GiftControllerTest @Autowired constructor(
|
|||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.data.direction").value("SENT"))
|
.andExpect(jsonPath("$.data.direction").value("SENT"))
|
||||||
.andExpect(jsonPath("$.data.status").value("DELIVERED"))
|
.andExpect(jsonPath("$.data.status").value(giftStatus.name))
|
||||||
.andExpect(jsonPath("$.data.senderInfo").isMap)
|
.andExpect(jsonPath("$.data.senderInfo").isMap)
|
||||||
.andExpect(jsonPath("$.data.senderInfo.name").value(""))
|
.andExpect(jsonPath("$.data.senderInfo.name").value(""))
|
||||||
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value(""))
|
.andExpect(jsonPath("$.data.senderInfo.phoneNumber").value(""))
|
||||||
@@ -255,7 +259,15 @@ class GiftControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.trackingRequired").value(false))
|
.andExpect(jsonPath("$.data.trackingRequired").value(false))
|
||||||
.andExpect(jsonPath("$.data.recipientAddressRequired").value(false))
|
.andExpect(jsonPath("$.data.recipientAddressRequired").value(false))
|
||||||
.andExpect(jsonPath("$.data.mailbox").value(nullValue()))
|
.andExpect(jsonPath("$.data.mailbox").value(nullValue()))
|
||||||
.andExpect(jsonPath("$.data.statusTimeline[4].occurredAt").value("2026-10-03T12:00:00Z"))
|
.andExpect(
|
||||||
|
jsonPath("$.data.statusTimeline[4].occurredAt")
|
||||||
|
.value("2026-10-03T12:00:00Z".takeIf { giftStatus == GiftStatus.DELIVERED })
|
||||||
|
)
|
||||||
|
.andExpect(
|
||||||
|
jsonPath("$.data.delivery.undeliverableAt")
|
||||||
|
.value("2026-10-03T12:00:00Z".takeIf { giftStatus == GiftStatus.UNDELIVERABLE })
|
||||||
|
)
|
||||||
|
.andExpect(jsonPath("$.data.delivery.undeliverableReason").value(delivery.undeliverableReason))
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v2/gifts/{applicationNo}", gift.applicationNo).with(user(MemberAdapter(recipient))))
|
mockMvc.perform(get("/api/v2/gifts/{applicationNo}", gift.applicationNo).with(user(MemberAdapter(recipient))))
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
|
|||||||
+130
-8
@@ -1,6 +1,9 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.api.home
|
package kr.co.vividnext.sodalive.v2.api.home
|
||||||
|
|
||||||
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
import kr.co.vividnext.sodalive.chat.character.ChatCharacter
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||||
|
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||||
|
import kr.co.vividnext.sodalive.event.Event
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
@@ -10,6 +13,8 @@ import kr.co.vividnext.sodalive.member.MemberAdapter
|
|||||||
import kr.co.vividnext.sodalive.member.MemberKind
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
import kr.co.vividnext.sodalive.member.MemberRepository
|
import kr.co.vividnext.sodalive.member.MemberRepository
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||||
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||||
@@ -29,6 +34,8 @@ 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
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
@@ -350,9 +357,29 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.lives").isArray)
|
.andExpect(jsonPath("$.data.lives").isArray)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
@DisplayName("메인 홈 통합 조회는 AI 캐릭터를 최신 스냅샷 기준 최대 20개까지 반환한다")
|
@CsvSource(
|
||||||
fun shouldReturnTwentyAiCharactersOnHomeRecommendations() {
|
"ANONYMOUS, ko",
|
||||||
|
"ANONYMOUS, ja",
|
||||||
|
"UNVERIFIED, ko",
|
||||||
|
"UNVERIFIED, ja",
|
||||||
|
"VERIFIED, ko",
|
||||||
|
"VERIFIED, ja",
|
||||||
|
"ADMIN, ko",
|
||||||
|
"ADMIN, ja"
|
||||||
|
)
|
||||||
|
@DisplayName("메인 홈 통합 조회는 AI 스냅샷이 있어도 모든 조회자와 KO·JA 요청에 빈 AI 배열을 반환한다")
|
||||||
|
fun shouldReturnEmptyAiCharactersOnHomeRecommendations(viewerType: String, language: String) {
|
||||||
|
val member = when (viewerType) {
|
||||||
|
"ANONYMOUS" -> null
|
||||||
|
"ADMIN" -> saveMember("home-ai-admin", MemberRole.ADMIN)
|
||||||
|
else -> saveMember("home-ai-viewer", MemberRole.USER)
|
||||||
|
}
|
||||||
|
if (viewerType == "VERIFIED") {
|
||||||
|
entityManager.persist(
|
||||||
|
Auth("verified", "19900101", "home-ai-ci", "home-ai-di", 1).apply { this.member = member }
|
||||||
|
)
|
||||||
|
}
|
||||||
(1..22).forEach { index ->
|
(1..22).forEach { index ->
|
||||||
val character = saveAiCharacter("home-ai-character-$index")
|
val character = saveAiCharacter("home-ai-character-$index")
|
||||||
saveRecommendationSnapshot(character.id!!)
|
saveRecommendationSnapshot(character.id!!)
|
||||||
@@ -360,10 +387,84 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
entityManager.flush()
|
entityManager.flush()
|
||||||
entityManager.clear()
|
entityManager.clear()
|
||||||
|
|
||||||
mockMvc.perform(get("/api/v2/home/recommendations"))
|
val request = get("/api/v2/home/recommendations").header("Accept-Language", language)
|
||||||
|
member?.let { request.with(user(MemberAdapter(it))) }
|
||||||
|
|
||||||
|
mockMvc.perform(request)
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
.andExpect(jsonPath("$.data.aiCharacters.length()").value(20))
|
.andExpect(jsonPath("$.data.aiCharacters").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.aiCharacters").isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ANONYMOUS, KR, ja, KO, all|false",
|
||||||
|
"ANONYMOUS, JP, ko, JA, all|false",
|
||||||
|
"UNVERIFIED, KR, ja, KO, all|false",
|
||||||
|
"UNVERIFIED, JP, ko, JA, all|false",
|
||||||
|
"VERIFIED, KR, ja, KO, all|true",
|
||||||
|
"VERIFIED, JP, ko, JA, all|true",
|
||||||
|
"ADMIN, KR, ja, KO, all|false|true",
|
||||||
|
"ADMIN, JP, ko, JA, all|false|true"
|
||||||
|
)
|
||||||
|
@DisplayName("홈 EVENT 응답은 요청 언어가 아닌 접속 국가와 실제 인증 대상 정책을 따르고 AI는 빈 배열이다")
|
||||||
|
fun shouldApplyEventBannerDetailPolicyForHomeAggregate(
|
||||||
|
viewerType: String,
|
||||||
|
country: String,
|
||||||
|
requestLanguage: String,
|
||||||
|
eventLang: Lang,
|
||||||
|
expected: String
|
||||||
|
) {
|
||||||
|
val viewer = if (viewerType == "ANONYMOUS") {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
|
||||||
|
var candidate = saveMember("home-event-viewer", role)
|
||||||
|
while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) {
|
||||||
|
candidate = saveMember("home-event-viewer-${candidate.id}", role)
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
if (viewerType == "VERIFIED") {
|
||||||
|
entityManager.persist(Auth("verified", "19900101", "home-event-ci", "home-event-di", 1).apply { member = viewer })
|
||||||
|
}
|
||||||
|
viewer?.let {
|
||||||
|
entityManager.persist(
|
||||||
|
MemberContentPreference(isAdultContentVisible = viewerType == "UNVERIFIED").apply {
|
||||||
|
member = it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val events = mutableMapOf<String, Event>()
|
||||||
|
Lang.values().forEach { lang ->
|
||||||
|
listOf(null, false, true).forEachIndexed { index, audience ->
|
||||||
|
val name = audience?.toString() ?: "all"
|
||||||
|
events["$lang-$name"] = saveHomeEventBanner(name, lang, audience, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveHomeEventBanner("inactive", eventLang, null, 3).isActive = false
|
||||||
|
saveHomeEventBanner("future", eventLang, null, 4).startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
|
||||||
|
saveHomeEventBanner("expired", eventLang, null, 5).endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
|
||||||
|
saveHomeEventBanner("wrong-event-language", eventLang, null, 6).lang =
|
||||||
|
if (eventLang == Lang.KO) Lang.JA else Lang.KO
|
||||||
|
entityManager.persist(AudioContentBanner("missing-event", AudioContentBannerType.EVENT, lang = eventLang, orders = 7))
|
||||||
|
entityManager.flush()
|
||||||
|
entityManager.clear()
|
||||||
|
|
||||||
|
val request = get("/api/v2/home/recommendations")
|
||||||
|
.header("CloudFront-Viewer-Country", country)
|
||||||
|
.header("Accept-Language", requestLanguage)
|
||||||
|
viewer?.let { request.with(user(MemberAdapter(it))) }
|
||||||
|
val response = mockMvc.perform(request)
|
||||||
|
.andExpect(status().isOk)
|
||||||
|
.andExpect(jsonPath("$.success").value(true))
|
||||||
|
.andExpect(jsonPath("$.data.aiCharacters").isArray)
|
||||||
|
.andExpect(jsonPath("$.data.aiCharacters").isEmpty)
|
||||||
|
.andExpect(jsonPath("$.data.banners.length()").value(expected.split("|").size))
|
||||||
|
expected.split("|").forEachIndexed { index, name ->
|
||||||
|
response.andExpect(jsonPath("$.data.banners[$index].eventItem.id").value(events.getValue("$eventLang-$name").id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -630,7 +731,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AI 캐릭터 추천은 홈 통합과 전체보기 응답에 characterId와 creatorId를 함께 노출한다")
|
@DisplayName("홈 통합 AI 목록은 비어 있고 전용 전체보기는 characterId와 creatorId를 함께 노출한다")
|
||||||
fun shouldExposeCreatorIdOnAiCharacterRecommendations() {
|
fun shouldExposeCreatorIdOnAiCharacterRecommendations() {
|
||||||
val member = saveMember("ai-character-page-viewer", MemberRole.USER)
|
val member = saveMember("ai-character-page-viewer", MemberRole.USER)
|
||||||
val character = saveAiCharacter("ai-character-api")
|
val character = saveAiCharacter("ai-character-api")
|
||||||
@@ -640,8 +741,8 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
|
|
||||||
mockMvc.perform(get("/api/v2/home/recommendations"))
|
mockMvc.perform(get("/api/v2/home/recommendations"))
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
.andExpect(jsonPath("$.data.aiCharacters[0].characterId").value(character.id))
|
.andExpect(jsonPath("$.data.aiCharacters").isArray)
|
||||||
.andExpect(jsonPath("$.data.aiCharacters[0].creatorId").value(character.creatorMember!!.id))
|
.andExpect(jsonPath("$.data.aiCharacters").isEmpty)
|
||||||
|
|
||||||
mockMvc.perform(
|
mockMvc.perform(
|
||||||
get("/api/v2/home/recommendations/ai-characters")
|
get("/api/v2/home/recommendations/ai-characters")
|
||||||
@@ -696,6 +797,27 @@ class HomeRecommendationControllerTest @Autowired constructor(
|
|||||||
.andExpect(jsonPath("$.data.recentlyActiveCreators[1].targetId").doesNotExist())
|
.andExpect(jsonPath("$.data.recentlyActiveCreators[1].targetId").doesNotExist())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveHomeEventBanner(name: String, lang: Lang, audience: Boolean?, order: Int): Event {
|
||||||
|
val event = Event(
|
||||||
|
thumbnailImage = "$lang-$name.png",
|
||||||
|
detailImage = null,
|
||||||
|
popupImage = null,
|
||||||
|
link = null,
|
||||||
|
title = name,
|
||||||
|
lang = lang,
|
||||||
|
isAdult = audience,
|
||||||
|
startDate = LocalDateTime.of(2000, 1, 1, 0, 0),
|
||||||
|
endDate = LocalDateTime.of(2100, 1, 1, 0, 0)
|
||||||
|
)
|
||||||
|
entityManager.persist(event)
|
||||||
|
entityManager.persist(
|
||||||
|
AudioContentBanner("$lang-$name", AudioContentBannerType.EVENT, lang = lang, orders = order).apply {
|
||||||
|
this.event = event
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveMember(seed: String, role: MemberRole): Member {
|
private fun saveMember(seed: String, role: MemberRole): Member {
|
||||||
return memberRepository.saveAndFlush(
|
return memberRepository.saveAndFlush(
|
||||||
Member(
|
Member(
|
||||||
|
|||||||
+70
-8
@@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.MemberRole
|
|||||||
import kr.co.vividnext.sodalive.member.auth.Auth
|
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.application.CreatorCommunityTranslationService
|
||||||
|
import kr.co.vividnext.sodalive.v2.recommendation.application.AiCharacterSnapshotFallbackPort
|
||||||
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.RecommendedSectionType
|
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.HomeAiCharacterRecommendationRecord
|
||||||
@@ -22,6 +23,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreator
|
|||||||
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 kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
|
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
|
||||||
|
import org.junit.jupiter.api.Assertions.assertAll
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
@@ -31,10 +34,13 @@ import java.time.LocalDateTime
|
|||||||
|
|
||||||
class HomeRecommendationFacadeTest {
|
class HomeRecommendationFacadeTest {
|
||||||
private val queryPort = FakeHomeRecommendationQueryPort()
|
private val queryPort = FakeHomeRecommendationQueryPort()
|
||||||
|
private val snapshotPort = EmptyRecommendationSnapshotPort()
|
||||||
|
private val aiFallbackPort = FakeAiCharacterSnapshotFallbackPort()
|
||||||
private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
private val creatorCommunityTranslationService = Mockito.mock(CreatorCommunityTranslationService::class.java)
|
||||||
private val queryService = HomeRecommendationQueryService(
|
private val queryService = HomeRecommendationQueryService(
|
||||||
queryPort,
|
queryPort,
|
||||||
EmptyRecommendationSnapshotPort(),
|
snapshotPort,
|
||||||
|
aiCharacterSnapshotFallbackService = aiFallbackPort,
|
||||||
creatorCommunityTranslationService = creatorCommunityTranslationService
|
creatorCommunityTranslationService = creatorCommunityTranslationService
|
||||||
)
|
)
|
||||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||||
@@ -45,6 +51,22 @@ class HomeRecommendationFacadeTest {
|
|||||||
"https://cdn.test"
|
"https://cdn.test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 추천은 AI 스냅샷과 상세 및 무작위 보충이나 fallback 조회 없이 빈 AI 목록을 반환한다")
|
||||||
|
fun shouldReturnEmptyAiCharactersWithoutQueryingAiRecommendations() {
|
||||||
|
val response = facade.getHomeRecommendations(null, Lang.KO)
|
||||||
|
|
||||||
|
assertAll(
|
||||||
|
{ assertTrue(response.aiCharacters.isEmpty()) },
|
||||||
|
{ assertEquals(0, snapshotPort.aiSnapshotCalls) },
|
||||||
|
{ assertEquals(0, snapshotPort.aiSnapshotExistenceCalls) },
|
||||||
|
{ assertEquals(0, queryPort.aiSnapshotCalls) },
|
||||||
|
{ assertEquals(0, queryPort.aiDetailCalls) },
|
||||||
|
{ assertEquals(0, queryPort.aiRandomCalls) },
|
||||||
|
{ assertEquals(0, aiFallbackPort.calls) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
|
@DisplayName("홈 첫 오디오 응답은 무료 콘텐츠의 포인트 가능 여부를 false로 보정한다")
|
||||||
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
|
fun shouldNormalizePointAvailabilityForFreeFirstAudioContent() {
|
||||||
@@ -150,6 +172,9 @@ class HomeRecommendationFacadeTest {
|
|||||||
var bannerLang: Lang? = null
|
var bannerLang: Lang? = null
|
||||||
var liveIsViewerCreator: Boolean? = null
|
var liveIsViewerCreator: Boolean? = null
|
||||||
var liveEffectiveViewerGender: Gender? = null
|
var liveEffectiveViewerGender: Gender? = null
|
||||||
|
var aiSnapshotCalls: Int = 0
|
||||||
|
var aiDetailCalls: Int = 0
|
||||||
|
var aiRandomCalls: Int = 0
|
||||||
|
|
||||||
override fun findLiveRecommendations(
|
override fun findLiveRecommendations(
|
||||||
offset: Long,
|
offset: Long,
|
||||||
@@ -205,7 +230,10 @@ class HomeRecommendationFacadeTest {
|
|||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
windowEndExclusive: LocalDateTime,
|
windowEndExclusive: LocalDateTime,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<RecommendationSnapshotRecord> = emptyList()
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
aiSnapshotCalls++
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findCheerCreatorSnapshots(
|
override fun findCheerCreatorSnapshots(
|
||||||
windowStart: LocalDateTime,
|
windowStart: LocalDateTime,
|
||||||
@@ -221,9 +249,15 @@ class HomeRecommendationFacadeTest {
|
|||||||
|
|
||||||
override fun findAiCharacterRecommendationDetails(
|
override fun findAiCharacterRecommendationDetails(
|
||||||
characterIds: List<Long>
|
characterIds: List<Long>
|
||||||
): List<HomeAiCharacterRecommendationRecord> = emptyList()
|
): List<HomeAiCharacterRecommendationRecord> {
|
||||||
|
aiDetailCalls++
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List<Long>, limit: Int): List<Long> = emptyList()
|
override fun findRandomAiCharacterRecommendationIds(excludeCharacterIds: List<Long>, limit: Int): List<Long> {
|
||||||
|
aiRandomCalls++
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findCheerCreatorRecommendationDetails(
|
override fun findCheerCreatorRecommendationDetails(
|
||||||
creatorIds: List<Long>,
|
creatorIds: List<Long>,
|
||||||
@@ -248,22 +282,37 @@ class HomeRecommendationFacadeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private class EmptyRecommendationSnapshotPort : RecommendationSnapshotPort {
|
private class EmptyRecommendationSnapshotPort : RecommendationSnapshotPort {
|
||||||
|
var aiSnapshotCalls: Int = 0
|
||||||
|
var aiSnapshotExistenceCalls: Int = 0
|
||||||
|
|
||||||
override fun findLatestSnapshots(
|
override fun findLatestSnapshots(
|
||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<RecommendationSnapshotRecord> = emptyList()
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotCalls++
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
override fun findSnapshots(
|
override fun findSnapshots(
|
||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
snapshotAt: LocalDateTime,
|
snapshotAt: LocalDateTime,
|
||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int
|
limit: Int
|
||||||
): List<RecommendationSnapshotRecord> = emptyList()
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotCalls++
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean = true
|
override fun existsLatestSnapshot(sectionType: RecommendedSectionType): Boolean {
|
||||||
|
if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotExistenceCalls++
|
||||||
|
return sectionType != RecommendedSectionType.AI_CHARACTER
|
||||||
|
}
|
||||||
|
|
||||||
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean = true
|
override fun existsSnapshot(sectionType: RecommendedSectionType, snapshotAt: LocalDateTime): Boolean {
|
||||||
|
if (sectionType == RecommendedSectionType.AI_CHARACTER) aiSnapshotExistenceCalls++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
override fun replaceSnapshots(
|
override fun replaceSnapshots(
|
||||||
sectionType: RecommendedSectionType,
|
sectionType: RecommendedSectionType,
|
||||||
@@ -271,4 +320,17 @@ class HomeRecommendationFacadeTest {
|
|||||||
newSnapshots: List<RecommendationSnapshotRecord>
|
newSnapshots: List<RecommendationSnapshotRecord>
|
||||||
) = Unit
|
) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class FakeAiCharacterSnapshotFallbackPort : AiCharacterSnapshotFallbackPort {
|
||||||
|
var calls: Int = 0
|
||||||
|
|
||||||
|
override fun refreshIfMissing(
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
nowUtc: LocalDateTime
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
calls++
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+242
-2
@@ -17,9 +17,11 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
|
|||||||
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
|
||||||
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
|
||||||
|
import kr.co.vividnext.sodalive.event.Event
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicy
|
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicy
|
||||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||||
@@ -29,6 +31,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
|||||||
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.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
import org.junit.jupiter.params.provider.MethodSource
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
@@ -49,13 +52,218 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
contentTranslationRepository: ContentTranslationRepository
|
contentTranslationRepository: ContentTranslationRepository
|
||||||
) {
|
) {
|
||||||
private var bannerNow = AudioContentBannerReservationFixtures.now
|
private var bannerNow = AudioContentBannerReservationFixtures.now
|
||||||
|
private var eventNow = LocalDateTime.of(2026, 10, 31, 23, 59, 59, 123456789)
|
||||||
private val repository = DefaultAudioRecommendationQueryRepository(
|
private val repository = DefaultAudioRecommendationQueryRepository(
|
||||||
queryFactory,
|
queryFactory,
|
||||||
entityManager,
|
entityManager,
|
||||||
contentTranslationRepository,
|
contentTranslationRepository,
|
||||||
"https://cdn.test"
|
"https://cdn.test",
|
||||||
|
eventNowProvider = { eventNow }
|
||||||
) { bannerNow }
|
) { bannerNow }
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ANONYMOUS, KO, false, all|false",
|
||||||
|
"ANONYMOUS, JA, false, all|false",
|
||||||
|
"UNVERIFIED, KO, true, all|false",
|
||||||
|
"UNVERIFIED, JA, true, all|false",
|
||||||
|
"VERIFIED, KO, false, all|true",
|
||||||
|
"VERIFIED, JA, false, all|true",
|
||||||
|
"ADMIN, KO, false, all|false|true",
|
||||||
|
"ADMIN, JA, false, all|false|true"
|
||||||
|
)
|
||||||
|
@DisplayName("오디오 EVENT 대상자는 배너 성인 선호와 무관하게 조회자의 인증 기록으로 판정한다")
|
||||||
|
fun shouldApplyAudioEventVisibilityPolicyBeforeLimit(
|
||||||
|
viewerType: String,
|
||||||
|
lang: Lang,
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
expected: String
|
||||||
|
) {
|
||||||
|
val viewer = saveEventViewer(viewerType)
|
||||||
|
saveEventViewer("VERIFIED", "other-verified")
|
||||||
|
val tab = saveContentRecommendationTab()
|
||||||
|
listOf(null, false, true).forEachIndexed { index, audience ->
|
||||||
|
val name = audience?.toString() ?: "all"
|
||||||
|
val event = saveEvent(name).apply {
|
||||||
|
this.lang = lang
|
||||||
|
isAdult = audience
|
||||||
|
}
|
||||||
|
saveBanner(name, AudioContentBannerType.EVENT, index, tab = tab, lang = lang, event = event)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
expected.split("|").map { "https://cdn.test/$it" },
|
||||||
|
repository.findBanners(20, viewer?.id, canViewAdultContent, lang).map { it.imageUrl }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ANONYMOUS, MISSING",
|
||||||
|
"ANONYMOUS, INACTIVE",
|
||||||
|
"ANONYMOUS, FUTURE",
|
||||||
|
"ANONYMOUS, EXPIRED",
|
||||||
|
"ANONYMOUS, WRONG_LANGUAGE",
|
||||||
|
"ADMIN, MISSING",
|
||||||
|
"ADMIN, INACTIVE",
|
||||||
|
"ADMIN, FUTURE",
|
||||||
|
"ADMIN, EXPIRED",
|
||||||
|
"ADMIN, WRONG_LANGUAGE"
|
||||||
|
)
|
||||||
|
@DisplayName("오디오 EVENT는 관리자도 누락·비활성·기간 밖·다른 언어의 이벤트를 볼 수 없다")
|
||||||
|
fun shouldExcludeIneligibleAudioEventsForAnonymousAndAdmin(viewerType: String, hiddenReason: String) {
|
||||||
|
val viewer = saveEventViewer(viewerType)
|
||||||
|
val tab = saveContentRecommendationTab()
|
||||||
|
val event = if (hiddenReason == "MISSING") {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
saveEvent("hidden").apply {
|
||||||
|
when (hiddenReason) {
|
||||||
|
"INACTIVE" -> isActive = false
|
||||||
|
"FUTURE" -> startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
|
||||||
|
"EXPIRED" -> endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
|
||||||
|
"WRONG_LANGUAGE" -> lang = Lang.JA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveBanner("hidden", AudioContentBannerType.EVENT, 0, tab = tab, event = event)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(emptyList<String>(), repository.findBanners(20, viewer?.id, false).map { it.imageUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 EVENT는 부적격 첫 후보를 limit 전에 제외하고 적격 두 번째 후보를 반환한다")
|
||||||
|
fun shouldExcludeHiddenAudioEventBeforeLimit() {
|
||||||
|
val tab = saveContentRecommendationTab()
|
||||||
|
val hiddenEvent = saveEvent("hidden-first").apply { isAdult = true }
|
||||||
|
val visibleEvent = saveEvent("visible-second").apply { isAdult = false }
|
||||||
|
saveBanner("hidden-first", AudioContentBannerType.EVENT, 0, tab = tab, event = hiddenEvent)
|
||||||
|
saveBanner("visible-second", AudioContentBannerType.EVENT, 1, tab = tab, event = visibleEvent)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(listOf("https://cdn.test/visible-second"), repository.findBanners(1, null, false).map { it.imageUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("오디오 EVENT 후보가 모두 인증 대상에서 제외되면 빈 목록을 반환한다")
|
||||||
|
fun shouldReturnEmptyWhenAllAudioEventAudiencesAreHidden() {
|
||||||
|
val tab = saveContentRecommendationTab()
|
||||||
|
repeat(2) { index ->
|
||||||
|
val event = saveEvent("hidden-$index").apply { isAdult = true }
|
||||||
|
saveBanner("hidden-$index", AudioContentBannerType.EVENT, index, tab = tab, event = event)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(emptyList<String>(), repository.findBanners(20, null, false).map { it.imageUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자의 EVENT 대상자 우회는 오디오 배너 자체 활성·탭·언어·예약·성인 조건을 우회하지 않는다")
|
||||||
|
fun shouldKeepBannerOwnFiltersForAdminAudioEvents() {
|
||||||
|
val admin = saveEventViewer("ADMIN")
|
||||||
|
val tab = saveContentRecommendationTab()
|
||||||
|
val event = saveEvent("admin-event").apply { isAdult = true }
|
||||||
|
listOf("inactive", "tab", "language", "future", "expired", "adult", "visible").forEachIndexed { index, name ->
|
||||||
|
saveBanner(name, AudioContentBannerType.EVENT, index, tab = tab, event = event).apply {
|
||||||
|
when (name) {
|
||||||
|
"inactive" -> isActive = false
|
||||||
|
"tab" -> this.tab = null
|
||||||
|
"language" -> lang = Lang.JA
|
||||||
|
"future" -> startDate = bannerNow.plusYears(1).withNano(0)
|
||||||
|
"expired" -> endDate = bannerNow.minusYears(1).withNano(0)
|
||||||
|
"adult" -> isAdult = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(listOf("https://cdn.test/visible"), repository.findBanners(20, admin?.id, false).map { it.imageUrl })
|
||||||
|
assertEquals(
|
||||||
|
listOf("https://cdn.test/adult", "https://cdn.test/visible"),
|
||||||
|
repository.findBanners(20, admin?.id, true).map { it.imageUrl }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("EVENT 상세 정책은 오디오 LINK·CREATOR·SERIES 배너에 적용하지 않는다")
|
||||||
|
fun shouldNotApplyEventPolicyToNonEventAudioBanners() {
|
||||||
|
val tab = saveContentRecommendationTab()
|
||||||
|
val creator = saveMember("non-event-creator", MemberRole.CREATOR)
|
||||||
|
val series = saveSeries("non-event-series", creator, true)
|
||||||
|
val event = saveEvent("non-event-hidden").apply {
|
||||||
|
isActive = false
|
||||||
|
isAdult = true
|
||||||
|
lang = Lang.JA
|
||||||
|
endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
|
||||||
|
}
|
||||||
|
listOf(AudioContentBannerType.LINK, AudioContentBannerType.CREATOR, AudioContentBannerType.SERIES)
|
||||||
|
.forEachIndexed { index, type ->
|
||||||
|
saveBanner(type.name, type, index, creator = creator, tab = tab, event = event, series = series)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("LINK", "CREATOR", "SERIES").map { "https://cdn.test/$it" },
|
||||||
|
repository.findBanners(20, null, false).map { it.imageUrl }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"0, START, true",
|
||||||
|
"123456789, START, true",
|
||||||
|
"999999999, START, true",
|
||||||
|
"0, END, true",
|
||||||
|
"123456789, END, true",
|
||||||
|
"999999999, END, true",
|
||||||
|
"0, BOTH, true",
|
||||||
|
"123456789, BOTH, true",
|
||||||
|
"999999999, BOTH, true",
|
||||||
|
"0, BEFORE, false",
|
||||||
|
"123456789, BEFORE, false",
|
||||||
|
"999999999, BEFORE, false",
|
||||||
|
"0, AFTER, false",
|
||||||
|
"123456789, AFTER, false",
|
||||||
|
"999999999, AFTER, false"
|
||||||
|
)
|
||||||
|
@DisplayName("오디오 EVENT는 나노초 시작·종료를 포함하고 1ns 밖을 제외하며 배너 UTC 초 clock과 독립이다")
|
||||||
|
fun shouldApplyExactAudioEventBoundariesIndependentlyOfBannerClock(nano: Int, boundary: String, visible: Boolean) {
|
||||||
|
eventNow = eventNow.withNano(nano)
|
||||||
|
bannerNow = bannerNow.withNano(999999999)
|
||||||
|
entityManager.createNativeQuery("alter table event alter column start_date timestamp(9)").executeUpdate()
|
||||||
|
entityManager.createNativeQuery("alter table event alter column end_date timestamp(9)").executeUpdate()
|
||||||
|
val tab = saveContentRecommendationTab()
|
||||||
|
val start = when (boundary) {
|
||||||
|
"START", "BOTH" -> eventNow
|
||||||
|
"BEFORE" -> eventNow.plusNanos(1)
|
||||||
|
else -> eventNow.minusDays(1)
|
||||||
|
}
|
||||||
|
val end = when (boundary) {
|
||||||
|
"END", "BOTH" -> eventNow
|
||||||
|
"AFTER" -> eventNow.minusNanos(1)
|
||||||
|
else -> eventNow.plusDays(1)
|
||||||
|
}
|
||||||
|
val event = saveEvent("boundary").apply {
|
||||||
|
startDate = start
|
||||||
|
endDate = end
|
||||||
|
}
|
||||||
|
saveBanner("boundary", AudioContentBannerType.EVENT, 0, tab = tab, event = event).apply {
|
||||||
|
startDate = bannerNow.withNano(0)
|
||||||
|
endDate = bannerNow.withNano(0)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
val stored = entityManager.find(Event::class.java, event.id)
|
||||||
|
assertEquals(start, stored.startDate)
|
||||||
|
assertEquals(end, stored.endDate)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
if (visible) listOf("https://cdn.test/boundary") else emptyList(),
|
||||||
|
repository.findBanners(1, null, false).map { it.imageUrl }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다")
|
@DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다")
|
||||||
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
|
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
|
||||||
@@ -594,6 +802,34 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveEventViewer(viewerType: String, seed: String = "event-viewer"): Member? {
|
||||||
|
if (viewerType == "ANONYMOUS") return null
|
||||||
|
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
|
||||||
|
val viewer = saveMember(seed, role)
|
||||||
|
if (viewerType == "VERIFIED") {
|
||||||
|
entityManager.persist(
|
||||||
|
Auth("verified", "19900101", "event-ci-${viewer.id}", "event-di-${viewer.id}", 1).apply {
|
||||||
|
member = viewer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveEvent(title: String): Event {
|
||||||
|
val event = Event(
|
||||||
|
thumbnailImage = "$title.png",
|
||||||
|
detailImage = null,
|
||||||
|
popupImage = null,
|
||||||
|
link = null,
|
||||||
|
title = title,
|
||||||
|
startDate = eventNow.minusDays(1),
|
||||||
|
endDate = eventNow.plusDays(1)
|
||||||
|
)
|
||||||
|
entityManager.persist(event)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||||
val member = Member(
|
val member = Member(
|
||||||
email = "$nickname@test.com",
|
email = "$nickname@test.com",
|
||||||
@@ -621,7 +857,9 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
creator: Member? = null,
|
creator: Member? = null,
|
||||||
link: String? = null,
|
link: String? = null,
|
||||||
tab: AudioContentMainTab? = null,
|
tab: AudioContentMainTab? = null,
|
||||||
lang: Lang = Lang.KO
|
lang: Lang = Lang.KO,
|
||||||
|
event: Event? = null,
|
||||||
|
series: Series? = null
|
||||||
): AudioContentBanner {
|
): AudioContentBanner {
|
||||||
val banner = AudioContentBanner(
|
val banner = AudioContentBanner(
|
||||||
thumbnailImage = thumbnailImage,
|
thumbnailImage = thumbnailImage,
|
||||||
@@ -634,6 +872,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
banner.creator = creator
|
banner.creator = creator
|
||||||
banner.link = link
|
banner.link = link
|
||||||
banner.tab = tab
|
banner.tab = tab
|
||||||
|
banner.event = event
|
||||||
|
banner.series = series
|
||||||
entityManager.persist(banner)
|
entityManager.persist(banner)
|
||||||
return banner
|
return banner
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-45
@@ -22,6 +22,7 @@ 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.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource
|
||||||
import org.junit.jupiter.params.provider.MethodSource
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
import org.redisson.api.RLock
|
import org.redisson.api.RLock
|
||||||
@@ -608,43 +609,51 @@ class GiftSchedulerTest @Autowired constructor(
|
|||||||
Mockito.verifyNoInteractions(giftPushService)
|
Mockito.verifyNoInteractions(giftPushService)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
@DisplayName("전달 완료 후 정확히 7일인 선물의 배송 개인정보 12개를 DB에서 지운다")
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
fun shouldEraseDeliveredGiftWhenDeliveredAtEqualsSevenDayCutoff() {
|
@DisplayName("상태와 짝인 시각부터 정확히 7일인 선물의 배송 개인정보 12개를 DB에서 지운다")
|
||||||
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF)
|
fun shouldEraseGiftWhenMatchingTimestampEqualsSevenDayCutoff(status: GiftStatus) {
|
||||||
|
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF, status)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val eligibleGifts = giftRepository.findDeliveredGiftsWithDeliveryBeforeOrAt(ERASURE_CUTOFF)
|
assertEquals(1, scheduler.eraseGiftPersonalData(ERASURE_NOW))
|
||||||
|
val eligibleGifts = giftRepository.findGiftsWithPersonalDataRetentionExpired(ERASURE_CUTOFF)
|
||||||
assertEquals(listOf(gift.id), eligibleGifts.map { it.id })
|
assertEquals(listOf(gift.id), eligibleGifts.map { it.id })
|
||||||
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
|
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
|
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
@DisplayName("전달 완료 후 7일보다 1초 덜 지난 선물의 개인정보는 보존한다")
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
fun shouldIgnoreDeliveredGiftOneSecondNewerThanSevenDayCutoff() {
|
@DisplayName("짝인 시각부터 7일보다 1초 덜 지났으면 다른 시각이 오래돼도 개인정보를 보존한다")
|
||||||
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF.plusSeconds(1))
|
fun shouldIgnoreGiftOneSecondNewerThanSevenDayCutoffDespiteOldOtherTimestamp(status: GiftStatus) {
|
||||||
|
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF.plusSeconds(1), status)
|
||||||
|
if (status == GiftStatus.DELIVERED) {
|
||||||
|
delivery.undeliverableAt = ERASURE_CUTOFF.minusDays(30)
|
||||||
|
} else {
|
||||||
|
delivery.deliveredAt = ERASURE_CUTOFF.minusDays(30)
|
||||||
|
}
|
||||||
val originalPersonalData = personalData(delivery)
|
val originalPersonalData = personalData(delivery)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
|
assertEquals(0, scheduler.eraseGiftPersonalData(ERASURE_NOW))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(originalPersonalData, personalData(reloadDelivery(gift)))
|
assertEquals(originalPersonalData, personalData(reloadDelivery(gift)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
@DisplayName("도입 시점보다 오래전에 생성되고 전달 완료된 과거 누적 선물도 지운다")
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
fun shouldEraseOlderHistoricalDeliveredGifts() {
|
@DisplayName("도입 시점보다 오래전에 생성되고 종료된 과거 누적 선물도 지운다")
|
||||||
|
fun shouldEraseOlderHistoricalGifts(status: GiftStatus) {
|
||||||
val historicalAt = LocalDateTime.of(2020, 1, 1, 12, 0)
|
val historicalAt = LocalDateTime.of(2020, 1, 1, 12, 0)
|
||||||
val (gift, delivery) = saveGiftForErasure(historicalAt.plusDays(10))
|
val (gift, delivery) = saveGiftForErasure(historicalAt.plusDays(10), status)
|
||||||
gift.createdAt = historicalAt
|
gift.createdAt = historicalAt
|
||||||
delivery.createdAt = historicalAt
|
delivery.createdAt = historicalAt
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
|
assertEquals(1, scheduler.eraseGiftPersonalData(ERASURE_NOW))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
|
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
|
||||||
@@ -653,17 +662,29 @@ class GiftSchedulerTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("완료 시각이 없거나 전달 완료가 아닌 모든 상태는 개인정보를 보존한다")
|
@DisplayName("짝인 시각이 없거나 지원하지 않는 상태는 다른 시각이 오래돼도 개인정보를 보존한다")
|
||||||
fun shouldIgnoreNullDeliveredAtAndNonDeliveredGifts() {
|
fun shouldIgnoreNullMatchingTimestampsAndUnsupportedStatuses() {
|
||||||
val gifts = listOf(saveGiftForErasure(null)) + GiftStatus.values()
|
val supportedStatuses = setOf(GiftStatus.DELIVERED, GiftStatus.UNDELIVERABLE)
|
||||||
.filter { it != GiftStatus.DELIVERED }
|
val gifts = supportedStatuses.map { status ->
|
||||||
|
saveGiftForErasure(null, status, "NULL-$status").also { (_, delivery) ->
|
||||||
|
if (status == GiftStatus.DELIVERED) {
|
||||||
|
delivery.undeliverableAt = ERASURE_CUTOFF.minusDays(30)
|
||||||
|
} else {
|
||||||
|
delivery.deliveredAt = ERASURE_CUTOFF.minusDays(30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} + GiftStatus.values()
|
||||||
|
.filter { it !in supportedStatuses }
|
||||||
.mapIndexed { index, status ->
|
.mapIndexed { index, status ->
|
||||||
saveGiftForErasure(ERASURE_CUTOFF.minusDays(30), status, "IGNORE-$index")
|
saveGiftForErasure(null, status, "IGNORE-$index").also { (_, delivery) ->
|
||||||
|
delivery.deliveredAt = ERASURE_CUTOFF.minusDays(30)
|
||||||
|
delivery.undeliverableAt = ERASURE_CUTOFF.minusDays(30)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val originals = gifts.map { (_, delivery) -> personalData(delivery) }
|
val originals = gifts.map { (_, delivery) -> personalData(delivery) }
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
|
assertEquals(0, scheduler.eraseGiftPersonalData(ERASURE_NOW))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
gifts.forEachIndexed { index, (gift, _) ->
|
gifts.forEachIndexed { index, (gift, _) ->
|
||||||
@@ -672,56 +693,60 @@ class GiftSchedulerTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("12개 개인정보 중 어느 하나만 남아 있어도 나머지 값을 DB에서 지운다")
|
@DisplayName("12개 개인정보 중 어느 하나만 남아 있어도 나머지 값을 DB에서 지운다")
|
||||||
fun shouldEraseRemainingFieldsFromPartiallyClearedGift() {
|
fun shouldEraseRemainingFieldsFromPartiallyClearedGift(status: GiftStatus) {
|
||||||
val gifts = PERSONAL_DATA_FIELDS.mapIndexed { index, remainingField ->
|
val gifts = PERSONAL_DATA_FIELDS.mapIndexed { index, remainingField ->
|
||||||
saveGiftForErasure(ERASURE_CUTOFF, applicationNo = "PARTIAL-$index").also { (_, delivery) ->
|
saveGiftForErasure(ERASURE_CUTOFF, status, "PARTIAL-$index").also { (_, delivery) ->
|
||||||
PERSONAL_DATA_FIELDS.filter { it != remainingField }.forEach { it.set(delivery, null) }
|
PERSONAL_DATA_FIELDS.filter { it != remainingField }.forEach { it.set(delivery, null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(12, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
|
assertEquals(12, scheduler.eraseGiftPersonalData(ERASURE_NOW))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
gifts.forEach { (gift, _) -> assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) }
|
gifts.forEach { (gift, _) -> assertEquals(List(12) { null }, personalData(reloadDelivery(gift))) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("이미 개인정보가 모두 null인 선물은 첫 실행부터 변경하지 않는다")
|
@DisplayName("이미 개인정보가 모두 null인 선물은 첫 실행부터 변경하지 않는다")
|
||||||
fun shouldReturnZeroWhenAllPersonalDataIsAlreadyNull() {
|
fun shouldReturnZeroWhenAllPersonalDataIsAlreadyNull(status: GiftStatus) {
|
||||||
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF)
|
val (gift, delivery) = saveGiftForErasure(ERASURE_CUTOFF, status)
|
||||||
PERSONAL_DATA_FIELDS.forEach { it.set(delivery, null) }
|
PERSONAL_DATA_FIELDS.forEach { it.set(delivery, null) }
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
val updatedAt = reloadDelivery(gift).updatedAt
|
val updatedAt = reloadDelivery(gift).updatedAt
|
||||||
|
|
||||||
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
|
assertEquals(0, scheduler.eraseGiftPersonalData(ERASURE_NOW))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
|
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
|
||||||
assertEquals(updatedAt, reloadDelivery(gift).updatedAt)
|
assertEquals(updatedAt, reloadDelivery(gift).updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("개인정보 정리를 재실행하면 추가 변경 없이 0건을 반환한다")
|
@DisplayName("개인정보 정리를 재실행하면 추가 변경 없이 0건을 반환한다")
|
||||||
fun shouldReturnZeroWhenErasureRunsAgain() {
|
fun shouldReturnZeroWhenErasureRunsAgain(status: GiftStatus) {
|
||||||
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF)
|
val (gift, _) = saveGiftForErasure(ERASURE_CUTOFF, status)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
|
assertEquals(1, scheduler.eraseGiftPersonalData(ERASURE_NOW))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
val updatedAt = reloadDelivery(gift).updatedAt
|
val updatedAt = reloadDelivery(gift).updatedAt
|
||||||
assertEquals(0, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW.plusDays(1)))
|
assertEquals(0, scheduler.eraseGiftPersonalData(ERASURE_NOW.plusDays(1)))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
|
assertEquals(List(12) { null }, personalData(reloadDelivery(gift)))
|
||||||
assertEquals(updatedAt, reloadDelivery(gift).updatedAt)
|
assertEquals(updatedAt, reloadDelivery(gift).updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("개인정보 정리는 선물·회원·결제·동의·리뷰와 업무 시각을 DB에 보존하고 환불·푸시하지 않는다")
|
@DisplayName("개인정보 정리는 선물·회원·결제·동의·리뷰와 업무 시각을 DB에 보존하고 환불·푸시하지 않는다")
|
||||||
fun shouldPreserveGiftMemberPaymentConsentReviewAndBusinessTimestamps() {
|
fun shouldPreserveGiftMemberPaymentConsentReviewAndBusinessTimestamps(status: GiftStatus) {
|
||||||
val sender = entityManager.persist(
|
val sender = entityManager.persist(
|
||||||
Member(password = "test-password", nickname = "sender", profileImage = "sender-profile.png")
|
Member(password = "test-password", nickname = "sender", profileImage = "sender-profile.png")
|
||||||
.apply { pgChargeCan = 120 }
|
.apply { pgChargeCan = 120 }
|
||||||
@@ -733,7 +758,7 @@ class GiftSchedulerTest @Autowired constructor(
|
|||||||
UseCan(canUsage = CanUsage.GIFT, can = 60, rewardCan = 20).apply { member = sender }
|
UseCan(canUsage = CanUsage.GIFT, can = 60, rewardCan = 20).apply { member = sender }
|
||||||
)
|
)
|
||||||
val (gift, delivery) = saveGift(
|
val (gift, delivery) = saveGift(
|
||||||
GiftStatus.DELIVERED,
|
status,
|
||||||
APPLIED_AT.plusDays(3),
|
APPLIED_AT.plusDays(3),
|
||||||
senderMemberId = requireNotNull(sender.id),
|
senderMemberId = requireNotNull(sender.id),
|
||||||
recipientMemberId = requireNotNull(recipient.id),
|
recipientMemberId = requireNotNull(recipient.id),
|
||||||
@@ -752,7 +777,7 @@ class GiftSchedulerTest @Autowired constructor(
|
|||||||
delivery.inspectionCompletedAt = APPLIED_AT.plusDays(4)
|
delivery.inspectionCompletedAt = APPLIED_AT.plusDays(4)
|
||||||
delivery.deliveredAt = ERASURE_CUTOFF
|
delivery.deliveredAt = ERASURE_CUTOFF
|
||||||
delivery.canceledAt = APPLIED_AT.plusHours(1)
|
delivery.canceledAt = APPLIED_AT.plusHours(1)
|
||||||
delivery.undeliverableAt = APPLIED_AT.plusHours(2)
|
delivery.undeliverableAt = if (status == GiftStatus.UNDELIVERABLE) ERASURE_CUTOFF else APPLIED_AT.plusHours(2)
|
||||||
delivery.undeliverableReason = "보존할 업무 사유"
|
delivery.undeliverableReason = "보존할 업무 사유"
|
||||||
val review = entityManager.persist(
|
val review = entityManager.persist(
|
||||||
GiftReview(requireNotNull(gift.id), requireNotNull(sender.id), 5, "[\"안전\"]", "감사합니다")
|
GiftReview(requireNotNull(gift.id), requireNotNull(sender.id), 5, "[\"안전\"]", "감사합니다")
|
||||||
@@ -764,7 +789,7 @@ class GiftSchedulerTest @Autowired constructor(
|
|||||||
val originalReview = entityManager.find(GiftReview::class.java, review.id)
|
val originalReview = entityManager.find(GiftReview::class.java, review.id)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
assertEquals(1, scheduler.eraseDeliveredGiftPersonalData(ERASURE_NOW))
|
assertEquals(1, scheduler.eraseGiftPersonalData(ERASURE_NOW))
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val reloadedGift = entityManager.find(Gift::class.java, gift.id)
|
val reloadedGift = entityManager.find(Gift::class.java, gift.id)
|
||||||
@@ -849,13 +874,14 @@ class GiftSchedulerTest @Autowired constructor(
|
|||||||
private fun personalData(delivery: GiftDelivery): List<String?> = PERSONAL_DATA_FIELDS.map { it.get(delivery) }
|
private fun personalData(delivery: GiftDelivery): List<String?> = PERSONAL_DATA_FIELDS.map { it.get(delivery) }
|
||||||
|
|
||||||
private fun saveGiftForErasure(
|
private fun saveGiftForErasure(
|
||||||
deliveredAt: LocalDateTime?,
|
retentionStartedAt: LocalDateTime?,
|
||||||
status: GiftStatus = GiftStatus.DELIVERED,
|
status: GiftStatus = GiftStatus.DELIVERED,
|
||||||
applicationNo: String = APPLICATION_NO
|
applicationNo: String = APPLICATION_NO
|
||||||
): Pair<Gift, GiftDelivery> = saveGift(status, APPLIED_AT.plusDays(3), applicationNo = applicationNo)
|
): Pair<Gift, GiftDelivery> = saveGift(status, APPLIED_AT.plusDays(3), applicationNo = applicationNo)
|
||||||
.also { (_, delivery) ->
|
.also { (_, delivery) ->
|
||||||
fillDeliveryPersonalData(delivery)
|
fillDeliveryPersonalData(delivery)
|
||||||
delivery.deliveredAt = deliveredAt
|
delivery.deliveredAt = retentionStartedAt.takeIf { status == GiftStatus.DELIVERED }
|
||||||
|
delivery.undeliverableAt = retentionStartedAt.takeIf { status == GiftStatus.UNDELIVERABLE }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fillDeliveryPersonalData(delivery: GiftDelivery) {
|
private fun fillDeliveryPersonalData(delivery: GiftDelivery) {
|
||||||
@@ -928,11 +954,11 @@ class GiftSchedulerTest @Autowired constructor(
|
|||||||
GiftScheduler::runExpiredRecipientAddressGiftExpiration
|
GiftScheduler::runExpiredRecipientAddressGiftExpiration
|
||||||
),
|
),
|
||||||
ScheduledEntryPoint(
|
ScheduledEntryPoint(
|
||||||
"runDeliveredGiftPersonalDataErasure",
|
"runGiftPersonalDataErasure",
|
||||||
"eraseDeliveredGiftPersonalData",
|
"eraseGiftPersonalData",
|
||||||
"0 20 * * * *",
|
"0 20 * * * *",
|
||||||
"lock:gift-delivered-personal-data-erasure",
|
"lock:gift-delivered-personal-data-erasure",
|
||||||
GiftScheduler::runDeliveredGiftPersonalDataErasure
|
GiftScheduler::runGiftPersonalDataErasure
|
||||||
),
|
),
|
||||||
ScheduledEntryPoint(
|
ScheduledEntryPoint(
|
||||||
"runDeadlineReminderGifts",
|
"runDeadlineReminderGifts",
|
||||||
|
|||||||
+8
-5
@@ -18,6 +18,7 @@ import org.junit.jupiter.api.Assertions.assertThrows
|
|||||||
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.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource
|
||||||
import org.junit.jupiter.params.provider.NullAndEmptySource
|
import org.junit.jupiter.params.provider.NullAndEmptySource
|
||||||
import org.junit.jupiter.params.provider.ValueSource
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
@@ -130,12 +131,13 @@ class GiftAdminQueryServiceTest @Autowired constructor(
|
|||||||
assertEquals(listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), result.availableActions)
|
assertEquals(listOf("COMPLETE_INSPECTION", "MARK_UNDELIVERABLE"), result.availableActions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("개인정보가 지워진 관리자 선물 응답은 닉네임과 종료 상태 액션을 유지한다")
|
@DisplayName("개인정보가 지워진 관리자 선물 응답은 닉네임과 종료 상태 액션을 유지한다")
|
||||||
fun shouldMapErasedDeliveryStringsWithoutChangingActions() {
|
fun shouldMapErasedDeliveryStringsWithoutChangingActions(status: GiftStatus) {
|
||||||
val sender = saveMember("fan-alpha")
|
val sender = saveMember("fan-alpha")
|
||||||
val recipient = saveMember("creator-alpha")
|
val recipient = saveMember("creator-alpha")
|
||||||
val gift = saveGift("A-ERASED-DETAIL", sender, recipient, saveCategory(), GiftStatus.DELIVERED)
|
val gift = saveGift("A-ERASED-DETAIL", sender, recipient, saveCategory(), status)
|
||||||
requireNotNull(deliveryRepository.findByGiftId(gift.id!!)).apply {
|
requireNotNull(deliveryRepository.findByGiftId(gift.id!!)).apply {
|
||||||
senderName = null
|
senderName = null
|
||||||
senderPhoneNumber = null
|
senderPhoneNumber = null
|
||||||
@@ -147,16 +149,17 @@ class GiftAdminQueryServiceTest @Autowired constructor(
|
|||||||
entityManager.clear()
|
entityManager.clear()
|
||||||
|
|
||||||
val detail = service().getGiftDetail(gift.applicationNo)
|
val detail = service().getGiftDetail(gift.applicationNo)
|
||||||
val item = service().getGifts(GiftStatus.DELIVERED, null, null, 0, 20).items.single()
|
val item = service().getGifts(status, null, null, 0, 20).items.single()
|
||||||
|
|
||||||
assertEquals(AdminGiftMemberInfoResult("fan-alpha", "", "", ""), detail.senderInfo)
|
assertEquals(AdminGiftMemberInfoResult("fan-alpha", "", "", ""), detail.senderInfo)
|
||||||
assertEquals(AdminGiftMemberInfoResult("creator-alpha", "", "", ""), detail.recipientInfo)
|
assertEquals(AdminGiftMemberInfoResult("creator-alpha", "", "", ""), detail.recipientInfo)
|
||||||
assertNull(detail.inboundDeliveryInfo.courierCompanyName)
|
assertNull(detail.inboundDeliveryInfo.courierCompanyName)
|
||||||
assertNull(detail.inboundDeliveryInfo.trackingNumber)
|
assertNull(detail.inboundDeliveryInfo.trackingNumber)
|
||||||
assertEquals(GiftStatus.DELIVERED, detail.status)
|
assertEquals(status, detail.status)
|
||||||
assertEquals(emptyList<String>(), detail.availableActions)
|
assertEquals(emptyList<String>(), detail.availableActions)
|
||||||
assertEquals("fan-alpha", item.senderNickname)
|
assertEquals("fan-alpha", item.senderNickname)
|
||||||
assertEquals("creator-alpha", item.recipientNickname)
|
assertEquals("creator-alpha", item.recipientNickname)
|
||||||
|
assertEquals(status, item.status)
|
||||||
assertNull(item.courierCompanyName)
|
assertNull(item.courierCompanyName)
|
||||||
assertNull(item.trackingNumber)
|
assertNull(item.trackingNumber)
|
||||||
assertEquals(emptyList<String>(), item.availableActions)
|
assertEquals(emptyList<String>(), item.availableActions)
|
||||||
|
|||||||
+18
-10
@@ -28,6 +28,7 @@ import org.junit.jupiter.api.Assertions.assertThrows
|
|||||||
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.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.EnumSource
|
||||||
import org.junit.jupiter.params.provider.NullAndEmptySource
|
import org.junit.jupiter.params.provider.NullAndEmptySource
|
||||||
import org.junit.jupiter.params.provider.ValueSource
|
import org.junit.jupiter.params.provider.ValueSource
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
@@ -318,14 +319,15 @@ class GiftQueryServiceTest @Autowired constructor(
|
|||||||
assertNull(result.statusTimeline[1].occurredAt)
|
assertNull(result.statusTimeline[1].occurredAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("개인정보가 지워진 보낸 선물은 발신자 객체와 빈 문자열을 유지한다")
|
@DisplayName("개인정보가 지워진 보낸 선물은 발신자 객체와 빈 문자열을 유지한다")
|
||||||
fun shouldMapErasedSentSenderDetailsToEmptyStrings() {
|
fun shouldMapErasedSentSenderDetailsToEmptyStrings(status: GiftStatus) {
|
||||||
val sender = saveMember("fan")
|
val sender = saveMember("fan")
|
||||||
val recipient = saveMember("creator", "profile/creator.png")
|
val recipient = saveMember("creator", "profile/creator.png")
|
||||||
val gift = saveGift("G-ERASED-SENT", sender.id!!, recipient.id!!, GiftStatus.DELIVERED)
|
val gift = saveGift("G-ERASED-SENT", sender.id!!, recipient.id!!, status)
|
||||||
saveDelivery(gift).apply {
|
saveDelivery(gift).apply {
|
||||||
applyStatusTimes(GiftStatus.DELIVERED)
|
applyStatusTimes(status)
|
||||||
recipientAddressDeadlineAt = NOW.plusDays(7)
|
recipientAddressDeadlineAt = NOW.plusDays(7)
|
||||||
senderName = null
|
senderName = null
|
||||||
senderPhoneNumber = null
|
senderPhoneNumber = null
|
||||||
@@ -340,7 +342,7 @@ class GiftQueryServiceTest @Autowired constructor(
|
|||||||
val item = queryService().getGifts(sender.id!!, GiftListType.SENT, 0, 20, Lang.KO).items.single()
|
val item = queryService().getGifts(sender.id!!, GiftListType.SENT, 0, 20, Lang.KO).items.single()
|
||||||
|
|
||||||
assertEquals(GiftDirection.SENT, result.direction)
|
assertEquals(GiftDirection.SENT, result.direction)
|
||||||
assertEquals(GiftStatus.DELIVERED, result.status)
|
assertEquals(status, result.status)
|
||||||
assertEquals(GiftAddressResult("", "", ""), result.senderInfo)
|
assertEquals(GiftAddressResult("", "", ""), result.senderInfo)
|
||||||
assertNull(result.recipientAddress)
|
assertNull(result.recipientAddress)
|
||||||
assertNull(result.giftInfo.tracking)
|
assertNull(result.giftInfo.tracking)
|
||||||
@@ -348,21 +350,24 @@ class GiftQueryServiceTest @Autowired constructor(
|
|||||||
assertEquals("creator", result.giftInfo.recipientCreatorNickname)
|
assertEquals("creator", result.giftInfo.recipientCreatorNickname)
|
||||||
assertEquals(false, result.trackingRequired)
|
assertEquals(false, result.trackingRequired)
|
||||||
assertEquals(false, result.recipientAddressRequired)
|
assertEquals(false, result.recipientAddressRequired)
|
||||||
assertEquals(NOW.plusHours(4), result.statusTimeline.last().occurredAt)
|
assertEquals(NOW.plusHours(4).takeIf { status == GiftStatus.DELIVERED }, result.statusTimeline.last().occurredAt)
|
||||||
|
assertEquals(NOW.plusHours(4).takeIf { status == GiftStatus.UNDELIVERABLE }, result.delivery.undeliverableAt)
|
||||||
|
assertEquals("검수 실패".takeIf { status == GiftStatus.UNDELIVERABLE }, result.delivery.undeliverableReason)
|
||||||
assertEquals("creator", item.counterpartNickname)
|
assertEquals("creator", item.counterpartNickname)
|
||||||
assertEquals("https://cdn.test/profile/creator.png", item.counterpartProfileImageUrl)
|
assertEquals("https://cdn.test/profile/creator.png", item.counterpartProfileImageUrl)
|
||||||
assertEquals(false, item.recipientAddressRegistered)
|
assertEquals(false, item.recipientAddressRegistered)
|
||||||
assertEquals(NOW.plusDays(7), item.recipientAddressDeadlineAt)
|
assertEquals(NOW.plusDays(7), item.recipientAddressDeadlineAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
|
@EnumSource(value = GiftStatus::class, names = ["DELIVERED", "UNDELIVERABLE"])
|
||||||
@DisplayName("개인정보가 지워진 받은 선물은 발신자 정보와 수신자 배송지를 null로 유지한다")
|
@DisplayName("개인정보가 지워진 받은 선물은 발신자 정보와 수신자 배송지를 null로 유지한다")
|
||||||
fun shouldKeepReceivedSenderInfoNullAndRecipientAddressNullWhenErased() {
|
fun shouldKeepReceivedSenderInfoNullAndRecipientAddressNullWhenErased(status: GiftStatus) {
|
||||||
val sender = saveMember("fan", "profile/fan.png")
|
val sender = saveMember("fan", "profile/fan.png")
|
||||||
val recipient = saveMember("creator")
|
val recipient = saveMember("creator")
|
||||||
val gift = saveGift("G-ERASED-RECEIVED", sender.id!!, recipient.id!!, GiftStatus.DELIVERED)
|
val gift = saveGift("G-ERASED-RECEIVED", sender.id!!, recipient.id!!, status)
|
||||||
saveDelivery(gift).apply {
|
saveDelivery(gift).apply {
|
||||||
applyStatusTimes(GiftStatus.DELIVERED)
|
applyStatusTimes(status)
|
||||||
recipientAddressDeadlineAt = NOW.plusDays(7)
|
recipientAddressDeadlineAt = NOW.plusDays(7)
|
||||||
senderName = null
|
senderName = null
|
||||||
senderPhoneNumber = null
|
senderPhoneNumber = null
|
||||||
@@ -377,12 +382,15 @@ class GiftQueryServiceTest @Autowired constructor(
|
|||||||
val item = queryService().getGifts(recipient.id!!, GiftListType.RECEIVED, 0, 20, Lang.KO).items.single()
|
val item = queryService().getGifts(recipient.id!!, GiftListType.RECEIVED, 0, 20, Lang.KO).items.single()
|
||||||
|
|
||||||
assertEquals(GiftDirection.RECEIVED, result.direction)
|
assertEquals(GiftDirection.RECEIVED, result.direction)
|
||||||
|
assertEquals(status, result.status)
|
||||||
assertNull(result.senderInfo)
|
assertNull(result.senderInfo)
|
||||||
assertNull(result.recipientAddress)
|
assertNull(result.recipientAddress)
|
||||||
assertNull(result.giftInfo.tracking)
|
assertNull(result.giftInfo.tracking)
|
||||||
assertEquals("fan", result.giftInfo.senderNickname)
|
assertEquals("fan", result.giftInfo.senderNickname)
|
||||||
assertEquals(false, result.trackingRequired)
|
assertEquals(false, result.trackingRequired)
|
||||||
assertEquals(false, result.recipientAddressRequired)
|
assertEquals(false, result.recipientAddressRequired)
|
||||||
|
assertEquals(NOW.plusHours(4).takeIf { status == GiftStatus.UNDELIVERABLE }, result.delivery.undeliverableAt)
|
||||||
|
assertEquals("검수 실패".takeIf { status == GiftStatus.UNDELIVERABLE }, result.delivery.undeliverableReason)
|
||||||
assertEquals(NOW.plusDays(7), result.recipientAddressDeadlineAt)
|
assertEquals(NOW.plusDays(7), result.recipientAddressDeadlineAt)
|
||||||
assertEquals("fan", item.counterpartNickname)
|
assertEquals("fan", item.counterpartNickname)
|
||||||
assertEquals("https://cdn.test/profile/fan.png", item.counterpartProfileImageUrl)
|
assertEquals("https://cdn.test/profile/fan.png", item.counterpartProfileImageUrl)
|
||||||
|
|||||||
+217
-3
@@ -40,6 +40,7 @@ import kr.co.vividnext.sodalive.member.Gender
|
|||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberKind
|
import kr.co.vividnext.sodalive.member.MemberKind
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
|
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||||
@@ -52,6 +53,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
|
|||||||
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.params.ParameterizedTest
|
import org.junit.jupiter.params.ParameterizedTest
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource
|
||||||
import org.junit.jupiter.params.provider.MethodSource
|
import org.junit.jupiter.params.provider.MethodSource
|
||||||
import org.mockito.ArgumentMatchers.any
|
import org.mockito.ArgumentMatchers.any
|
||||||
import org.mockito.ArgumentMatchers.anyString
|
import org.mockito.ArgumentMatchers.anyString
|
||||||
@@ -76,11 +78,209 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
contentTranslationRepository: ContentTranslationRepository
|
contentTranslationRepository: ContentTranslationRepository
|
||||||
) {
|
) {
|
||||||
private var bannerNow = AudioContentBannerReservationFixtures.now
|
private var bannerNow = AudioContentBannerReservationFixtures.now
|
||||||
private val repository = DefaultHomeRecommendationQueryRepository(queryFactory, entityManager, contentTranslationRepository) {
|
private var eventNow = LocalDateTime.of(2026, 10, 31, 23, 59, 59, 123456789)
|
||||||
|
private val repository = DefaultHomeRecommendationQueryRepository(
|
||||||
|
queryFactory,
|
||||||
|
entityManager,
|
||||||
|
contentTranslationRepository,
|
||||||
|
eventNowProvider = { eventNow }
|
||||||
|
) {
|
||||||
bannerNow
|
bannerNow
|
||||||
}
|
}
|
||||||
private val scorePolicy = RecommendationScorePolicy()
|
private val scorePolicy = RecommendationScorePolicy()
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ANONYMOUS, KO, false, all|false",
|
||||||
|
"ANONYMOUS, JA, false, all|false",
|
||||||
|
"UNVERIFIED, KO, true, all|false",
|
||||||
|
"UNVERIFIED, JA, true, all|false",
|
||||||
|
"VERIFIED, KO, false, all|true",
|
||||||
|
"VERIFIED, JA, false, all|true",
|
||||||
|
"ADMIN, KO, false, all|false|true",
|
||||||
|
"ADMIN, JA, false, all|false|true"
|
||||||
|
)
|
||||||
|
@DisplayName("홈 EVENT 대상자는 배너 성인 선호와 무관하게 조회자의 인증 기록으로 판정한다")
|
||||||
|
fun shouldApplyHomeEventVisibilityPolicyBeforeLimit(
|
||||||
|
viewerType: String,
|
||||||
|
lang: Lang,
|
||||||
|
includeAdultBanners: Boolean,
|
||||||
|
expected: String
|
||||||
|
) {
|
||||||
|
val viewer = saveEventViewer(viewerType)
|
||||||
|
saveEventViewer("VERIFIED", "other-verified")
|
||||||
|
listOf(null, false, true).forEachIndexed { index, audience ->
|
||||||
|
val name = audience?.toString() ?: "all"
|
||||||
|
val event = saveEvent(name).apply {
|
||||||
|
this.lang = lang
|
||||||
|
isAdult = audience
|
||||||
|
}
|
||||||
|
saveBanner(name, AudioContentBannerType.EVENT, index, true, event = event, lang = lang)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
expected.split("|"),
|
||||||
|
repository.findHomeBanners(20, viewer?.id, includeAdultBanners, lang).map { it.thumbnailImage }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"ANONYMOUS, MISSING",
|
||||||
|
"ANONYMOUS, INACTIVE",
|
||||||
|
"ANONYMOUS, FUTURE",
|
||||||
|
"ANONYMOUS, EXPIRED",
|
||||||
|
"ANONYMOUS, WRONG_LANGUAGE",
|
||||||
|
"ADMIN, MISSING",
|
||||||
|
"ADMIN, INACTIVE",
|
||||||
|
"ADMIN, FUTURE",
|
||||||
|
"ADMIN, EXPIRED",
|
||||||
|
"ADMIN, WRONG_LANGUAGE"
|
||||||
|
)
|
||||||
|
@DisplayName("홈 EVENT는 관리자도 누락·비활성·기간 밖·다른 언어의 이벤트를 볼 수 없다")
|
||||||
|
fun shouldExcludeIneligibleHomeEventsForAnonymousAndAdmin(viewerType: String, hiddenReason: String) {
|
||||||
|
val viewer = saveEventViewer(viewerType)
|
||||||
|
val event = if (hiddenReason == "MISSING") {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
saveEvent("hidden").apply {
|
||||||
|
when (hiddenReason) {
|
||||||
|
"INACTIVE" -> isActive = false
|
||||||
|
"FUTURE" -> startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
|
||||||
|
"EXPIRED" -> endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
|
||||||
|
"WRONG_LANGUAGE" -> lang = Lang.JA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveBanner("hidden", AudioContentBannerType.EVENT, 0, true, event = event)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(emptyList<String>(), repository.findHomeBanners(20, memberId = viewer?.id).map { it.thumbnailImage })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 EVENT는 부적격 첫 후보를 limit 전에 제외하고 적격 두 번째 후보를 반환한다")
|
||||||
|
fun shouldExcludeHiddenHomeEventBeforeLimit() {
|
||||||
|
val hiddenEvent = saveEvent("hidden-first").apply { isAdult = true }
|
||||||
|
val visibleEvent = saveEvent("visible-second").apply { isAdult = false }
|
||||||
|
saveBanner("hidden-first", AudioContentBannerType.EVENT, 0, true, event = hiddenEvent)
|
||||||
|
saveBanner("visible-second", AudioContentBannerType.EVENT, 1, true, event = visibleEvent)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(listOf("visible-second"), repository.findHomeBanners(1).map { it.thumbnailImage })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("홈 EVENT 후보가 모두 인증 대상에서 제외되면 빈 목록을 반환한다")
|
||||||
|
fun shouldReturnEmptyWhenAllHomeEventAudiencesAreHidden() {
|
||||||
|
repeat(2) { index ->
|
||||||
|
val event = saveEvent("hidden-$index").apply { isAdult = true }
|
||||||
|
saveBanner("hidden-$index", AudioContentBannerType.EVENT, index, true, event = event)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(emptyList<String>(), repository.findHomeBanners(20).map { it.thumbnailImage })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("관리자의 EVENT 대상자 우회는 배너 자체 활성·탭·언어·예약·성인 조건을 우회하지 않는다")
|
||||||
|
fun shouldKeepBannerOwnFiltersForAdminHomeEvents() {
|
||||||
|
val admin = saveEventViewer("ADMIN")
|
||||||
|
val event = saveEvent("admin-event").apply { isAdult = true }
|
||||||
|
listOf("inactive", "tab", "language", "future", "expired", "adult", "visible").forEachIndexed { index, name ->
|
||||||
|
saveBanner(name, AudioContentBannerType.EVENT, index, true, event = event).apply {
|
||||||
|
when (name) {
|
||||||
|
"inactive" -> isActive = false
|
||||||
|
"tab" -> tab = saveMainTab("event-tab")
|
||||||
|
"language" -> lang = Lang.JA
|
||||||
|
"future" -> startDate = bannerNow.plusYears(1).withNano(0)
|
||||||
|
"expired" -> endDate = bannerNow.minusYears(1).withNano(0)
|
||||||
|
"adult" -> isAdult = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(listOf("visible"), repository.findHomeBanners(20, admin?.id).map { it.thumbnailImage })
|
||||||
|
assertEquals(
|
||||||
|
listOf("adult", "visible"),
|
||||||
|
repository.findHomeBanners(20, admin?.id, includeAdultBanners = true).map { it.thumbnailImage }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("EVENT 상세 정책은 LINK·CREATOR·SERIES 배너에 적용하지 않는다")
|
||||||
|
fun shouldNotApplyEventPolicyToNonEventHomeBanners() {
|
||||||
|
val creator = saveMember("non-event-creator", MemberRole.CREATOR)
|
||||||
|
val series = saveSeries("non-event-series", creator, true)
|
||||||
|
val event = saveEvent("non-event-hidden", isActive = false).apply {
|
||||||
|
isAdult = true
|
||||||
|
lang = Lang.JA
|
||||||
|
endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
|
||||||
|
}
|
||||||
|
listOf(AudioContentBannerType.LINK, AudioContentBannerType.CREATOR, AudioContentBannerType.SERIES)
|
||||||
|
.forEachIndexed { index, type ->
|
||||||
|
saveBanner(type.name, type, index, true, creator = creator, series = series, event = event)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
assertEquals(listOf("LINK", "CREATOR", "SERIES"), repository.findHomeBanners(20).map { it.thumbnailImage })
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource(
|
||||||
|
"0, START, true",
|
||||||
|
"123456789, START, true",
|
||||||
|
"999999999, START, true",
|
||||||
|
"0, END, true",
|
||||||
|
"123456789, END, true",
|
||||||
|
"999999999, END, true",
|
||||||
|
"0, BOTH, true",
|
||||||
|
"123456789, BOTH, true",
|
||||||
|
"999999999, BOTH, true",
|
||||||
|
"0, BEFORE, false",
|
||||||
|
"123456789, BEFORE, false",
|
||||||
|
"999999999, BEFORE, false",
|
||||||
|
"0, AFTER, false",
|
||||||
|
"123456789, AFTER, false",
|
||||||
|
"999999999, AFTER, false"
|
||||||
|
)
|
||||||
|
@DisplayName("홈 EVENT는 나노초 시작·종료를 포함하고 1ns 밖을 제외하며 배너 UTC 초 clock과 독립이다")
|
||||||
|
fun shouldApplyExactHomeEventBoundariesIndependentlyOfBannerClock(nano: Int, boundary: String, visible: Boolean) {
|
||||||
|
eventNow = eventNow.withNano(nano)
|
||||||
|
bannerNow = bannerNow.withNano(999999999)
|
||||||
|
entityManager.createNativeQuery("alter table event alter column start_date timestamp(9)").executeUpdate()
|
||||||
|
entityManager.createNativeQuery("alter table event alter column end_date timestamp(9)").executeUpdate()
|
||||||
|
val start = when (boundary) {
|
||||||
|
"START", "BOTH" -> eventNow
|
||||||
|
"BEFORE" -> eventNow.plusNanos(1)
|
||||||
|
else -> eventNow.minusDays(1)
|
||||||
|
}
|
||||||
|
val end = when (boundary) {
|
||||||
|
"END", "BOTH" -> eventNow
|
||||||
|
"AFTER" -> eventNow.minusNanos(1)
|
||||||
|
else -> eventNow.plusDays(1)
|
||||||
|
}
|
||||||
|
val event = saveEvent("boundary").apply {
|
||||||
|
startDate = start
|
||||||
|
endDate = end
|
||||||
|
}
|
||||||
|
saveBanner("boundary", AudioContentBannerType.EVENT, 0, true, event = event).apply {
|
||||||
|
startDate = bannerNow.withNano(0)
|
||||||
|
endDate = bannerNow.withNano(0)
|
||||||
|
}
|
||||||
|
flushAndClear()
|
||||||
|
val stored = entityManager.find(Event::class.java, event.id)
|
||||||
|
assertEquals(start, stored.startDate)
|
||||||
|
assertEquals(end, stored.endDate)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
if (visible) listOf("boundary") else emptyList(),
|
||||||
|
repository.findHomeBanners(1).map { it.thumbnailImage }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("홈 배너는 limit 전에 미래 및 만료 예약을 제외한다")
|
@DisplayName("홈 배너는 limit 전에 미래 및 만료 예약을 제외한다")
|
||||||
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
|
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
|
||||||
@@ -2825,6 +3025,20 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
return member
|
return member
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveEventViewer(viewerType: String, seed: String = "event-viewer"): Member? {
|
||||||
|
if (viewerType == "ANONYMOUS") return null
|
||||||
|
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
|
||||||
|
val viewer = saveMember(seed, role)
|
||||||
|
if (viewerType == "VERIFIED") {
|
||||||
|
entityManager.persist(
|
||||||
|
Auth("verified", "19900101", "event-ci-${viewer.id}", "event-di-${viewer.id}", 1).apply {
|
||||||
|
member = viewer
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return viewer
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveCharacter(name: String, isActive: Boolean, originalWork: OriginalWork? = null): ChatCharacter {
|
private fun saveCharacter(name: String, isActive: Boolean, originalWork: OriginalWork? = null): ChatCharacter {
|
||||||
val creatorMember = Member(
|
val creatorMember = Member(
|
||||||
email = null,
|
email = null,
|
||||||
@@ -3039,8 +3253,8 @@ class DefaultHomeRecommendationQueryRepositoryTest @Autowired constructor(
|
|||||||
popupImage = null,
|
popupImage = null,
|
||||||
link = "https://$title.test",
|
link = "https://$title.test",
|
||||||
title = title,
|
title = title,
|
||||||
startDate = LocalDateTime.of(2026, 5, 1, 0, 0),
|
startDate = eventNow.minusDays(1),
|
||||||
endDate = LocalDateTime.of(2026, 6, 1, 0, 0),
|
endDate = eventNow.plusDays(1),
|
||||||
isActive = isActive
|
isActive = isActive
|
||||||
)
|
)
|
||||||
entityManager.persist(event)
|
entityManager.persist(event)
|
||||||
|
|||||||
Reference in New Issue
Block a user