diff --git a/docs/20260819_이벤트_접속국가별_언어필터/plan-task.md b/docs/20260819_이벤트_접속국가별_언어필터/plan-task.md index 9f538323..41fdf779 100644 --- a/docs/20260819_이벤트_접속국가별_언어필터/plan-task.md +++ b/docs/20260819_이벤트_접속국가별_언어필터/plan-task.md @@ -4,7 +4,7 @@ **Goal:** 관리자가 언어를 지정해 이벤트를 등록·식별하고, 앱 이벤트 목록과 팝업이 기존 국가 판정에 맞는 언어만 반환하게 한다. -**Architecture:** 기존 `Lang`과 `MemberContentPreferenceService.resolveCountryCode(member)`를 재사용한다. `EventRepository`에 nullable `lang` 조건을 추가해 두 앱 API는 KO/JA를 전달하고, 관리자 목록과 콘텐츠 메인 탭의 기존 내부 조회는 null을 전달해 언어 전체 조회를 유지한다. +**Architecture:** 기존 `Lang`과 `MemberContentPreferenceService.resolveCountryCode(member)`를 재사용한다. 앱 목록·팝업은 KO/JA를 조회하고, Phase 3에서 콘텐츠 메인 탭 6곳도 회원 국가 판정에 따른 필수 언어를 조회한다. 관리자 목록은 언어 전체 조회를 유지한다. **Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, JPA/QueryDSL, JUnit 5, Mockito, MockMvc, Gradle Wrapper, MySQL @@ -12,11 +12,11 @@ | 문서 항목 | 내용 | |---|---| -| 상태 | 구현 및 회귀 수정 완료 | +| 상태 | Phase 3 후속 언어 필터 구현·전체 검증·독립 리뷰 완료, 미배포 | | 작성일 | 2026-08-19 | | 요구사항 기준 | `docs/20260819_이벤트_접속국가별_언어필터/prd.md` | | API 기준 | PRD 8절 | -| 현재 Phase | Phase 2 완료 | +| 현재 Phase | Phase 1~3 완료 | | 현재 활성 Goal | 없음 | | 다음 Goal | 없음 | @@ -27,9 +27,9 @@ - 이벤트 수정 API와 레거시 `POST /event`의 요청 계약은 변경하지 않는다. - 기존 이벤트와 레거시 등록의 기본 언어는 `KO`다. - 관리자 목록은 기존 활성·종료 시각 조건을 유지하고 언어 조건을 추가하지 않는다. -- `GET /event`, `GET /event/popup`만 `JP -> Lang.JA`, 그 외 `Lang.KO`를 사용한다. +- `GET /event`, `GET /event/popup`과 Phase 3의 메인 탭 이벤트 배너는 `JP -> Lang.JA`, 그 외 `Lang.KO`를 사용한다. - 로그인 회원은 기존 강제 KR/JP 매핑을 유지하고, 비로그인·국가 누락은 기존 정규화·KR 기본값을 사용한다. -- 콘텐츠 메인 탭 6곳의 `EventService.getEventList(isAdult)`는 언어 필터 없이 유지한다. +- [과거 기준, `DEC-009`에서 정정] 콘텐츠 메인 탭 6곳은 원래 언어 필터 없이 조회했으나 이번 후속 Phase에서 국가별 언어 필터를 적용한다. 과거 P2 구현·검증 기록은 당시 상태로 보존한다. - 언어 필터는 QueryDSL `where`에서 정렬·`fetchFirst()` 전에 적용하고 메모리 후처리를 추가하지 않는다. - 신규 dependency·cache·resolver abstraction·관련 없는 refactoring을 추가하지 않는다. - 모든 구현 Task는 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR 순서를 지킨다. @@ -43,6 +43,7 @@ |---:|---|---:|---|---| | 1 | 완료 | `3/3` | 없음 | 없음 | | 2 | 완료 | `2/2` + Gate + `P2-R1` | 없음 | 없음 | +| 3 | 완료 | `1/1` + Gate | 없음 | 운영 배포 제외 | - 동시에 하나의 미완료 goal만 운용한다. - 구현 완료 즉시 해당 Task 체크박스와 현재 상태를 갱신한다. @@ -58,13 +59,13 @@ - 관리자 이벤트 목록 `lang` 응답과 언어 전체 조회 - 앱 이벤트 목록·팝업의 접속 국가별 KO/JA 필터 - 기존 강제 국가 매핑과 비로그인 KR 기본값 재사용 -- 콘텐츠 메인 탭 6곳의 언어 전체 조회 비회귀 +- P2 당시 콘텐츠 메인 탭 6곳의 언어 전체 조회 비회귀 기록. `DEC-009`로 후속 언어 필터가 승인되어 현재 Phase 3에서 대체한다. ### 제외 - 이벤트 수정 시 언어 변경 - 레거시 `POST /event`의 `lang` request parameter -- 콘텐츠 메인 탭 6곳의 국가별 이벤트 필터 +- 과거 제외 범위: 콘텐츠 메인 탭 6곳의 국가별 이벤트 필터. `DEC-009` 후속 승인으로 현재 범위에 포함하며 과거 체크박스·검증은 당시 이력으로 유지한다. - EN 이벤트를 반환할 앱 국가 정책 - 다른 언어 fallback, `Accept-Language` 판정, 신규 dependency·공통 abstraction - 기존 이벤트 성인·활성·게시 기간·정렬·URL 정책 변경 @@ -82,6 +83,7 @@ | `EVENT-LANG-007` | `P2-T2` | `lang = null`의 KO/EN/JA 전체 조회 mutation RED·GREEN | | `EVENT-LANG-008` | `P1-T1`, `P1-GATE` | KO 기본 저장 테스트와 KO backfill DDL 대조 | | `EVENT-LANG-009` | `P2-T1`, `P2-GATE` | QueryDSL `where` 조건과 정렬·`fetchFirst()` 선행 검증 | +| `EVENT-LANG-010` | `P3-T1`, `P3-GATE` | 여섯 메인 탭의 JP→JA/그 외→KO HTTP·service 검증 및 기존 `isAdult` 계산 비회귀 | ## 파일 구조 @@ -832,3 +834,67 @@ P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE → P2-R1 - 전체 회귀를 실행하지 않았으면 생략 근거와 대신 실행한 focused·직접 영향 명령을 적는다. - 남은 항목과 갱신한 PRD·plan·review 경로를 적는다. - 성공을 추정하지 않고 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다. + +## Phase 3: 콘텐츠 메인 탭 이벤트 언어 필터 후속 + +2026-11-02 승인에 따라 과거 `EVENT-LANG-007`의 언어 미필터 요구를 정정한다. P2 완료 체크박스와 검증 기록은 당시 요구 범위의 이력이며 되돌리지 않는다. + +### Task 3.1 여섯 메인 탭의 국가별 이벤트 언어 조회 + +**Goal `P3-T1`:** 콘텐츠 메인 탭 여섯 `eventBannerList`가 기존 회원 국가 판정에 따른 JA 또는 KO 이벤트만 조회하고 기존 성인 계산을 유지한다. + +- 시작 조건: `DEC-009`, `EVENT-LANG-010` 승인. +- 완료 증거: 기준 테스트의 사전 통과 기록, RED에서 국가별 언어·기존 성인 조건 assertion 실패, GREEN focused 통과, 실제 HTTP 경로 검증, 전체 회귀·ktlint·bootJar 및 독립 리뷰 결과. +- 범위 밖: 이벤트 상세·팝업 정책, 메인 탭 응답 DTO/URL 변경, 링크·이벤트 파싱, 새 dependency, 운영 DDL 실행, UI 및 기존 성인 계산 변경. +- **Files:** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt` + - Verify/reuse (변경 불필요): `src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt` + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/event/EventBannerLanguageIntegrationTest.kt` (new; actual HTTP coverage for the six main-tab endpoints) + - Baseline/related HTTP regression: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeServiceIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt` +- **Interfaces:** reuse `MemberContentPreferenceService.resolveCountryCode(member)` and existing `Lang`; each current caller supplies member-derived language while retaining its existing `isAdult` value. Repository language is non-null on these six paths; existing low-level callers without requested filtering retain their nullable contract. No public response field is added. +- [x] **Baseline:** 부모의 사전 통과 113개 중 기존 `EventServiceTest`, `EventRepositoryTest`, `EventControllerIntegrationTest`, `AudioContentMainTabHomeServiceIntegrationTest` 15개를 재사용했다. 원본: `/tmp/soda-banner-ulw.HDkpg4/baseline.log`. +- [x] **RED:** 여섯 실제 HTTP 경로가 실제 EventService/Repository를 사용하도록 mixed KO/JA/EN 및 성인 true/false/null 행을 추가했다. 국가별 언어와 기존 성인 조건을 응답 ID로 검증한다. +- [x] **RED 확인:** 신규 HTTP 7개 모두 언어 미필터 assertion으로 실패했다. 컴파일·설정 실패가 아니다. `/tmp/soda-banner-impl.CfAkhD/event-red.log`, `event-red.xml`. +- [x] **GREEN:** 여섯 기존 호출부에 member 국가로 선택한 `Lang`만 추가했다. 기존 성인 계산식·EventService/Repository production code·공개 DTO는 변경하지 않았다. +- [x] **GREEN 확인:** focused 22개가 실패·오류·무시 0으로 통과했다. JP/KR/US/누락, 성인 설정 false/true, 강제 JP/KR 회원 및 익명 홈을 실제 HTTP 64건으로 확인했다. +- [x] **REFACTOR:** 전체 2,928개 테스트, 공백 수정 후 영향 26개 HTTP 테스트·전체 ktlint·bootJar 및 독립 리뷰를 확인했다. 운영 DDL은 실행하지 않았다. + +```bash +export TMPDIR=/tmp/soda-banner-impl.CfAkhD NODE_COMPILE_CACHE=/tmp/soda-banner-impl.CfAkhD/node-compile-cache BUN_RUNTIME_TRANSPILER_CACHE_PATH=/tmp/soda-banner-impl.CfAkhD/bun-transpiler +./gradlew test --tests 'kr.co.vividnext.sodalive.event.EventServiceTest' --tests 'kr.co.vividnext.sodalive.event.EventRepositoryTest' --tests 'kr.co.vividnext.sodalive.event.EventBannerLanguageIntegrationTest' --tests 'kr.co.vividnext.sodalive.event.EventControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.content.main.tab.home.AudioContentMainTabHomeServiceIntegrationTest' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process +./gradlew test ktlintCheck bootJar --continue --console=plain +``` + +**Expected:** focused tests exercise real country-to-language and HTTP paths; full suite, lint, and bootJar exit 0. LSP diagnostics are unavailable in this environment and must be reported as not run, not as passing. + +### Phase 3 Gate `P3-GATE` + +- 시작 조건: `P3-T1` 완료. +- 완료 증거: six-caller diff confirms preserved `isAdult`, real HTTP tests show country language filtering, focused/full tests and lint/build pass, independent review has no unresolved blocker. +- 범위 밖: UI, production DB/DDL execution, event detail/adult policy changes. +- [x] 여섯 호출부·반환 언어·공개 DTO 불변을 확인하고 P1/P2 이력을 보존했다. 전체 검증 및 최종 Oracle 승인으로 Gate를 완료했다. + +### Phase 3 구현 Progress, 2026-11-02 + +- 현재 `test` 브랜치/HEAD `7fce26ad`에서 구현했으며 worktree·commit·stage·push는 하지 않았다. 기존 P1/P2 기록과 부모가 승인한 PRD 변경은 보존했다. +- RED 명령은 위 focused 명령에서 `--tests 'kr.co.vividnext.sodalive.event.EventBannerLanguageIntegrationTest'` 하나만 선택했다. JP의 예상 2개 대신 KO/EN까지 6개가 반환되는 assertion 실패 7건을 확인했다. 로그 `event-red.log`, 원본 HTTP/XML `event-red.xml`은 모두 `/tmp/soda-banner-impl.CfAkhD/` 아래에 있다. +- GREEN은 위 focused 명령이며 `event-green-retry.log`에 `BUILD SUCCESSFUL in 4m 15s`가 남는다. Service 4, Repository 5, 기존 Controller 5, 신규 Banner HTTP 7, 기존 Home HTTP 1로 총 22개가 통과했다. 첫 `event-green.log` 실행은 test compilation 중 600초 도구 제한으로 종료되어 통과 근거로 사용하지 않았다. 프로세스 종료 확인 후 제한을 늘려 순차 재실행했다. +- 실제 JWT/랜덤 포트/H2 Event 행/기존 resolver를 사용했다. 여섯 경로 각각 10개 국가·성인·강제 매핑 조합과 익명 홈 4개를 확인했고 `Accept-Language: en`에도 국가 기준 언어가 선택됐다. `event-http-evidence.log`의 64건과 `event-green.xml`에 실제 상태·본문이 남으며 토큰은 출력하지 않았다. +- 구현 및 focused/HTTP 범위만 완료했다. 사용자 지정 종료 경계에 따라 전체 suite·ktlint·bootJar·독립 리뷰와 `P3-GATE`는 부모 작업으로 미완료 유지한다. LSP 진단 도구는 없어 실행하지 않았다. 구현 보고서: `/tmp/soda-banner-impl.CfAkhD/implementation-report.md`. +- 최종 source 상태에서 위 5개 클래스와 Phase 7의 6개 클래스를 하나의 focused 명령으로 순차 재검증했다. `final-focused.log`: `BUILD SUCCESSFUL in 8m 8s`, 11개 클래스 145개, 실패·오류·무시 0. 최종 원본은 같은 root의 `final-TEST-*.xml`, 실제 HTTP 본문은 `final-http-db-evidence.log`다. 전체 suite 실행은 아니다. + +### Phase 3 최종 검증 Progress, 2026-11-03 + +- 부모 실행: `./gradlew test ktlintCheck bootJar --continue --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`. 전체 XML 집계는 336 suites / 2,928 tests / failures=0 / errors=0 / skipped=0이며 `bootJar`도 성공했다. 최초 명령은 서브 배너 테스트 팩토리 인자 줄바꿈 한 곳의 ktlint 오류로 exit 1 (15m 50s)이었다. 테스트 실패나 제품 코드 오류는 아니었다. +- 해당 테스트 인자 여섯 개를 값·순서 변경 없이 줄별로 배치한 뒤 `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.RecommendationSubBannerIntegrationTest' ktlintCheck bootJar --continue --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`를 실행했다. exit 0, `BUILD SUCCESSFUL in 4m 32s`, 26 tests / failures=0 / errors=0 / skipped=0, 전체 lint 통과, bootJar UP-TO-DATE다. +- 전체 회귀 후 변경은 테스트의 공백뿐이므로 전체 2,928개를 다시 실행하지 않고 영향받은 26개 HTTP 시나리오를 재실행했다. 최신 `build/test-results/test`와 HTML은 이 26개 실행으로 갱신됐다. 전체 집계는 갱신 전 부모가 직접 확인한 기록이다. +- 부모는 전체 실행으로 실제 HTTP 테스트를 다시 구동하고 두 추천의 JP/KR/US/누락·강제 국가, 기간·소프트 삭제·공개 6필드 재검증 결과를 확인했다. 제품 JAR: `build/libs/sodalive-0.0.1-SNAPSHOT.jar`. +- LSP 도구 부재로 미실행이며 Gradle Kotlin compile 및 ktlint를 대체 진단으로 사용했다. 기존 무관 파일의 컴파일/Gradle 경고와 H2 scheduler의 MySQL 문법 로그는 테스트 실패와 구분하며 수정하지 않았다. +- 원본 로그는 실행 당시 `/tmp/soda-banner-ulw.HDkpg4/full-verification.log`, `final-verification.log`에 보관했다. 소유 임시 자료는 종료 시 정리하고 핵심 결과는 본 기록과 [공동 리뷰](../20261029_가로형서브배너/reviews/banner-language-review.md)에 보존한다. +- 독립 Oracle 리뷰는 명세·코드·시나리오를 승인했고, 후속 공백 delta와 최종 검증 증거도 APPROVE했다. 미해결 발견 사항 없음. `P3-T1/P3-GATE` 완료이며 운영 배포는 별도다. +- 메인 탭 구현·테스트 커밋 `255da2b6`, hook/메시지 검사 통과. 소유 QA 프로세스와 임시 경로 정리는 공동 리뷰의 종료 증거에 기록했다. 운영 배포·push는 하지 않았다. diff --git a/docs/20260819_이벤트_접속국가별_언어필터/prd.md b/docs/20260819_이벤트_접속국가별_언어필터/prd.md index 61b407c4..c20a488d 100644 --- a/docs/20260819_이벤트_접속국가별_언어필터/prd.md +++ b/docs/20260819_이벤트_접속국가별_언어필터/prd.md @@ -4,9 +4,9 @@ | 항목 | 내용 | |---|---| -| 문서 상태 | 구현 기준 확정 | +| 문서 상태 | Phase 3 메인 탭 후속 언어 필터 구현·전체 검증·독립 리뷰 완료, 미배포 | | 작성일 | 2026-08-19 | -| 최종 수정일 | 2026-08-20 | +| 최종 수정일 | 2026-11-03 | | 대상 제품 | 관리자 이벤트 배너, 앱 이벤트 목록·팝업 | | 작성자·결정권자 | 사용자 | | 관련 API Contract | 별도 문서 없음. 이 문서 8절을 기준으로 사용 | @@ -38,7 +38,7 @@ - 이벤트 수정 API에서 `lang`을 변경하지 않는다. - `EventController.createEvent`의 `POST /event` 요청 계약을 변경하지 않는다. -- 콘텐츠 메인 탭 6곳이 `EventService.getEventList(isAdult)`로 조립하는 `eventBannerList`에 언어 필터를 적용하지 않는다. +- [2026-11-02 정정, DEC-009] 콘텐츠 메인 탭 6곳의 `eventBannerList`는 Phase 2 당시 언어 필터 제외 결정의 범위였으나, 이번 후속 요구로 각 호출에 기존 접속 국가 판정에 따른 언어 필터를 적용한다. 과거 구현·검증 기록은 당시 범위로 보존한다. - `Lang`에 새 enum을 추가하거나 `EN`을 노출할 접속 국가 정책을 추가하지 않는다. - `Accept-Language`를 이벤트 언어 판정에 사용하지 않는다. - 요청 언어의 이벤트가 없을 때 다른 언어로 fallback하지 않는다. @@ -92,9 +92,10 @@ | `EVENT-LANG-004` | 확정 | `GET /event`는 판정 국가가 JP이면 JA, 그 외에는 KO 이벤트만 반환한다. | 서로 다른 언어의 활성 이벤트가 함께 있어도 판정 언어만 `eventList`에 있다. | `P2-T1` | | `EVENT-LANG-005` | 확정 | `GET /event/popup`은 판정 국가가 JP이면 JA, 그 외에는 KO 팝업만 반환한다. | 다른 언어 팝업의 ID가 더 최신이어도 언어 필터 후 선택된 팝업을 반환한다. | `P2-T1` | | `EVENT-LANG-006` | 확정 | 로그인 회원은 기존 강제 KR/JP 매핑을 포함한 국가 판정을 사용한다. | 강제 JP 회원은 비JP 헤더에서도 JA, 강제 KR 회원은 JP 헤더에서도 KO를 선택한다. | `P2-T1` | -| `EVENT-LANG-007` | 확정 | 콘텐츠 메인 탭의 기존 `eventBannerList`는 언어 필터 없이 조회한다. | `getEventList(isAdult)` 경로가 `lang = null`을 유지하고 KO/JA/EN 모두를 조회할 수 있다. | `P2-T2` | +| `EVENT-LANG-007` | 과거 요구, `DEC-009`에서 정정 | Phase 2 당시 콘텐츠 메인 탭 `eventBannerList`는 언어 필터 없이 조회하도록 확정했다. 이 요구는 `EVENT-LANG-010`으로 대체됐으며 현재 적용하지 않는다. | P2 당시 `lang = null`의 전체 언어 조회를 검증한 완료 증거는 역사적 기록으로 보존한다. | `P2-T2` (당시), `P3-T1` (대체) | | `EVENT-LANG-008` | 확정 | 기존 이벤트와 레거시 등록 경로의 언어는 KO다. | 기존 row를 KO로 backfill하고 `NOT NULL DEFAULT 'KO'`를 적용하며 `POST /event`에 `lang`을 추가하지 않는다. | `P1-T1` | | `EVENT-LANG-009` | 확정 | 앱 언어 필터는 DB 조회 조건으로 적용한다. | QueryDSL `where` 내 `event.lang.eq(lang)`이 정렬·`fetchFirst()` 전에 적용된다. | `P2-T1` | +| `EVENT-LANG-010` | 확정, 2026-11-02 후속 | 콘텐츠 메인 탭 6곳의 `eventBannerList`도 접속 국가별 이벤트 언어를 조회한다. | 기존 `resolveCountryCode(member)` 결과가 JP이면 JA, 그 외에는 KO만 반환하며 각 호출부의 기존 `isAdult` 계산과 응답 계약은 유지한다. | `P3-T1`, `P3-GATE` | ## 8. API 계약 @@ -155,7 +156,8 @@ - [x] 판정 국가 JP에서 `GET /event`, `GET /event/popup`이 JA만 반환한다. (`EVENT-LANG-004~006`) - [x] JP 이외와 국가 누락에서 두 API가 KO만 반환한다. (`EVENT-LANG-004`, `EVENT-LANG-005`) - [x] 다른 언어의 최신 팝업이 있어도 언어 필터 후 선택된 팝업이 반환된다. (`EVENT-LANG-005`, `EVENT-LANG-009`) -- [x] 콘텐츠 메인 탭 6곳의 기존 `eventBannerList`는 언어 필터 없이 조회된다. (`EVENT-LANG-007`) +- [x] Phase 2 당시 콘텐츠 메인 탭 6곳의 `eventBannerList`가 언어 필터 없이 조회됨을 검증했다. 이는 당시 요구에 대한 완료 이력이다. (`EVENT-LANG-007`) +- [x] 후속 요구: 콘텐츠 메인 탭 6곳의 `eventBannerList`가 JP→JA, 그 외→KO만 조회하며 기존 `isAdult` 계산을 유지한다. (`EVENT-LANG-010`, `P3-T1`, `P3-GATE`; 2026-11-03 전체 2,928개 및 실제 HTTP 검증 완료) - [x] 기존 성인·활성·게시 기간·정렬·URL·응답 스키마 정책이 유지된다. ### 11.1 구현 완료 검증 — 2026-08-20 @@ -180,6 +182,7 @@ | `EVENT-LANG-001~003`, `EVENT-LANG-008` | 1 | `P1-T1`, `P1-T2`, `P1-GATE` | 엔티티·관리자 controller/service/repository 통합 테스트 | | `EVENT-LANG-004~006`, `EVENT-LANG-009` | 2 | `P2-T1`, `P2-GATE` | `EventServiceTest`, `EventRepositoryTest`, `EventControllerIntegrationTest` | | `EVENT-LANG-007` | 2 | `P2-T2`, `P2-GATE` | 언어 없는 기존 서비스 호출과 Repository null 조건 비회귀 테스트 | +| `EVENT-LANG-010` | 3 | `P3-T1`, `P3-GATE` | 여섯 호출부의 국가별 language filter, adult 조건 비회귀, 실제 HTTP 테스트 | ## 14. Decision Log @@ -193,6 +196,7 @@ | 2026-08-19 | `DEC-006` | 확정 | 기존 이벤트는 KO로 backfill하고 `NOT NULL DEFAULT 'KO'`를 적용한다. | 사용자 인터뷰 A, 기존 배너 DDL 패턴 | `EVENT-LANG-008`, `P1-T1` | | 2026-08-19 | `DEC-007` | 확정 | 언어 입력은 `AdminEventBannerController`에만 추가하고 레거시 `POST /event`는 KO 기본값을 사용한다. | 사용자 인터뷰 A | `EVENT-LANG-002`, `EVENT-LANG-008`, `P1-T1~T2` | | 2026-08-20 | `DEC-008` | 확정 | `GET /event`의 익명 접근은 유지하고 `GET /event/popup`은 기준 HEAD의 인증 필수 정책을 유지한다. | 구현 리뷰에서 언어 필터와 무관한 익명 공개 확장을 확인 | `EVENT-LANG-005`, `P2-R1` | +| 2026-11-02 | `DEC-009` | 정정 | 기존 콘텐츠 메인 탭 6곳의 언어 미필터 요구를 대체한다. 각 `eventBannerList` 호출은 기존 회원 국가 판정 `resolveCountryCode(member)`를 사용해 JP→JA, 그 외→KO로 필터링한다. 기존 `isAdult` 계산, 응답 계약 및 과거 구현 기록은 보존한다. | 승인된 최소 후속 언어 수정 | `EVENT-LANG-007`의 과거 범위, `EVENT-LANG-010`, `P3-T1` | ## 15. 변경 관리 @@ -202,3 +206,10 @@ 2. 관련 요구사항·수용 기준·API 계약을 갱신한다. 3. `plan-task.md`의 범위·Files·Interfaces·체크박스를 코드 변경 전에 먼저 갱신한다. 4. 기존 Progress·검증 기록을 삭제하거나 덮어쓰지 않는다. + +## 16. 후속 요구사항: 메인 탭 이벤트 언어 — 2026-11-02 + +- 승인된 변경은 콘텐츠 메인 탭의 기존 `eventBannerList`를 조립하는 6개 호출부에만 적용한다. 접속 국가 판정은 기존 `MemberContentPreferenceService.resolveCountryCode(member)`를 재사용해 `JP`는 `Lang.JA`, 그 외는 `Lang.KO`로 변환한다. +- 기존 각 호출부의 `isAdult` 계산과 성인 필터 동작은 변경하지 않는다. 언어 조건은 기존 Event Repository 조회 경로에서 DB 조건으로 적용한다. +- 이벤트 상세, 팝업/API 이외 정책, 공개 응답 DTO·URL 처리, 이벤트 연결 파싱은 변경하지 않는다. 새 dependency와 별도 resolver를 추가하지 않는다. +- 관련 성공 기준·추적표는 `EVENT-LANG-010` 및 `P3-T1`/`P3-GATE`에 연결한다. 과거 `EVENT-LANG-007`과 완료된 P2 증거는 당시 언어 전체 조회 계약의 기록이며 현재 메인 탭 요구사항을 지배하지 않는다. diff --git a/docs/20261029_가로형서브배너/admin-prompt.md b/docs/20261029_가로형서브배너/admin-prompt.md index a7882b2d..bf464ff6 100644 --- a/docs/20261029_가로형서브배너/admin-prompt.md +++ b/docs/20261029_가로형서브배너/admin-prompt.md @@ -4,6 +4,8 @@ **상태:** 관리자 CRUD API 구현·검증 완료, 미배포. 백엔드 전체 테스트·lint·빌드와 독립 코드 리뷰를 통과했다. 화면 배치, 폼 표현, 삭제 확인 등 UI 선택은 작성자 추천이며 계약으로 가장하지 않는다. +2026-11-03 언어 후속 계약의 전체 2,928개 테스트·영향 HTTP 재검증·lint·빌드·독립 리뷰를 완료했다. 신규 환경은 `create-sub-banner.sql`, 기존 테이블은 `20261102_add_sub_banner_lang.sql`로 언어 컬럼을 서버 배포 전에 준비해야 한다. 운영 적용은 미실행이며 이 프롬프트의 UI 작업에서 SQL을 실행하지 않는다. + ## 목표 가로형 서브 배너의 목록·생성·수정·삭제 UI를 제공한다. 레코드 하나는 `HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION` placement 하나에 속한다. 별도 상세 조회는 없으므로 목록 데이터로 수정 form을 채운다. @@ -12,15 +14,17 @@ 아래 계약은 구현되어 있지만 아직 배포되지 않았다. 배포된 target을 확인한 뒤 그 target에만 실제 연결한다. target 오류 후 환경 변경이나 mock fallback을 자동 적용하지 않는다. fixture는 명시적인 UI 검증에서만 사용하며 mock 상태를 서버 성공으로 표시하지 않는다. -- `GET /admin/sub-banners?placement={placement}`: 필수 placement query, 삭제되지 않은 활성 배너를 기간과 무관하게 반환한다. +- `GET /admin/sub-banners?placement={placement}`: 필수 placement query, 삭제되지 않은 활성 배너를 기간·언어와 무관하게 모두 반환한다. 서버 언어 필터를 가정하지 않는다. - `POST /admin/sub-banners`: 생성. -- `PUT /admin/sub-banners/{id}`: 기본 JSON 네 필드 교체. 두 날짜 경계만 선택적으로 생략 보존 또는 명시적 null 해제한다. +- `PUT /admin/sub-banners/{id}`: 기본 JSON 네 필드 교체. 날짜와 언어는 선택적으로 생략 보존한다. 명시적 null 해제는 두 날짜에만 허용된다. - `DELETE /admin/sub-banners/{id}`: 소프트 삭제. 성공 시 목록에서 제거한다. 삭제된 id의 수정·재삭제는 404이며, 응답은 기존 HTTP 200과 `data:null`이다. `isActive`를 직접 보내거나 복원·활성 토글 UI를 추가하지 않는다. - 기존 관리자 API client로 `Authorization: Bearer `을 전송하고 `ADMIN` 역할이 필요하다. 익명 요청은 HTTP 401이다. 유효한 비관리자 요청은 현재 정책상 HTTP 200 `success:false`이며 403을 보장하지 않는다. - 성공은 HTTP 200 및 네 필드 `ApiResponse`: `success`, `message`, `data`, `errorProperty`. GET `data`는 전체 객체 배열, POST/PUT은 저장된 전체 객체, DELETE는 `null`. 빈 목록 `[]`. -- 관리자 응답 형식 `AdminSubBanner`: `id` integer JSON number(정밀도를 보존하는 불투명 식별자), `placement` enum(`HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION`), `imageUrl` 절대 http/https URL 문자열, `webUrl`·`androidDeepLink`·`iosDeepLink` 각각 string|null, `startDate`·`endDate` 각각 string|null. 모든 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이다. 목록, 생성, 수정 모두 이 관리자 shape를 쓴다. +- 관리자 응답 형식 `AdminSubBanner`는 정확히 9필드다: `id` integer JSON number(정밀도를 보존하는 불투명 식별자), `placement` enum(`HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION`), `imageUrl` 절대 http/https URL 문자열, `webUrl`·`androidDeepLink`·`iosDeepLink` 각각 string|null, `startDate`·`endDate` 각각 string|null, `lang`은 `KO`·`EN`·`JA` 중 하나다. 모든 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이다. 목록, 생성, 수정 모두 이 관리자 shape를 쓴다. - 앱은 두 추천 API의 `data.subBanners`를 사용한다. 배열 항목은 날짜·활성 필드가 없는 기존 6필드이며 활성·기간 내 항목에 앱의 웹/현재 OS 링크 조건을 적용한다. 관리자 CRUD 경로·요청·응답은 이 조회 통합으로 바뀌지 않는다. - POST/PUT은 multipart: 파일 파트 `image`, `Content-Type: application/json`인 JSON 파트 `request`. 기본 JSON의 `placement`, `webUrl`, `androidDeepLink`, `iosDeepLink` 네 필드는 항상 모두 포함한다. 기존 네 필드는 PUT replacement이며 주소 `null`은 지운다. POST `image` 필수. PUT에서 `image` 생략은 기존 이미지 유지, 포함은 교체다. +- 선택적 `lang`은 기존 `Lang.fromCode` 규칙으로 `KO`/`EN`/`JA` 및 소문자 code를 대소문자 무시·앞뒤 trim으로 허용한다. 생성 생략은 KO, 수정 생략은 기존 언어 보존이다. 명시적 null·숫자·boolean·배열·객체·blank·알 수 없는 코드는 업로드/DB 쓰기 전에 HTTP 400이다. 언어를 지우는 null은 보내지 않는다. +- 앱 공개 서브 배너는 기존 회원 국가 resolver의 강제 매핑 우선, `CloudFront-Viewer-Country` 누락 KR 규칙으로 JP→JA, 그 외→KO만 반환한다. `Accept-Language`와 무관하며 EN은 저장 가능하지만 현재 공개 노출되지 않는다. 공개 DTO는 `lang` 없는 6필드다. - `startDate`, `endDate`는 각각 선택적이다. create에서 생략 또는 null이면 무제한이다. update에서 생략하면 해당 저장 값을 보존하고 null이면 해당 경계만 해제한다. update는 두 날짜를 모두 제출해도 된다. 현재 저장된 placement와 주소가 예시와 같을 때 `{ "placement":"HOME_RECOMMENDATION", "webUrl":"https://example.test/promo", "androidDeepLink":"sampleapp://promo", "iosDeepLink":null, "startDate":"2026-11-01 09:30" }`는 주소를 유지하고 생략한 `endDate`를 유지한다. `"endDate":null`을 추가하면 종료만 해제한다. 날짜를 둘 다 null로 보내면 기간 제한을 모두 해제한다. - 기간 의미: null/null은 무제한, 시작만 있으면 시작 분의 `:00`부터, 종료만 있으면 종료 분의 `:59`까지, 두 값이 있으면 양 끝을 포함한다. 같은 분도 유효하며 과거 시작일 제한은 없다. - 입력은 엄격한 `Asia/Seoul` `yyyy-MM-dd HH:mm` 분 단위다. 초, `Z`, offset, blank string 및 존재하지 않는 KST 현지 시각 `1988-05-08 02:30`은 HTTP 400이다. 겹치는 현지 시각은 기존 앞선 offset으로 처리된다. browser local timezone이나 UTC ISO 문자열로 변환하지 않는다. 날짜 선택기에는 `KST`를 명시한다. @@ -31,7 +35,7 @@ - 파일 내용은 MIME 감지로 이미지인지 검사하며 GIF를 허용한다. 빈 파일과 비이미지는 HTTP 400이다. 현재 설정의 최대 파일/요청 크기는 1024 MB이며 제품 권장 용량이 아니다. reverse proxy 또는 배포 설정이 더 낮은 제한을 둘 수 있다. 범용 업로드 endpoint는 없다. ```json -{"placement":"HOME_RECOMMENDATION","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null,"startDate":"2026-11-01 09:30","endDate":null} +{"placement":"HOME_RECOMMENDATION","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null,"startDate":"2026-11-01 09:30","endDate":null,"lang":"JA"} ``` POST/PUT 성공 응답 예시: @@ -40,7 +44,7 @@ POST/PUT 성공 응답 예시: { "success": true, "message": null, - "data": {"id": 123, "placement": "HOME_RECOMMENDATION", "imageUrl": "https://cdn.example.test/promo.jpg", "webUrl": "https://example.test/promo", "androidDeepLink": "sampleapp://promo", "iosDeepLink": null, "startDate": "2026-11-01 09:30", "endDate": null}, + "data": {"id": 123, "placement": "HOME_RECOMMENDATION", "imageUrl": "https://cdn.example.test/promo.jpg", "webUrl": "https://example.test/promo", "androidDeepLink": "sampleapp://promo", "iosDeepLink": null, "startDate": "2026-11-01 09:30", "endDate": null, "lang": "JA"}, "errorProperty": null } ``` @@ -49,6 +53,7 @@ POST/PUT 성공 응답 예시: - 관리자 API는 placement별 삭제되지 않은 모든 배너를 id 오름차순으로 반환한다. 클라이언트에서 재정렬하지 않고 전부 표시한다. 별도 정렬 편집은 추가하지 않는다. - 폼은 이미지, 세 주소, KST 시작/종료 날짜 선택기를 제공한다. 날짜 입력을 비우면 `null`을 보내며 빈 문자열은 보내지 않는다. 각 경계를 별도로 지울 수 있다. 화면에 KST를 표시하고 브라우저 지역 시각 또는 UTC ISO로 바꾸지 않는다. +- 언어 선택은 KO/EN/JA를 사용하고 편집 시 목록의 `lang`을 채운다. 언어 수정 생략을 KO로 재작성하지 않는다. 기존 행의 KO backfill은 자동 분류가 아니므로 JA 소재를 수동 확인하고 연결 이벤트 언어와 일치시킨다. 외부 URL/딥링크는 언어 추론·파싱 없이 원문을 유지한다. - 편집은 목록의 전체 객체를 기반으로 모든 값을 채운다. 기존 이미지 URL/선택 상태, 주소 및 기간 값을 오류 후 보존한다. 기간만 바꾸는 요청은 기존 placement와 세 주소를 그대로 전송하고, image를 생략해 기존 이미지를 유지한다. 주소를 임의로 null로 초기화하지 않는다. - 관리자 목록은 삭제되지 않은 배너의 미래/현재/만료/무기간 상태를 모두 표시하며 노출 기간으로 필터링하지 않는다. 기간 밖 레코드도 열어 수정할 수 있지만 소프트 삭제된 배너는 표시·수정하지 않는다. 같은 placement의 서버 id 오름차순을 유지한다. - 업데이트 기본 네 JSON 필드는 항상 보내되, 날짜 필드는 둘 다 보내거나 필요한 경계만 보내거나 생략할 수 있다. 생략은 보존, 명시적 null은 해당 경계 해제다. API의 생략 보존 의미는 UI가 두 날짜를 모두 보낼 때도 유지된다. @@ -77,6 +82,7 @@ Android/iOS 탭 동작은 작성자 추천이며 제품 승인된 UI 동작이 ## 필수 테스트와 완료 확인 +- 언어 KO/EN/JA 입력·응답, 생성 생략 KO, 수정 생략 유지, null·비문자열·blank·unknown 거부 및 관리자 전체 언어 목록을 확인한다. - 여덟 주소 조합의 form 유효성 및 Android/iOS 대상 판단. - 전부 null로 수정할 때 오류, `null`로 한 필드 지우기, 공백 trim, URI 원형 보존. - 두 날짜 null, 시작만, 종료만, 같은 분, 경계의 `:00` 및 `:59`, 과거 날짜가 유효한 경우를 확인한다. diff --git a/docs/20261029_가로형서브배너/api-contract.md b/docs/20261029_가로형서브배너/api-contract.md index 64b3aba8..adc9a919 100644 --- a/docs/20261029_가로형서브배너/api-contract.md +++ b/docs/20261029_가로형서브배너/api-contract.md @@ -2,6 +2,8 @@ > **상태: 소프트 삭제·두 추천 API 통합 구현 및 검증 완료, 미배포.** 최종 전체 테스트 2,682개(실패·오류·무시 0), lint·bootJar 및 독립 리뷰를 통과했다. 운영 DB 적용과 배포는 수행하지 않았다. +2026-11-03 언어 후속 Phase 7 검증도 완료했다. 전체 2,928개 통과, 테스트 공백 수정 후 영향 HTTP 26개·전체 lint·bootJar 재확인 및 독립 리뷰 APPROVE다. 위 2,682개는 기존 Phase 2~6 이력이며 후속 증거는 `plan-task.md` Phase 7에 기록한다. 운영 DDL·배포는 미실행이다. + ## 1. 공통 형식 애플리케이션 성공 응답은 네 필드를 가진 `ApiResponse` 형식이다. 아래는 관리자 빈 목록 예시이며, 두 추천 GET의 data는 §2의 응답 객체다. @@ -37,9 +39,11 @@ | `androidDeepLink` | string 또는 null | Android 대상 링크 원문 | | `iosDeepLink` | string 또는 null | iOS 대상 링크 원문 | -공개 `SubBanner`에는 날짜 필드를 추가하지 않는다. 앱 응답은 아래와 같이 기간 필터 적용 후의 기본 필드만 반환한다. 세 주소 중 하나 이상은 공백이 아닌 값이어야 한다. 입력 주소는 trim 후 공백이면 `null`로 보내고, nonblank URI는 trim 외 재인코딩·정규화·손상을 가하지 않는다. `webUrl`은 host를 가진 절대 http(s) URL이어야 한다. OS 딥링크는 유효한 절대 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 임의 scheme allowlist는 없다. +공개 `SubBanner`는 정확히 위 6필드이며 `lang`·날짜·활성 필드를 추가하지 않는다. 앱 응답은 국가별 언어·기간 필터 적용 후의 기본 필드만 반환한다. 세 주소 중 하나 이상은 공백이 아닌 값이어야 한다. 입력 주소는 trim 후 공백이면 `null`로 보내고, nonblank URI는 trim 외 재인코딩·정규화·손상을 가하지 않는다. `webUrl`은 host를 가진 절대 http(s) URL이어야 한다. OS 딥링크는 유효한 절대 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 임의 scheme allowlist는 없다. -관리자 전용 `AdminSubBanner`는 `SubBanner`의 기본 필드에 nullable `startDate`, `endDate`를 더한 형태다. 관리자 GET 및 POST/PUT 응답은 모두 `AdminSubBanner`를 반환한다. 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이다. +관리자 전용 `AdminSubBanner`는 `SubBanner`의 기본 필드에 nullable `startDate`, `endDate`와 필수 `lang`을 더한 정확히 9필드다. 관리자 GET 및 POST/PUT 응답은 모두 `AdminSubBanner`를 반환한다. 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이고, `lang` 응답은 `KO`, `EN`, `JA` 중 하나다. + +요청의 `lang`은 선택적 문자열이며 기존 `Lang.fromCode` 규칙을 사용한다. `KO`/`EN`/`JA` 및 `ko`/`en`/`ja`를 대소문자 구분 없이, 앞뒤 공백을 제거해 허용한다. 생성 생략은 KO, 수정 생략은 저장된 언어 유지다. 명시적 null, 숫자·boolean·배열·객체, blank 문자열, 알 수 없는 코드는 HTTP 400이며 이미지 업로드·DB 쓰기 전에 거부한다. 날짜의 null 해제와 달리 언어 null은 허용하지 않는다. 관리자 기간 의미는 기존 콘텐츠 배너 예약과 동일하다. null/null은 무제한, 시작만 있으면 시작부터, 종료만 있으면 해당 종료 경계까지 포함하며 시작과 종료가 같은 분인 것도 유효하다. 입력·응답은 분 단위지만 실제 노출 판정은 초 단위로 양 끝을 포함한다. 시작 경계는 입력 분의 `:00`, 종료 경계는 입력 분의 `:59`다. 예를 들어 종료 입력이 `2026-11-01 12:00`이면 `12:00:59`까지 포함하고 `12:01:00`부터 제외한다. API 문자열에는 초, `Z`, offset을 표현하거나 받지 않는다. @@ -56,7 +60,7 @@ UTC로 변환된 경계 instant는 `1970-01-01 00:00:01`부터 `2038-01-19 03:14 | 접근 | 익명 조회 가능. 로그인 사용자도 기존 클라이언트 헤더를 유지해 조회한다. | | 성공 | HTTP 200, `ApiResponse` | | `data` | 기존 홈 추천 응답 객체의 모든 필드와 `subBanners: SubBanner[]`를 포함한다. 기존 `banners` 및 추천 섹션은 유지한다. | -| `data.subBanners` | 해당 placement의 활성 배너 중 현재 노출 기간 안에 있는 전체 항목, id 오름차순. 페이지네이션·묵시적 절단 없음. 결과가 없으면 `[]`. 날짜·활성 필드는 응답하지 않는다. | +| `data.subBanners` | 해당 placement의 활성 배너 중 회원 국가별 언어와 현재 노출 기간에 맞는 전체 항목, id 오름차순. 페이지네이션·묵시적 절단 없음. 결과가 없으면 `[]`. 언어·날짜·활성 필드는 응답하지 않는다. | ```json { @@ -84,15 +88,17 @@ UTC로 변환된 경계 instant는 `1970-01-01 00:00:01`부터 `2038-01-19 03:14 두 추천 GET은 Authorization 헤더 없이 200으로 조회할 수 있다. 아래 인증 오류 설명은 관리자 등 인증이 필요한 경로에 적용되며, 앱 추천 조회에 새 로그인 조건을 추가하는 것이 아니다. 서브 배너 전용 HTTP 요청·서버 fallback은 없다. +두 endpoint의 서브 배너 언어는 기존 `MemberContentPreferenceService.resolveCountryCode(member)`로 판정한다. 강제 회원 매핑이 접속 국가 헤더보다 우선하며, 일반 회원·익명은 `CloudFront-Viewer-Country`를 사용하고 누락 시 KR이다. JP는 JA, KR·US 등 그 외는 KO만 조회한다. `Accept-Language`나 `LangContext`로 선택하지 않고 별도 언어 query도 받지 않는다. EN은 관리자에서 저장·조회할 수 있지만 현재 공개 국가 매핑으로는 노출되지 않는다. 이벤트 연결을 파싱하거나 외부 링크를 번역하지 않으며 기존 링크 원문을 유지한다. + ## 3. 관리자 목록과 변경 관리자 경로는 `Authorization: Bearer ` 인증과 `ADMIN` 역할이 필요하다. 유효한 비관리자 요청이 반드시 403을 반환하는 것은 아니며 현재 정책 응답은 HTTP 200 `success:false`다. | Method / Path | 목적 | 성공 data | |---|---|---| -| `GET /admin/sub-banners?placement={placement}` | placement별 삭제되지 않은 활성 레코드, 기간 무관. 필수 query, id 오름차순 | `AdminSubBanner[]` | +| `GET /admin/sub-banners?placement={placement}` | placement별 삭제되지 않은 활성 레코드, 기간·언어 무관. 필수 query, id 오름차순 | `AdminSubBanner[]` | | `POST /admin/sub-banners` | 신규 생성 | 생성된 `AdminSubBanner` | -| `PUT /admin/sub-banners/{id}` | 원래 네 JSON 필드는 교체, 날짜 필드는 선택적 생략 보존 | 수정된 `AdminSubBanner` | +| `PUT /admin/sub-banners/{id}` | 원래 네 JSON 필드는 교체, 날짜와 언어 필드는 선택적 생략 보존 | 수정된 `AdminSubBanner` | | `DELETE /admin/sub-banners/{id}` | 소프트 삭제 | `null` | 삭제는 내부 `is_active` 값을 false로 변경하며 행·이미지 key·주소·기간·생성 시각과 S3 이미지는 보존한다. 생성 기본값은 true다. 삭제된 id는 관리자/공개 목록에서 제외하고, 수정·재삭제는 없는 대상과 동일하게 404다. 성공 응답은 기존 HTTP 200 및 `data:null`을 유지한다. `isActive` 요청/응답 필드나 복구·활성 토글 API는 추가하지 않는다. @@ -106,12 +112,14 @@ POST와 PUT은 `multipart/form-data`다. `image` 파트는 파일이며, `reques "androidDeepLink": "sampleapp://promo", "iosDeepLink": null, "startDate": "2026-11-01 09:30", - "endDate": null + "endDate": null, + "lang": "JA" } ``` - POST: `image` 필수. -- PUT은 기존 네 필드 `placement`, `webUrl`, `androidDeepLink`, `iosDeepLink`를 모두 보낸다. 이 네 필드와 placement는 기존처럼 필수 replacement 값이다. 주소 `null`은 해당 주소를 지운다. 이는 전체 JSON replacement라고 무조건 부르는 대신, 아래 두 날짜 필드만 예외적으로 생략 보존한다고 명시한다. +- PUT은 기존 네 필드 `placement`, `webUrl`, `androidDeepLink`, `iosDeepLink`를 모두 보낸다. 이 네 필드와 placement는 기존처럼 필수 replacement 값이다. 주소 `null`은 해당 주소를 지운다. 이는 전체 JSON replacement가 아니며 아래 날짜와 언어 필드는 생략 보존한다. +- `lang` 생성 생략은 KO, 수정 생략은 기존 언어 유지다. 언어를 바꾸려면 유효한 문자열을 보내며 명시적 null은 지우기가 아니라 HTTP 400이다. - `startDate`, `endDate`는 요청에서 각각 선택적이다. POST에서 생략 또는 null이면 무제한 경계다. PUT에서 생략하면 기존 경계를 보존하고, null이면 그 경계만 해제한다. 둘 다 보낼 필요는 없다. PUT은 날짜 두 필드를 모두 보내도 된다. - 기간만 수정하더라도 `placement`와 세 주소는 현재 저장된 값을 그대로 보낸다. 예를 들어 현재 주소가 아래 예시와 같을 때 `{"placement":"HOME_RECOMMENDATION","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null,"startDate":"2026-11-01 09:30"}`는 생략한 `endDate`를 유지한다. 같은 주소를 유지하며 `"startDate":null,"endDate":null`을 보내면 양쪽 경계만 해제한다. 기간 변경을 위해 기존 주소를 임의로 null로 바꾸지 않는다. - PUT에서 `image` 생략 시 기존 이미지를 유지하고, 포함 시 교체한다. @@ -138,4 +146,4 @@ POST와 PUT은 `multipart/form-data`다. `image` 파트는 파일이며, `reques 관리자 CRUD·소프트 삭제·두 추천 API 통합을 구현하고 최종 전체 2,682개 테스트와 lint·bootJar·독립 리뷰를 통과했다. 실제 HTTP/DB 및 단계별 실행 증거는 [plan-task.md](plan-task.md)에 있다. DDL은 독립 H2에 적용해 검증했지만 native MySQL 및 운영 DB에는 적용하지 않았다. 구현·검증 완료를 운영 배포 완료로 간주하지 않는다. -배포 시 신규 `sub_banner` 테이블과 `is_active` 컬럼 등 현재 DDL을 먼저 준비해야 한다. 통합된 추천 조회도 해당 테이블을 사용하므로 DDL 준비 전 서버 코드를 배포하지 않는다. 실제 운영 DDL 적용과 배포는 별도 승인 대상으로 이번 작업에서는 수행하지 않는다. +서버 배포 전에 신규 환경은 [create-sub-banner.sql](create-sub-banner.sql), 기존 테이블은 [20261102_add_sub_banner_lang.sql](20261102_add_sub_banner_lang.sql)을 적용해 `lang VARCHAR(10) NOT NULL DEFAULT 'KO'`를 준비해야 한다. 기존 `is_active` 등 선행 스키마도 필요하다. 언어 migration은 컬럼 존재 확인 → nullable 추가 → NULL의 KO backfill → NOT NULL/default 순서다. KO는 기존 행의 언어를 추론한 결과가 아니므로 운영 담당자가 JA 소재를 수동 분류하고 연결 이벤트 언어와 일치하는지 검토해야 한다. 외부 링크는 원문 그대로이며 자동 언어 분류·링크 파서는 없다. 통합 추천 조회도 새 컬럼을 사용하므로 migration 전에 서버 코드를 배포하지 않는다. SQL의 운영 적용·native MySQL 검증·배포는 이번 작업에서 수행하지 않는다. diff --git a/docs/20261029_가로형서브배너/app-prompt.md b/docs/20261029_가로형서브배너/app-prompt.md index c006d2ff..80d59733 100644 --- a/docs/20261029_가로형서브배너/app-prompt.md +++ b/docs/20261029_가로형서브배너/app-prompt.md @@ -4,6 +4,8 @@ **상태:** 소프트 삭제와 두 추천 API 통합의 백엔드 구현·검증 완료, 미배포. 전체 테스트·lint·빌드와 독립 리뷰를 통과했다. 제공 환경을 확인하기 전 운영 연동 또는 서버 배포가 완료됐다고 가정하지 않는다. +2026-11-03 국가별 언어 후속 계약의 전체 2,928개 테스트·영향 HTTP 재검증·lint·빌드·독립 리뷰를 완료했다. 서버 배포 전 신규 환경은 `create-sub-banner.sql`, 기존 테이블은 `20261102_add_sub_banner_lang.sql`로 `lang` 컬럼을 준비해야 한다. 운영 적용은 미실행이며 앱 작업에서 migration이나 운영 DB 변경을 실행하지 않는다. + ## 목표 메인 홈 추천과 메인 콘텐츠 추천 화면에 가로형 서브 배너 영역을 추가한다. 각 placement 목록에 여러 배너가 있을 수 있다. 아래 API와 데이터 계약은 구현된 계약이다. 화면 배치, 표현, 링크 우선순위, fetch 실패 시 UI 처리는 작성자 추천이며 기존 앱 패턴을 따른다. @@ -19,8 +21,10 @@ - 성공은 HTTP 200이며 envelope는 `success`, `message`, `data`, `errorProperty` 네 필드다. 클라이언트는 HTTP status와 `success`를 모두 확인한다. HTTP 200의 `success:false`도 실패다. 다른 HTTP 오류도 성공으로 처리하지 않는다. - 배너 객체: `id` 정수(JSON number, 불투명 ID로 앱의 정밀도 안전 전략 사용), `placement` enum, `imageUrl` 절대 http/https URL, `webUrl` string|null, `androidDeepLink` string|null, `iosDeepLink` string|null. 다른 배너 필드는 가정하지 않는다. - 공개 `SubBanner` DTO는 위 기본 필드 그대로이며 `startDate`/`endDate`/`isActive`를 추가하거나 가정하지 않는다. 서버는 활성 배너 중 현재 노출 기간 안인 항목 전체를 id 오름차순으로 `data.subBanners`에 반환하고, 결과가 없어도 배열을 `[]`로 반환한다. 앱은 그 목록에 기존 웹 URL/현재 OS 링크 필터를 적용한다. 응답 순서와 placement는 보존한다. +- 두 추천의 `data.subBanners`는 국가별 언어 조건도 서버에서 적용한다. 기존 회원 국가 resolver의 강제 매핑이 우선하며 일반 회원·익명은 `CloudFront-Viewer-Country`를 사용하고 누락 시 KR이다. JP→JA, 그 외(KR·US 포함)→KO다. `Accept-Language`/`LangContext` 또는 기기 언어로 대체하지 않고 별도 language query나 클라이언트 언어 필터를 추가하지 않는다. 공개 DTO는 정확히 위 6필드이고 `lang`은 없다. EN은 관리자 저장·조회 가능하지만 현재 공개 노출되지 않는다. - 더 이상 사용하지 않을 별도 앱 요청은 `GET /api/v2/sub-banners`다. 앱에서 해당 endpoint를 호출하거나 대체용 두 번째 요청을 추가하지 않는다. 삭제된 경로의 구체적인 오류 status를 가정하지 않는다. - 기간 판단은 서버가 맡는다. 기기 시계로 기간을 다시 계산하거나 로컬 예약 timer를 추가하지 않는다. +- 관리자 언어 계약은 생성 생략 KO·수정 생략 유지이며 null·비문자열·blank·unknown은 업로드/쓰기 전 400이다. 관리자 목록은 모든 언어다. 기존 KO backfill은 자동 언어 분류가 아니므로 운영 담당자가 JA 소재와 연결 이벤트 언어를 수동 검토한다. 앱은 이벤트 링크를 파싱해 언어를 추정하지 않고 외부 URL/딥링크 원문을 유지한다. - 서브 배너만을 위한 별도 fetch는 없다. 추천 API의 통신 실패는 기존 추천 화면의 오류 정책으로 처리하며 정상 `subBanners:[]`로 바꾸지 않는다. 개별 배너의 이미지 로딩·링크 실행 실패는 기존 추천 콘텐츠의 표시와 분리한다. 홈 추천 성공 응답 예시 (기존 추천 필드는 예시 데이터로 유지, 서브 배너 두 항목): @@ -94,6 +98,7 @@ 실제 앱 구조에 맞는 테스트를 추가한다. 최소 검증 범위: - 모든 항목 표시, 응답 순서 유지, 홈/콘텐츠 placement 격리, 빈 목록에서 gap 없음. +- JP와 KR·US·국가 누락 및 강제 매핑에서 서버가 선택한 목록을 그대로 소비하고 공개 6필드에 `lang`을 요구하지 않는지 확인한다. - URI 입력을 보존하면서 대상 OS 링크 우선, OS 링크 부재 시 웹, 딥링크 실패 시 웹 fallback 한 번, 양쪽 실패 시 비치명 상태. - 다른 OS 링크를 실행하지 않고 잘못된 scheme을 거부한다. - 비로그인 두 GET의 `data.subBanners`를 소비하며 별도 배너 요청이 발생하지 않는다. 추천 API 오류와 정상 빈 배열을 구분하고, 개별 이미지·링크 오류는 다른 추천 콘텐츠를 깨뜨리지 않는다. diff --git a/docs/20261029_가로형서브배너/plan-task.md b/docs/20261029_가로형서브배너/plan-task.md index 3dd5b2bf..0c391050 100644 --- a/docs/20261029_가로형서브배너/plan-task.md +++ b/docs/20261029_가로형서브배너/plan-task.md @@ -2,12 +2,13 @@ | 문서 항목 | 내용 | |---|---| -| 상태 | P5 소프트 삭제·P6 추천 통합 구현 및 전체 검증 완료, 미배포 | +| 상태 | P7 언어 후속 구현·전체 검증·독립 리뷰 완료, 미배포 | | 작성일 | 2026-10-29 | | 요구사항 기준 | [prd.md](prd.md) | | API 기준 | [api-contract.md](api-contract.md), 실제 구현·HTTP 검증 계약 | -| 현재 Phase | Phase 2~6 완료 | +| 현재 Phase | Phase 2~7 완료 | | 현재 활성 Goal | 없음 | +| 다음 Goal | 없음 | ## 목표 @@ -31,6 +32,12 @@ - 신규 dependency, global exception rewrite, 공통 utility wrapper - 앱/client network 통합은 backend 구현 후 별도 계약 검토와 제공이 끝날 때까지 차단한다. backend를 알 수 없는 외부 block으로 취급하지 않는다. +### 승인된 언어 후속 범위 + +- [2026-11-02 승인 후속] 재사용 `Lang`의 KO/EN/JA로 서브 배너를 저장한다. 관리자 생성 누락은 KO, 수정 누락은 유지, explicit null/non-text/invalid는 upload/write 전 HTTP 400이다. Public 조회는 member 국가로 JP→JA, 그 외→KO를 선택하며 관리자 목록은 언어 미필터다. +- public `SubBannerResponse`는 기존 필드를 유지하고, 운영 DB/DDL 실행·UI·event link parsing·event detail/adult 변경·새 dependency는 금지한다. +- 신규 설치용 `create-sub-banner.sql`과 `20261102_add_sub_banner_lang.sql`을 함께 갱신한다. KO backfill은 소재의 JA 여부와 연결 이벤트 언어를 자동 추정하지 않으며, 운영 담당자의 수동 분류·확인을 적용 전에 요구한다. + ## 문서 변환 Goal ### `DOC-T5` 백엔드 계획 문서 변환 @@ -545,3 +552,67 @@ backend checkbox는 실제 검증 후 갱신한다. 문서 작성/부모 검토 - 결과: 종료 코드 0, `BUILD SUCCESSFUL in 5m 5s`. 최신 HTML/XML에서 161 tests, failures/errors/skipped 0을 확인했다. 테스트는 재실행됐으며 lint·컴파일·bootJar는 입력 변경이 없어 UP-TO-DATE였다. - 전체 회귀 생략: 이전 최종 Gate의 전체 2,682개 통과 이후 제품 코드 변경이 없어 전체 suite를 반복하지 않았다. 이번 영향 범위 실행으로 `build/reports/tests/test/index.html`과 XML은 161개 결과로 갱신됐으며, 과거 전체 결과와 구분한다. - 운영 한계: 운영 DB·native MySQL·실제 AWS·앱/관리자 UI·배포는 이번에도 검증하지 않았다. 서버 배포 전에 현재 DDL 준비가 필요하다. + +## Phase 7: 서브 배너 언어 후속 + +2026-11-02 사용자 승인으로 새 Phase를 추가한다. Phase 2~6의 완료 상태와 검증 기록은 당시 계약의 이력이며 수정하지 않는다. + +### Task 7.1 저장·관리자 계약·공개 국가별 조회 + +**Goal `P7-T1`:** 서브 배너가 기존 `Lang`을 저장하고 관리자가 안전하게 언어를 관리하며, 두 공개 추천 API가 회원 국가에 맞는 언어만 반환한다. + +- 요구사항: PRD `SB-013`, `DEC-012`. +- 시작 조건: `DEC-012` 승인과 기존 Phase 2~6 완료. +- 완료 증거: baseline 기록, language 저장/기본값/수정 보존 persistence 및 실제 관리자/public HTTP 테스트의 RED→GREEN 결과, DDL 두 경로 검토, 독립 코드 리뷰. +- 범위 밖: 앱·관리자 UI, public `SubBannerResponse` 변경, event link parsing, event detail/adult 정책, dependency, 운영 DB/DDL 실행, 기존 언어 자동 추론. +- **Files:** + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt` + - Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt` + - Modify: `docs/20261029_가로형서브배너/create-sub-banner.sql` + - Create: `docs/20261029_가로형서브배너/20261102_add_sub_banner_lang.sql` + - Modify: `docs/20261029_가로형서브배너/api-contract.md`, `docs/20261029_가로형서브배너/admin-prompt.md`, `docs/20261029_가로형서브배너/app-prompt.md` (실제 언어 계약과 배포 주의사항 동기화; UI 구현 제외) + - Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt` +- **Interfaces:** `SubBanner.lang: Lang = Lang.KO`; admin request optional `lang` preserving JSON presence/type distinctions; `AdminSubBannerResponse.lang: Lang`; repository `findVisibleByPlacement(placement: SubBannerPlacement, lang: Lang, now: LocalDateTime): List`. Public service accepts `Member?`, resolves through existing `MemberContentPreferenceService.resolveCountryCode(member)`, then passes required `Lang`. Both existing facades pass their `member`. Admin list receives no language filter. `SubBannerResponse` remains unchanged. +- [x] **Baseline:** 부모의 사전 통과 113개 중 서브 배너 기존 4개 클래스 98개를 확인했다. 원본 `/tmp/soda-banner-ulw.HDkpg4/baseline.log`. 두 facade 클래스는 사전 baseline 범위 밖이며 이번 GREEN에서 10개를 검증했다. 운영 DDL은 실행하지 않았다. +- [x] **RED:** domain KO 기본값, 관리자 9필드·생성 언어·invalid 입력 400 및 공개 두 JP 경로의 KO 제외를 실제 HTTP/응답 assertion으로 먼저 추가했다. invalid 입력은 실제 DB 상태·업로드·repository save 호출 0회를 검증한다. +- [x] **RED 확인:** domain/admin/public 81개 중 언어 관련 assertion 15개 실패, 오류 0을 확인했다. 컴파일·설정 RED가 아니다. `/tmp/soda-banner-impl.CfAkhD/subbanner-red.log`와 `subbanner-*-red.xml`. +- [x] **GREEN:** 기존 `Lang`, 생성 KO·수정 생략 보존, 부작용 전 검증, facade의 member 전달, repository 필수 언어 조건 및 두 SQL artifact를 구현했다. typed persistence 언어 왕복·필터·변경과 기존 호출부도 함께 갱신했다. +- [x] **GREEN 확인:** focused 123개 실패·오류·무시 0. 실제 관리자 KO/JA/EN·수정 생략 보존·invalid 입력, 두 추천 JP/KR/US/누락·강제 회원, 공개 정확히 6필드 및 기존 기간·소프트 삭제·정렬·링크 회귀가 통과했다. +- [x] **REFACTOR:** 전체 2,928개 테스트, 공백 수정 후 영향 26개 HTTP 테스트·전체 ktlint·bootJar 및 독립 리뷰를 확인했다. 운영 DDL과 도구가 없는 LSP 진단은 실행하지 않았다. + +```bash +export TMPDIR=/tmp/soda-banner-impl.CfAkhD NODE_COMPILE_CACHE=/tmp/soda-banner-impl.CfAkhD/node-compile-cache BUN_RUNTIME_TRANSPILER_CACHE_PATH=/tmp/soda-banner-impl.CfAkhD/bun-transpiler +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerTest' --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.SubBannerPersistenceTest' --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.AdminSubBannerControllerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.RecommendationSubBannerIntegrationTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process +./gradlew test ktlintCheck bootJar --continue --console=plain +``` + +**DDL requirements:** both SQL artifacts define `lang VARCHAR(10) NOT NULL DEFAULT 'KO'` with a column comment. The migration safely adds a missing column, backfills NULL rows to KO, then enforces NOT NULL/default, following the existing project SQL pattern. Before production application, warn that KO is only a safe placeholder: operations must manually identify JA banners and verify language consistency against linked events. No event link parser or automatic classification is allowed. Production DB/DDL execution is not part of this goal. + +### Phase 7 Gate `P7-GATE` + +- 시작 조건: `P7-T1` 완료. +- 완료 증거: full test suite·ktlint·bootJar 성공, 실제 관리자와 두 추천 HTTP 경로 검증, 두 SQL artifact/수동 분류 경고 대조, 독립 리뷰에서 미해결 차단 사항 없음. +- 범위 밖: 운영 DB 적용·배포·UI. +- [x] 국가 resolver 재사용·관리자 전체 언어·public DTO 불변·invalid 부작용 0을 확인하고 이전 Phase 이력을 보존했다. 전체 검증 및 최종 Oracle 승인으로 Gate를 완료했다. + +### Phase 7 구현 Progress, 2026-11-02 + +- RED는 위 명령 중 domain/admin/public 3개 클래스를 실행해 `81 tests completed, 15 failed`를 확인했다. 실패는 KO 기본값/관리자 lang 필드 누락, invalid lang의 200 수락, JP에서 KO 노출이다. 원본 XML은 `/tmp/soda-banner-impl.CfAkhD/subbanner-domain-red.xml`, `subbanner-admin-red.xml`, `subbanner-public-red.xml`이다. 개별 typed persistence 테스트를 별도 RED 실행한 것으로 주장하지 않는다. +- GREEN은 위 6개 클래스 명령이며 `/tmp/soda-banner-impl.CfAkhD/subbanner-green.log`에 `BUILD SUCCESSFUL in 8m 28s`가 남는다. Domain 3, Persistence 32, Admin HTTP 52, Public HTTP 26, Home facade 8, Audio facade 2로 총 123개가 통과했다. +- 실제 HTTP/DB 증거는 `subbanner-http-db-evidence.log`의 HTTP 121건과 `subbanner-green-TEST-*.xml`에 보관했다. 신규 언어 경로는 두 추천 각각 익명 JP/KR/US/누락·강제 JP/KR를 실제 JWT/회원/resolver로 검증한다. EN 행이 있어도 공개 목록에서 제외되고, admin은 KO/JA/EN을 모두 반환한다. invalid 8종 POST/PUT 16건은 400이며 실제 DB 불변·S3 상호작용 없음·repository save 0회가 통과했다. +- H2 flush/clear/재조회에서 모든 언어의 문자열 저장·변경·언어 조건 및 `VARCHAR(10) NOT NULL`을 확인했다. 기존 CREATE SQL 실행 테스트도 H2에서 KO default/NOT NULL/길이 10을 검증했다. migration은 기존 event DDL의 information_schema/PREPARE 패턴과 대조만 했고 native MySQL·운영 DDL은 실행하지 않았다. 기존 초 정밀도·소프트 삭제/동시성·링크 검증은 유지했다. +- API 계약/관리자·앱 프롬프트에 생성 생략 KO·수정 생략 유지·null/type/blank/unknown 400, JP→JA/그 외→KO, admin 전체 언어, EN 비노출, 공개 6필드 및 migration-before-deploy를 반영했다. KO backfill은 자동 분류가 아니므로 JA 소재와 연결 이벤트 언어를 수동 확인해야 한다. 외부 링크 원문은 유지하며 파서를 추가하지 않았다. +- 사용자 지정 종료 경계에 따라 전체 suite·ktlint·bootJar·독립 리뷰와 `P7-GATE`는 부모가 수행하도록 미완료 유지한다. LSP 진단 도구 없음, 실제 AWS/UI/운영 DB/배포 미검증. Gradle 및 embedded Redis 소유 프로세스가 종료됐음을 확인했고 evidence는 남겼다. 모든 변경 파일·명령·제한은 `/tmp/soda-banner-impl.CfAkhD/implementation-report.md`에 기록한다. 기존 Phase 2~6 기록과 부모 PRD 변경은 보존했다. +- 최종 source 상태에서 위 6개 클래스와 Phase 3의 5개 클래스를 한 focused 명령으로 재실행했다. `/tmp/soda-banner-impl.CfAkhD/final-focused.log`: `BUILD SUCCESSFUL in 8m 8s`, 총 145개 실패·오류·무시 0. 전체 suite는 실행하지 않았다. 최종 11개 XML은 `final-TEST-*.xml`, 실제 HTTP 185건과 DB/동시성 증거는 `final-http-db-evidence.log`에 별도 보관했다. + +### Phase 7 최종 검증 Progress, 2026-11-03 + +- 부모가 `./gradlew test ktlintCheck bootJar --continue --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`를 실행했다. 전체 XML을 집계해 336 suites / 2,928 tests / failures=0 / errors=0 / skipped=0을 확인했고 JAR도 생성됐다. 최초 명령은 `RecommendationSubBannerIntegrationTest.kt`의 생성자 인자 줄바꿈 1곳(5개 lint 진단) 때문에 exit 1, 15m 50s였다. +- 값·순서·동작 변경 없이 인자만 줄별로 배치했다. `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.RecommendationSubBannerIntegrationTest' ktlintCheck bootJar --continue --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: exit 0, `BUILD SUCCESSFUL in 4m 32s`, 26 tests / failures=0 / errors=0 / skipped=0, 전체 lint 통과, bootJar UP-TO-DATE. +- 전체 회귀 뒤 테스트 공백만 달라져 영향 HTTP suite만 재실행했다. 현재 `build/test-results/test`/HTML은 26개 결과이며, 전체 2,928개 수치는 갱신 전 부모의 직접 집계다. 실제 HTTP·DB 검증은 테스트 대체가 아니라 RANDOM_PORT 서버를 대상으로 한 TestRestTemplate 요청과 저장소 재조회다. +- 부모가 production diff, 입력·저장·조회 경로, 테스트 assertion, SQL 두 개와 소비 문서를 직접 대조했다. LSP 도구가 없어 미실행이며 Gradle 컴파일·lint로 대체했다. 운영 DB/native MySQL/AWS/UI/배포는 제외한다. +- 원본 `/tmp/soda-banner-ulw.HDkpg4/full-verification.log`, `final-verification.log` 및 구현자 임시 증거는 작업 종료 시 정리한다. 재현 테스트 소스, 최신 build reports, 본 검증 기록과 [공동 리뷰](reviews/banner-language-review.md)를 남긴다. +- 독립 Oracle 리뷰는 명세·코드·시나리오 APPROVE, 후속 공백 delta 및 최종 검증 Gate도 APPROVE했다. 미해결 발견 사항 없음. `P7-T1/P7-GATE` 완료이며 운영 DDL·배포는 별도다. +- 서브 배너 구현·SQL·테스트 커밋 `c4c61e36`, hook/메시지 검사 통과. 소유 임시 자료·QA 프로세스 정리와 build 산출물 보존은 공동 리뷰의 종료 증거에 기록했다. 운영 DDL·배포·push는 하지 않았다. diff --git a/docs/20261029_가로형서브배너/prd.md b/docs/20261029_가로형서브배너/prd.md index 5d90028f..c65738bd 100644 --- a/docs/20261029_가로형서브배너/prd.md +++ b/docs/20261029_가로형서브배너/prd.md @@ -4,9 +4,9 @@ | 항목 | 내용 | |---|---| -| 문서 상태 | 소프트 삭제·두 추천 API 통합 및 전체 검증 완료, 미배포 | +| 문서 상태 | Phase 7 언어 필터 후속 구현·전체 검증·독립 리뷰 완료, 미배포 | | 작성일 | 2026-10-29 | -| 최종 수정일 | 2026-10-29 | +| 최종 수정일 | 2026-11-03 | | 대상 기능 | 가로형 서브 배너 백엔드 및 영속성 | | 관련 API 계약 | [api-contract.md](api-contract.md), 구현·실제 HTTP 검증 기준 | | 구현 계획 | [plan-task.md](plan-task.md) | @@ -43,6 +43,7 @@ - **G2:** 관리자 API는 ADMIN만 사용하고 이미지 multipart 포함 CRUD를 제공하며, payload 검증이 업로드와 영속화보다 먼저 이뤄진다. (`SB-004`~`SB-007`, `SB-011`) - **G3:** 공개 GET은 placement와 유효 기간으로 필터링하고 전체 목록을 id 오름차순으로 반환하며, 날짜 없는 공개 DTO와 공통 응답 envelope를 유지한다. (`SB-008`~`SB-009`) - **G4:** 배포 DDL은 적용·롤백 영향을 명시하며 기존 콘텐츠 배너 테이블과 데이터를 변경하지 않는다. (`SB-010`) +- **G5 (2026-11-02 승인 후속):** 서브 배너는 기존 `Lang`으로 저장되고 두 추천 API는 회원 접속 국가에 맞는 언어만 반환하며, 관리자는 모든 언어 항목을 조회·관리할 수 있다. (`SB-013`) ### 포함 범위 @@ -82,6 +83,7 @@ | `SB-010` | 작성자 데이터 안전 제안 | 신규 테이블 DDL과 적용 전후 안전성을 제공한다. | `docs/20261029_가로형서브배너/create-sub-banner.sql`에 모든 column/table COMMENT, nullable UTC `TIMESTAMP` 기간 컬럼, 필요 시 기존 audit timestamp 관례를 포함한다. 계획에 적용 조건과 rollback 절차를 적고 자동 live DDL은 하지 않는다. | §1, `P2-T1` | | `SB-011` | 작성자 API/오류 제안 | 성공 envelope와 계약된 응답 shape를 일관되게 유지한다. | 성공은 `ApiResponse` 4필드, HTTP 200, 관리자 빈 목록 `[]`, 관리자 POST/PUT 객체, DELETE `null`, 공개 날짜 미포함. 잘못된 입력/없음 상태의 status는 API 계약 검토 후 기존 handler를 활용하거나 endpoint 경계에서 처리한다. | `api-contract.md` §1, §5, `P3-T1`, `P4-T1` | | `SB-012` | 사용자 후속 확정 | 관리자 삭제는 `is_active=false` 소프트 삭제다. | DB 행·이미지 key·주소·기간·생성 시각을 보존한다. 관리자/공개 목록과 id 조회에서 비활성 행을 제외하며 삭제된 id 수정·재삭제는 404다. 응답에 isActive를 추가하지 않고 S3는 보존한다. 복구/활성 토글/cleanup은 범위 밖이다. | §3, `P5-T1` | +| `SB-013` | 사용자 후속 확정, 2026-11-02 | 서브 배너 언어는 기존 `Lang`의 `KO`, `EN`, `JA` 중 하나로 저장한다. 관리자 생성에서 생략하면 KO, 수정에서 생략하면 기존 값을 유지하고 응답에는 `lang`을 포함한다. 공개 추천은 기존 회원 국가 판정 JP→JA, 그 외→KO를 적용한다. | 명시적 null·비문자열·잘못된 코드는 HTTP 400이며 업로드/저장 전 거부된다. 관리자 목록은 언어 미필터 전체 조회다. public repository에는 필수 `Lang` 조건을 전달하고 public `SubBanner` DTO는 변경하지 않는다. 기존 행은 KO로 backfill하며 JA 분류 및 연결 이벤트와의 언어 정합성은 운영 담당자가 수동 확인한다. | §3, `P7-T1`, `P7-GATE` | ## API 및 데이터 계약 @@ -125,6 +127,7 @@ - [x] payload 검증 실패의 이미지 upload/DB mutation 0회와 실제 오류 응답을 검증했다. - [x] 전체 2,676 tests, lint, bootJar 및 독립 최종 리뷰 통과. 운영 DB 적용·배포는 별도이며 완료로 표시하지 않는다. - [x] 후속 P5·P6는 전체 2,682 tests(실패·무시 0), lint·bootJar와 독립 리뷰를 통과했다. 비로그인 두 추천 응답의 subBanners와 standalone handler 제거, 소프트 삭제 행 보존 및 동시 비복원을 실제 HTTP/DB로 검증했다. 앞선 2,676건은 초기 구현 이력이다. +- [x] 후속 `SB-013`: 기존 `Lang` 필드, 관리자 생성 KO 기본값·수정 생략 유지·잘못된 값의 부작용 전 HTTP 400, 관리자 전체 언어 목록, 두 공개 추천 API의 회원 국가별 언어 필터와 public DTO 불변을 검증했다. (`P7-T1`, `P7-GATE`; 2026-11-03 전체 2,928개 및 영향 HTTP 재검증 완료) ## Decision Log @@ -141,6 +144,7 @@ | 2026-10-29 | DEC-009 | 사용자 구현 승인 | 문서대로 백엔드 코드와 DDL 파일을 구현한다. 운영 DB 적용·배포는 승인 범위에 포함하지 않는다. 기존 기간·독립 API 모델을 사용하며 검증 시 H2·테스트 JWT·S3 대체 객체로 외부 부작용을 격리한다. | 후속 사용자 요청 및 ulw 실행 | | 2026-10-29 | DEC-010 | 사용자 후속 확정·정정 | `is_active BOOLEAN NOT NULL DEFAULT TRUE`를 추가하고 DELETE는 false 갱신으로 변경한다. 삭제된 행은 모든 소비자 조회에서 제외하며 응답·권한·기간·S3 정책은 유지한다. 앞선 하드 삭제 완료 기록은 당시 이력으로 보존한다. | "is_active를 두고 소프트 삭제로 하자" | | 2026-10-29 | DEC-011 | 사용자 후속 확정·정정 | 별도 앱 GET을 제거하고 두 추천 GET에 `data.subBanners`를 추가한다. 두 GET 모두 비로그인 접근을 유지하고 앱 프롬프트도 함께 변경한다. 관리자 CRUD·소프트 삭제·기간·응답 항목은 유지한다. | 두 추천 API 통합 및 앱 프롬프트 수정 요청 | +| 2026-11-02 | DEC-012 | 사용자 후속 확정 | 서브 배너에 기존 `Lang` enum 필드를 추가한다. 관리자 생성 누락은 KO, 수정 누락은 유지, 명시적 null·비문자열·invalid는 HTTP 400이며 upload/write 전 검증한다. 관리자 목록은 언어 전체 조회, 공개 두 추천은 회원 국가 기준 JP→JA/그 외→KO로 필터링한다. public DTO는 유지한다. | 승인된 최소 이벤트/서브 배너 언어 후속 수정 | ## 후속 사용자 요구사항: 관리자 노출 기간 @@ -150,3 +154,13 @@ - 공개 조회는 현재 기간 안에 있는 항목 전체를 반환한다. 앱은 그 결과에 기존 웹 URL/현재 OS 딥링크 필터를 적용하며 앱 로컬 시계로 기간을 계산하지 않는다. - 공개 DTO에는 날짜·활성 필드를 추가하지 않는다. 관리자 목록은 삭제되지 않은 활성 행의 미래·현재·만료·무제한 상태를 모두 포함한다. - 기존 근거는 `docs/20261027_콘텐츠배너예약기간/prd.md` §6-8, `AdminContentBannerService.kt` 및 `GetAdminContentBannerResponse.kt`다. 신규 계약의 승인이나 구현을 뜻하지 않는다. + +## 후속 사용자 요구사항: 서브 배너 언어 — 2026-11-02 + +- `SubBanner` domain, JPA entity/storage, admin request와 admin response에 기존 `Lang`의 `KO`, `EN`, `JA`를 추가한다. 새 enum은 만들지 않는다. +- 관리자 create에서 `lang`을 생략하면 KO로 저장한다. update에서 생략하면 기존 언어를 보존한다. 명시적 null, JSON 비문자열 또는 유효하지 않은 코드는 HTTP 400으로 거부하고 S3 upload와 DB write 이전에 검증한다. +- 관리자 목록은 기존 placement 조건 외 언어 필터를 추가하지 않으며 응답마다 `lang`을 반환한다. public `SubBannerResponse`에는 `lang`을 노출하지 않는다. +- 공개 조회 service는 member를 받아 기존 `MemberContentPreferenceService.resolveCountryCode(member)`로 JP→JA, 그 외→KO를 선택하고 이를 필수 Lang으로 repository에 전달한다. 두 추천 facade는 기존 member를 service에 전달한다. +- 기존 테이블 CREATE SQL에는 `lang VARCHAR(10) NOT NULL DEFAULT 'KO'`를 포함한다. 별도 신규 migration SQL은 기존 테이블에 언어 컬럼을 안전하게 추가하고 null 값을 KO로 backfill한 뒤 NOT NULL DEFAULT KO를 적용한다. +- KO backfill은 기존 행의 언어를 자동 추론하지 않는다. 운영 적용 전에 배너 소재를 검토해 JA 배너와 연결 이벤트의 언어를 운영 담당자가 수동 분류·정합성 확인해야 한다. 운영 DB에 DDL을 실행하지 않는다. +- 제외: 앱/관리자 UI, event link parsing, event detail/adult 정책 변경, 새 dependency, public DTO 변경, 운영 DB·DDL 실행. diff --git a/docs/20261029_가로형서브배너/reviews/banner-language-review.md b/docs/20261029_가로형서브배너/reviews/banner-language-review.md new file mode 100644 index 00000000..d4e2c177 --- /dev/null +++ b/docs/20261029_가로형서브배너/reviews/banner-language-review.md @@ -0,0 +1,52 @@ +# 메인 탭·서브 배너 언어 후속 리뷰 + +## 리뷰 정보 + +- 일자: 2026-11-02 +- 대상: 이벤트 언어 `P3-T1/P3-GATE`, 서브 배너 `P7-T1/P7-GATE`. +- 기준: `7fce26ad` 이후 현재 `test` 브랜치 변경. 별도 worktree 없음. +- 독립 리뷰어: Oracle (`ses_e634d4707ffe00405lhcwZXMXM`). 기록 담당: Sisyphus. +- 요구사항: 이벤트 PRD `EVENT-LANG-010`, 서브 배너 PRD `SB-013/DEC-012`, 기존 API 계약. + +## 판정 + +**명세 준수 APPROVE, 코드 품질·시나리오 커버리지 APPROVE. 확정 발견 사항 없음.** +초기 리뷰 시 전체 검증은 진행 중이므로 전체 Gate 완료 판정은 보류했다. 후속 실행 결과는 아래에 누적한다. + +| 시나리오 | 확인한 근거 | +|---|---| +| S1 메인 탭 | 여섯 호출부가 기존 `isAdult`를 보존하고 `resolveCountryCode(member)`로 JP→JA/그 외→KO를 전달한다. `EventBannerLanguageIntegrationTest`가 실제 HTTP에서 정확한 ID·순서·5필드, 일반/강제 국가와 익명 홈을 검증한다. | +| S2 공개 서브 배너 | 두 facade가 member를 전달하고 repository가 언어 equality를 기존 활성·기간 조건과 결합한다. `RecommendationSubBannerIntegrationTest`가 JP/KR/US/누락/강제 국가, 빈 결과, EN 제외, 6필드 및 원문 링크를 확인한다. | +| S3 관리자 | 생성 언어 생략 KO, 수정 생략 보존, explicit null/non-text/blank/unknown의 업로드·쓰기 전 400을 검증한다. 실제 DB 불변, S3 상호작용 0, 실제 repository spy save 0을 함께 확인한다. 관리자 조회는 모든 언어·9필드다. | +| S4 인접 회귀 | focused 11개 클래스 145 tests, failures/errors/skipped 0. 기존 기간·초 정밀도·소프트 삭제·정렬·무절단 테스트 보존. 전체 suite/lint/build는 초기 리뷰 시 진행 중이었다. | + +## 검토한 증거 + +- 신규 실제 HTTP 테스트와 기존 서브 배너 domain/persistence/admin/recommendation 및 facade 테스트의 assertion을 직접 검토했다. +- `final-focused.log`의 `BUILD SUCCESSFUL in 8m 8s`, 11개 XML의 145 tests 및 실제 HTTP 185건/DB 증거를 대조했다. 해당 임시 원본은 작업 종료 시 정리하며 핵심 결과를 이 문서와 두 계획에 보존한다. +- 신규 CREATE와 기존 테이블 migration의 guarded 컬럼 추가 → NULL만 KO backfill → NOT NULL/default 흐름을 검토했다. H2 CREATE 검증과 native MySQL 실행을 구분한다. +- LSP 도구가 없어 `lsp_diagnostics`는 미실행이며 Gradle 컴파일·lint를 대체 근거로 사용한다. LSP 통과로 표현하지 않는다. + +## 운영 한계 + +운영 DB·native MySQL·실제 AWS·앱/관리자 UI·배포는 검증 범위 밖이다. 서버 배포 전에 스키마를 준비해야 한다. 임의 링크에 이벤트 관계나 자동 언어 검증을 추가하지 않았으므로 JA 소재 및 연결 이벤트 언어의 수동 정합성 확인이 필요하다. 이벤트 상세·성인 정책은 변경하지 않는다. + +## 후속 검증 + +전체 검증 결과와 소유 자원 정리 증거는 실행 종료 후 누적한다. 확정 결함이 없어 회귀 수정 Goal 전환 항목은 없다. + +### 2026-11-03 부모 재검증 + +- `./gradlew test ktlintCheck bootJar --continue --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: 전체 336 suites / 2,928 tests / failures=0 / errors=0 / skipped=0, bootJar 성공. 테스트 인자 줄바꿈 한 곳만 lint 실패해 최초 명령 exit 1 (15m 50s). +- 제품 코드 변경 없이 테스트 인자 배치만 수정한 뒤 해당 `RecommendationSubBannerIntegrationTest`와 `ktlintCheck bootJar` 재실행: exit 0, 26 tests / 실패·오류·무시 0, `BUILD SUCCESSFUL in 4m 32s`. +- 전체 XML은 재실행 전에 부모가 집계했다. 현재 보고서는 26개 재실행 결과로 갱신되어 있으며 전체 성공 기록과 구분한다. 공백 변경만 있어 전체 재실행 대신 영향받은 실제 HTTP suite를 다시 구동했다. +- 기존 Oracle 세션의 continuation 도구가 `Task not found`를 반환해 새 Oracle 세션에 공백 delta와 최종 실행 증거만 재판정하도록 전달했다. 전면 리뷰를 중복 수행하지 않는다. +- 후속 Oracle (`ses_e6323aa1afferoJ13TsZawApwR`) 판정: **APPROVE. 동작 변경 없음, 영향 HTTP 26개 및 lint/build Gate 승인, 추가 발견 사항 없음.** 전체 2,928개 집계는 XML 갱신 전 부모 기록에 의존함을 명시했다. 부모는 최종 bash 실행의 정상 반환과 `BUILD SUCCESSFUL`을 직접 확인했다. +- 최종 결론: 승인된 backend 범위의 `P3-GATE/P7-GATE` 완료. 운영 배포 성공이나 LSP 실행을 주장하지 않는다. + +## 종료 증거 + +- 제품 커밋: `255da2b6` (메인 탭 필터·HTTP 회귀), `c4c61e36` (서브 배너 언어·SQL·테스트). 두 커밋의 ktlint hook 및 커밋 메시지 검증 통과. `git diff --exit-code -- src` 정상으로 hook 후 추가 소스 변경 없음. +- 전체 검증 runner PID `49134` 종료, `pgrep -fl 'GradleDaemon|GradleWorkerMain|redis-server'` 결과 없음. 랜덤 포트 테스트 서버는 테스트 컨텍스트 종료로 정리됐다. +- 소유 임시 경로 `/tmp/soda-banner-ulw.HDkpg4`, `/tmp/soda-banner-worker.AMcPYe`, `/tmp/soda-banner-impl.CfAkhD`, `/tmp/soda-banner-review.tIFyeb`는 소유·실경로 확인 후 삭제하고 네 경로 모두 부재를 확인했다. 임시 notepad도 삭제했다. 공유 Gradle 캐시와 저장소 build reports/JAR는 보존했다. +- worktree 생성·운영 DDL·배포·push 없음. 현재 `test` 브랜치에서 완료했다.