From 610a8edce4d025a23794f000a1a71f391b77c571 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 31 Oct 2026 21:53:54 +0900 Subject: [PATCH] =?UTF-8?q?docs(recommendation):=20=EB=85=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=EA=B3=BC=20=EA=B5=AC=ED=98=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../http-qa.md | 71 ++++ .../plan-task.md | 344 ++++++++++++++++++ .../prd.md | 193 ++++++++++ 3 files changed, 608 insertions(+) create mode 100644 docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/http-qa.md create mode 100644 docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/plan-task.md create mode 100644 docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/prd.md diff --git a/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/http-qa.md b/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/http-qa.md new file mode 100644 index 00000000..02daaade --- /dev/null +++ b/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/http-qa.md @@ -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 기록을 따른다. diff --git a/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/plan-task.md b/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/plan-task.md new file mode 100644 index 00000000..fcc1b58e --- /dev/null +++ b/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/plan-task.md @@ -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`. +- 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 명령은 향후 구현 검증 계획일 뿐 실행 결과가 아니다. 구현 체크박스와 기능 수용 기준은 미완료다. diff --git a/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/prd.md b/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/prd.md new file mode 100644 index 00000000..a68afe73 --- /dev/null +++ b/docs/20261031_추천화면AI캐릭터및이벤트배너노출정책/prd.md @@ -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 진단은 수행하지 않았다. 운영/저장소 시간대 설정은 변경하지 않았다.