diff --git a/docs/20261027_콘텐츠배너예약기간/plan-task.md b/docs/20261027_콘텐츠배너예약기간/plan-task.md new file mode 100644 index 00000000..72cea30a --- /dev/null +++ b/docs/20261027_콘텐츠배너예약기간/plan-task.md @@ -0,0 +1,299 @@ +# 오디오 콘텐츠 배너 예약 기간 구현 계획 + +| 문서 항목 | 내용 | +|---|---| +| 상태 | 구현 완료 | +| 작성일 | 2026-10-27 | +| 요구사항 기준 | `docs/20261027_콘텐츠배너예약기간/prd.md` | +| API 기준 | 관리자 예약 필드 추가, 기존 공개 API 응답 schema 유지 | +| 현재 Phase | Phase 1: 예약 저장과 노출 판정 | +| 현재 활성 Goal | 없음 (`P1-GATE` 완료) | + +## 목표 + +관리자는 오디오 콘텐츠 배너 예약 기간을 저장·관리하고, 네 공개 오디오 배너 조회는 현재 시각에 노출 가능한 항목만 기존 조건과 순서로 반환한다. + +## 현재 상태 + +| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 조건 | +|---:|---|---:|---|---| +| 1 | 완료 | `2/2` | 없음 (`P1-GATE` 완료) | 없음 | + +- 동시에 하나의 미완료 goal만 실행한다. 순서: `P1-T1` → `P1-T2` → `P1-GATE`. +- 완료 체크박스와 검증 기록은 구현 때 실제 결과로 갱신하며, 이 계획 작성 시 검증을 실행한 것으로 간주하지 않는다. + +## 범위 + +### 포함 + +- `content_banner` 예약 시각 nullable 필드와 MySQL DDL, 관리자 입력·응답, 값 검증 및 부분 수정 semantics. +- 네 공개 조회의 기간 필터 및 focused repository/service 테스트. +- DDL 선행 적용을 포함한 rollout 순서 문서화. + +### 제외 + +- 오디오 이외 banner, scheduler, 공개 response 변경, security·권한 변경, 새 dependency, 전역 시간 설정 변경. +- live DB 실행 또는 기존 레코드 backfill. + +## 기술 제약 및 구현 결정 + +- Kotlin/Spring/JPA/QueryDSL 기존 구조를 유지한다. 신규 architecture/dependency는 추가하지 않는다. 네 조회가 재사용하는 작은 기간 predicate는 허용한다. +- 필드는 `startDate`, `endDate`, 컬럼은 `start_date`, `end_date`, 각각 `TIMESTAMP NULL DEFAULT NULL` 초 정밀도다. 모든 컬럼에 한국어 COMMENT를 기록한다. +- 기존 관리자 이벤트 입력 관례의 KST `yyyy-MM-dd HH:mm` 문자열을 사용한다. UTC 저장 및 KST 동일 포맷 응답을 적용하고, 종료는 해당 입력 분의 `:59`로 정규화한다. +- update는 기존 `UpdateContentBannerRequest` DTO를 유지하면서 raw JSON에서 필드 존재 여부를 확인해 생략은 유지, 명시 null은 clear 처리한다. 역전 범위와 형식 검증은 이미지 upload 전에 수행하고 `common.error.invalid_request`를 사용한다. +- 공개 조회 조건은 NULL 경계의 inclusive 규칙을 따르며 `.limit()`보다 앞선 DB `.where()`에 포함한다. 기존 모든 필터·정렬을 보존한다. +- DDL migration이 애플리케이션 rollout보다 먼저다. DB 작업은 운영 자동화에 연결하지 않고 별도 승인된 절차로 실행한다. +- 구현 테스트는 JUnit 5 TDD. 공개 banner port에는 now 인자가 없으므로, 기존 nowProvider 기본 인자 관례를 참고해 해당 query 경계의 최소 clock seam을 테스트에서 고정한다. 공개 조회의 UTC now는 초 단위로 비교한다. persistence 검증은 `TIMESTAMP(0)` 컬럼에 저장 후 `flush`/`clear`/재조회한다. 초 정밀도는 완화 비교하지 않는다. +- 이 host에는 `lsp_diagnostics`가 없어 Kotlin main/test 컴파일과 ktlint로 정적 검증을 대체한다. LSP 검증으로 보고하지 않는다. +- focused 명령은 아래 Gate에 기록한 여섯 테스트 클래스와 Progress의 실제 combined 명령을 기준으로 실행한다. + +### Phase 1: 예약 저장과 노출 판정 + +**Phase 결과:** 관리자 설정에서 시작/종료 시각을 저장하고 공개 경로가 기간 경계에 맞는 배너만 반환한다. + +**선행조건:** 없음. PRD 요구사항 SCH-001~007 확정. + +### Task 1.1 관리자·저장·응답 + +**Goal 실행 `P1-T1`:** 예약 범위를 생성·조회·부분 수정할 수 있고 DB에 정확히 왕복 저장한다. + +- **시작 조건:** PRD §7~8 확정, 기존 관리자 이벤트 날짜 입력 convention 확인. +- **완료 증거:** 모든 체크박스 완료, focused test 통과, DDL 파일 작성, DB 왕복 및 업로드 전 검증 결과 기록. +- **범위 밖:** 공개 조회 필터, 공개 응답 변경, 운영 DB 적용. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBanner.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/CreateContentBannerRequest.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/UpdateContentBannerRequest.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerService.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/GetAdminContentBannerResponse.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerRepository.kt` +- Create: `docs/20261027_콘텐츠배너예약기간/alter-content-banner-reservation.sql` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerServiceTest.kt` +- Create/Test: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt` + +**Interfaces:** + +- Consumes: 기존 `createAudioContentMainBanner(image, requestString)`, `updateAudioContentMainBanner(image, requestString)`, `getAudioContentMainBannerList(tabId)`와 `GetAdminContentBannerResponse` projection. +- Produces: entity `startDate/endDate: LocalDateTime?`; create/update 선택 필드 `startDate/endDate: String?`; update 생략/null 구분; admin response `startDate/endDate: String?`. 포맷 `yyyy-MM-dd HH:mm` KST. + +- [x] **RED:** create 값 저장, update 생략 유지 및 explicit null clear, 포맷/역전 오류와 업로드 미호출을 검증하는 focused test를 작성한다. +- [x] **RED 확인:** `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest'`로 미구현 동작의 HTTP/assertion 실패를 확인한다. 기존 mock 서비스 테스트는 회귀 확인으로 유지한다. +- [x] **GREEN:** nullable entity 컬럼, 선택적 create 필드, 최소 raw JSON presence 판독, KST/UTC parsing·formatting, effective 범위 검증, 응답 projection을 구현하고 DDL을 추가한다. +- [x] **GREEN 확인:** 같은 focused test를 통과시킨다. persistence 테스트는 고정 시각으로 저장 후 `flush`/`clear`/재조회해 두 경계가 초 단위로 왕복하는지 검증한다. +- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 관리자 test 및 `./gradlew ktlintCheck`를 실행한다. DDL의 nullable default/comment와 PRD 일치 여부를 확인한다. + +#### Task 1.1 회귀 수정: 예약 JSON 타입 오류 (`P1-T1-R1`) + +- **Objective:** 예약 값이 문자열/null이 아닌 JSON 객체·배열이어도 업로드·저장 전에 `common.error.invalid_request`로 거부한다. +- **시작 조건:** P1-T1 리뷰에서 DTO 역직렬화가 예약 검증보다 먼저 실패해 `common.error.unknown`이 되는 경로 확인. +- **Files:** `AdminContentBannerService.kt`, `AdminContentBannerControllerIntegrationTest.kt` (위 Task 1.1의 전체 경로와 동일). +- **완료 증거:** create/update × start/end × 객체/배열의 실제 HTTP RED/GREEN 및 기존 focused 회귀 통과, scoped 재검토. +- **범위 밖:** 다른 DTO 필드/전역 JSON 오류 처리 변경. +- [x] **RED:** 예약 객체·배열 입력의 HTTP 공통 오류 계약 실패를 확인한다. +- [x] **GREEN:** 해당 예약 필드의 타입 검증만 최소 보강해 같은 테스트를 통과시킨다. +- [x] **REFACTOR:** `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` 및 ktlint 결과를 누적한다. + +### Task 1.2 공개 조회 기간 필터 + +**Goal 실행 `P1-T2`:** 공개 네 조회가 현재 시각의 예약 가능 여부를 DB에서 limit 전에 판정한다. + +- **시작 조건:** `P1-T1` 완료, entity QueryDSL metamodel 재생성 또는 빌드 convention 반영. +- **완료 증거:** 네 경로 각각의 경계·무기한·조건 보존 테스트, focused test 통과. +- **범위 밖:** 관리자 목록 예약 필터, 공개 응답 schema 또는 기존 필터/정렬 변경. + +**Files:** + +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt` (`AudioContentQueryRepositoryImpl.getAudioContentMainBannerList(isAdult)`) +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` +- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` +- Create: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt` (네 query의 NULL 허용 기간 조건과 초 정밀도 비교를 공유하는 작은 QueryDSL 함수) +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.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/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt` +- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryBannerTest.kt` (해당 query method에 대한 신규 통합 테스트) +- Create/Test fixture: `src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerReservationFixtures.kt` (네 JPA 테스트가 같은 고정 UTC 예약 경계 matrix만 공유하며 기존 fixture/flush·clear를 재사용) + +**Interfaces:** + +- Consumes: `AudioContentBanner.startDate/endDate` UTC 저장 값; 구형 `AudioContentQueryRepositoryImpl.getAudioContentMainBannerList(isAdult)`와 `AudioContentBannerQueryRepositoryImpl.getAudioContentMainBannerList(tabId, isAdult, lang)`에 해당 query 경계의 테스트 가능한 now seam, v2의 `findHomeBanners(limit, memberId, includeAdultBanners, lang)`와 `findBanners(limit, memberId, canViewAdultContent, lang)`도 같은 고정 시각 seam. 공개 API signature는 유지. +- Produces: 기존 공개 method signature와 반환 타입 유지. 예약 기간 필터는 공개 조회 결과에만 적용. + +- [x] **RED:** 네 경로에서 양쪽 NULL, 시작만/종료만, inclusive 경계, 기간 밖 배너 및 limit 전에 걸러짐을 테스트한다. 기존 성인/언어/탭/active/target 조건과 정렬 유지 사례를 포함한다. +- [x] **RED 확인:** 네 repository 클래스의 신규 예약 테스트를 combined `./gradlew test --tests '*AudioContentBannerRepositoryTest.shouldExcludeFutureAndExpiredReservations' --tests '*AudioContentRepositoryBannerTest.shouldExcludeFutureAndExpiredReservations' --tests '*DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --tests '*DefaultAudioRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --console=plain`로 실행해 예약 조건 assertion 실패를 확인한다. +- [x] **GREEN:** 각 기존 QueryDSL query의 `.where(...)`에 현재 시각 기준 nullable inclusive 조건을 추가한다. 별도 조회 후 메모리 필터링은 하지 않는다. +- [x] **GREEN 확인:** 각 focused test를 통과시킨다. 시각은 고정된 UTC now를 사용하고 시작·종료 동등 경계를 포함한다. +- [x] **REFACTOR:** 기존 where 조건·정렬·limit 순서와 공개 response 변화를 확인하고 네 focused test 및 `./gradlew ktlintCheck`를 다시 실행한다. + +### Phase 1 Gate + +**Goal 실행 `P1-GATE`:** 관리자 설정, DB migration 및 공개 조회 전체 계약이 맞는지 판정한다. + +- **시작 조건:** `P1-T1`, `P1-T2` 완료. +- **완료 증거:** 아래 focused 테스트 통과, DDL과 rollout 순서 검토, 문서/변경 범위 확인 결과 기록. +- **범위 밖:** 전체 build/test 실행, 운영 DB 변경, 구현 범위 외 수정. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerControllerIntegrationTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryBannerTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest' +./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' +./gradlew ktlintCheck +``` + +**Expected:** 모든 명령 exit code 0. 공개 네 경로가 예약으로 선필터링되고 기존 조건/정렬 및 response schema가 보존된다. 관리자 목록에서 예약과 무관하게 기존 isActive/tab 조건으로 행이 조회된다. DDL은 nullable NULL 기본값이며 앱 rollout 전 적용이 필요하고 live DB 자동 실행은 없다. + +- [x] **자동 검증:** focused 테스트 재실행, Kotlin 컴파일, ktlint 및 `bootJar` 패키징이 통과한다. +- [x] **계약 대조:** NULL 경계 네 조합, 관리자 예약 무관 조회, 네 공개 query의 limit 전 조건과 기존 필터 보존을 확인한다. +- [x] **리뷰:** P1-T2와 전체 변경 통합 검토에서 미해결 blocking finding이 없다. +- [x] **배포/문서:** DDL 선행 적용과 미검증 운영 환경 경계를 명시하고 PRD·계획·리뷰 상태를 갱신한다. + +## 실행 순서 + +| 순서 | Goal | 선행조건 | 차단 시 행동 | +|---:|---|---|---| +| 1 | `P1-T1` | 없음 | PRD/기존 convention의 불일치만 문서화하고 임의 계약 금지 | +| 2 | `P1-T2` | `P1-T1` | 기존 query 조건을 유지하며 공개 경로 단위로 계속 검증 | +| 3 | `P1-GATE` | Task 1.1, 1.2 전체 | 실패 소유 Task에 회귀 검증 추가 | + +```text +P1-T1 → P1-T2 → P1-GATE +``` + +## 변경 금지 항목 + +- 예약 기간으로 관리자 목록을 필터링하지 않는다. 기존 `isActive` 및 tab 조건은 그대로 유지한다. +- 공개 response schema, 권한, security, 다른 banner 유형은 변경하지 않는다. +- 실패 테스트를 삭제·skip·완화하지 않는다. 새 dependency·scheduler·전역 시간대 설정을 추가하지 않는다. +- 운영 DB에 DDL을 자동 실행하지 않는다. migration 적용은 애플리케이션 rollout보다 먼저다. + +## Progress + +### 구현 전 확인 — 2026-10-27 + +- 변경 전 working tree는 clean이고 현재 branch는 `test`다. 저장소 규칙에 따라 worktree를 자동 생성하지 않고 현재 작업 공간에서 진행한다. +- `./gradlew tasks --all test --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, 기존 관리자/배너 테스트와 명령 유효성 확인. +- `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*DefaultAudioRecommendationQueryRepositoryTest' --console=plain` — exit 0, 기존 v2 repository 회귀 baseline 확인. +- `./gradlew ktlintCheck --console=plain` — exit 0, 변경 전 포맷 baseline 확인. +- 사전 대조: P1-T1은 nullable UTC entity와 관리자 필드를 제공하고 P1-T2는 같은 필드를 소비하므로 순차 실행한다. P1-T1의 HTTP 테스트와 DB 왕복, P1-T2의 고정 now/limit 전 필터가 각 요구사항과 일치한다. +- 계획 초안의 `startAt/endAt` 및 `start_at/end_at`은 구현 전에 사용자 명명 `startDate/endDate`, `start_date/end_date`로 보정했다. 제품 동작 변경은 없다. + +### P1-T1 실행 증거 — 2026-10-27 + +**TDD 실행 이력** + +| 순서 | 명령/대상 | 실제 결과 및 판정 | +|---|---|---| +| RED 1 | `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --console=plain` | exit 1, 6건 중 6건 실패, errors 0. 정상 multipart 생성과 GET 목록 요청 후 `Admin HTTP response must include startDate ==> expected: but was: ` assertion으로 실패. 미구현 entity 참조나 없는 JDBC 컬럼에 의한 실패가 아니다. | +| GREEN 1 | 위와 같은 focused 명령 | exit 0, `BUILD SUCCESSFUL in 1m 59s`, 생성·목록 6건 통과. nullable 저장·KST 응답 projection 구현 후 H2 컬럼을 `TIMESTAMP(0)`으로 맞추고 고정 기대 UTC 값과 실제 재조회 값을 정확히 비교했다. | +| 테스트 작성 중 보정 | `compileTestKotlin` | multipart builder의 `.with(...)` 반환 타입이 `MockHttpServletRequestBuilder`인데 helper에서 더 좁은 타입을 선언해 3곳 컴파일 오류가 발생했다. 테스트 helper의 타입만 바로잡았으며 이 실패는 유효 RED에 포함하지 않는다. Redis/DB 설정 변경이나 production 우회는 없었다. | +| RED 2 | 위와 같은 focused 명령 | exit 1, 60건 중 52건 실패. 수정 null 해제·새 경계 적용의 HTTP 날짜 assertion 실패, 역전/잘못된 수정의 `$.success` expected false/actual true, 잘못된 생성 날짜가 `SodaException`이 아닌 예외로 처리되는 assertion 실패를 확인했다. | +| GREEN 2 | 아래 최종 focused 명령과 동일 | exit 0, `BUILD SUCCESSFUL in 37s`, 통합 60건 + 기존 서비스 1건 + 공개 repository 1건 = 62건 통과. raw `JsonNode.has` 병합과 파싱 오류 변환, 업로드 전 effective 범위 검증을 구현했다. | +| RED 3 | 통합 focused 명령 | exit 1, 64건 중 3건 실패. strict `uuuu` parser도 `+02026-10-27 10:00`을 허용해 잘못된 형식의 create start/end 및 update start에서 `$.success`가 true였다. 계약의 16자리 형식 검증을 추가하기 전에 확인한 별도 RED다. | +| 최종 GREEN | 아래 최종 focused 명령 | exit 0, `BUILD SUCCESSFUL in 37s`, 66건 통과. XML 보고서의 failures/errors/skipped는 모두 0이다. | + +```bash +./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain +``` + +**최종 테스트 집계 및 실제 검증 범위** + +| 테스트 클래스 | tests | failures | errors | skipped | +|---|---:|---:|---:|---:| +| `AdminContentBannerControllerIntegrationTest` | 64 | 0 | 0 | 0 | +| `AdminContentBannerServiceTest` | 1 | 0 | 0 | 0 | +| `AudioContentBannerRepositoryTest` | 1 | 0 | 0 | 0 | +| 합계 | 66 | 0 | 0 | 0 | + +- 새 `@SpringBootTest`는 실제 controller/service/repository와 MockMvc multipart POST/PUT 및 GET을 사용하고 S3 외부 업로드만 mock한다. 기존 `EmbeddedRedisInitializer`를 해당 클래스에만 opt-in하고, 전용 H2 DB와 매 테스트 배너/탭 정리를 사용한다. 기존 서비스·공개 repository 테스트 파일은 수정하지 않았다. +- 생성 6건은 양쪽 생략/명시 null/시작만/종료만/양쪽/같은 분을 포함한다. 수정 9건은 생략 보존, 각 경계·양쪽 null 해제, 단일 경계 변경, 같은 분 및 다른 경계 해제를 반영한 병합을 검증한다. 미래·만료·무기한 예약은 관리자 목록에서 계속 보이고 기존 active/tab 조건은 보존된다. +- 형식/달력 오류는 11개 입력 × start/end × create/update = 44건이다. 생성 역전 1건 및 기존 값과 병합한 수정 역전 3건도 검증한다. 실패마다 `SodaException.messageKey == common.error.invalid_request`, S3 미호출, 생성 행 0개 또는 수정 전후 전체 DB row 완전 일치를 확인했다. +- persistence는 HTTP 서비스 트랜잭션 완료 후 별도 트랜잭션에서 `flush`/`clear`/재조회했다. H2 `start_date/end_date`는 명시적 `TIMESTAMP(0)`이다. `2026-10-27 00:00` KST의 전날 UTC 변환, `23:59` 입력의 종료 `:59`, 나노초 0, 목록의 KST 문자열/실제 JSON null을 허용오차나 절삭 비교 없이 확인했다. +- production 변경은 계획된 entity/관리자 DTO·service·repository 6파일에 한정했다. QueryDSL 보조 생성자에서 UTC를 KST 문자열로 변환해 기존 data class와 응답 필드를 유지하며 global serializer/timezone/dependency/security와 공개 query/response는 변경하지 않았다. + +**DDL 및 검증 경계** + +- `alter-content-banner-reservation.sql`은 MySQL `start_date/end_date TIMESTAMP NULL DEFAULT NULL`과 각 컬럼의 한국어 COMMENT만 추가한다. 기존 행은 NULL 유지, backfill 없음이며 파일 검토만 했다. 운영 적용은 별도 승인 절차로 DDL을 먼저 적용한 뒤 앱을 rollout해야 한다. +- 실제 MySQL DDL 실행, 운영 JDBC/session timezone과 MySQL TIMESTAMP 허용 범위에 대한 왕복 검증은 수행하지 않았다. H2 초 정밀도 및 Java KST/UTC 변환 검증을 운영 MySQL 검증으로 간주하지 않는다. +- `P1-T1`만 완료했다. `P1-T2` 공개 예약 필터와 `P1-GATE`는 미실행·미완료이며 해당 체크박스를 변경하지 않았다. + +### P1-T1-R1 실행 증거 — 2026-10-27 + +- RED: 기존 잘못된 날짜의 parameterized HTTP 테스트에 객체 `{}`/배열 `[]` × `startDate/endDate` × create/update 8건만 추가했다. `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --console=plain` — exit 1, `BUILD FAILED in 27s`, 72건 중 신규 8건만 assertion 실패, 기존 64건 통과, errors/skipped 0. HTTP 실패 응답의 resolved exception이 `SodaException`이어야 한다는 assertion에서 실패했으며 실제 원인은 DTO 매핑의 `MismatchedInputException`이었다. 컴파일/설정 오류는 없었다. +- GREEN: create/update 모두 JSON 트리를 한 번 읽고 DTO 매핑 전에 두 예약 필드만 검사한다. 생략·JSON null·문자열을 허용하고 그 외 JSON 타입을 `common.error.invalid_request`로 거부한다. 같은 트리를 `treeToValue`와 update의 `has` 판독에 재사용했다. 다른 DTO 필드나 전역 handler는 변경하지 않았고 mapping 예외를 포괄 catch하지 않는다. +- 회귀 명령: `./gradlew test --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, `BUILD SUCCESSFUL in 38s`. 실제 `:test`가 실행됐으며 통합 72건 + 기존 서비스 1건 + 공개 repository 1건 = 74건 통과했다. `build/test-results/test/TEST-*.xml`에서 failures/errors/skipped 모두 0임을 확인했다. +- 신규 8건은 실제 MockMvc multipart 요청으로 `SodaException.messageKey == common.error.invalid_request`, S3 미호출, 생성 행 0개 또는 수정 전후 전체 DB row 일치를 검증한다. 기존 64건으로 생략 보존·명시 null 해제·날짜 정밀도/시간대·관리자 목록 회귀도 재검증했다. S3 이외 mock은 추가하지 않았다. +- scoped 자체 재검토: production 변경은 `AdminContentBannerService.kt`의 예약 JSON 타입 경계뿐이다. 나머지 변경은 기존 통합 테스트의 공통 입력 source 확장과 이 R1 체크/증거이며 P1-T2·Gate 및 기존 완료 기록은 변경하지 않았다. 전체 test/build와 운영 MySQL은 이 회귀 목표의 검증 범위에서 제외했고 Kotlin main/test 컴파일을 focused 실행으로 확인했다. +- 정적 검사 및 문서 명령 확인: `./gradlew ktlintCheck tasks --all --console=plain` — exit 0, `BUILD SUCCESSFUL in 15s`. main/test ktlint 오류는 없었으며 기존 Gradle deprecation 안내만 남았다. LSP 도구는 제공되지 않아 Kotlin 컴파일/ktlint로 대체했다. R1의 RED/GREEN/REFACTOR만 완료 처리했다. +- parent 독립 검증: `./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain` — exit 0, `BUILD SUCCESSFUL in 36s`, 실제 test 재실행. XML의 72 + 1 + 1 = 74건, failures/errors/skipped 모두 0을 직접 확인했다. +- scoped 리뷰 및 재검토: 최초 예약 JSON 객체/배열 오류 경계 P2 1건을 R1로 보완했다. 재검토에서 finding ADDRESSED, 명세 준수/코드 품질 모두 APPROVE이며 신규 actionable finding은 없었다. P1-T1/R1 완료 후 P1-T2로 이동한다. + +### P1-T2 실행 증거 — 2026-10-27 + +- 실행 순서: 네 조회의 미구현 필터 RED 확인 → 공유 predicate/기본 UTC nowProvider 및 고정 시각 matrix → 공개 4클래스/관리자 2클래스 회귀와 ktlint. 관리자 파일 및 기존 완료 증거는 변경하지 않는다. +- 최초 RED 명령: `./gradlew test --tests '*AudioContentBannerRepositoryTest.shouldExcludeFutureAndExpiredReservations' --tests '*AudioContentRepositoryBannerTest.shouldExcludeFutureAndExpiredReservations' --tests '*DefaultHomeRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --tests '*DefaultAudioRecommendationQueryRepositoryTest.shouldExcludeFutureAndExpiredReservationsBeforeLimit' --console=plain`. +- 최초 RED 결과: exit 1, `BUILD FAILED in 23s`, 4건 실행/4건 assertion 실패, errors/skipped 0. 기존 생성자를 사용하고 실제 UTC 현재 시각의 ±1년으로 저장 후 flush/clear했다. 두 구형 조회는 expected `[visible]`, actual `[future, expired, visible]`; 홈 v2는 expected `[visible-1, visible-2]`, actual `[future, expired]`; 오디오 v2는 같은 이름의 CDN URL expected/actual로 실패했다. 컴파일/없는 seam/설정 오류가 아닌 실제 H2 조회 결과의 실패다. +- 중복 축소 결정: 구현 전 Files에 test-only `AudioContentBannerReservationFixtures.kt`를 추가했다. 고정 UTC now·nullable 시작/종료·기대 노출 여부만 공유하며 production test 분기나 공통 테스트 프레임워크는 추가하지 않는다. +- 종료 초 RED 명령: `./gradlew test --tests '*AudioContentBannerRepositoryTest.should*Reservation*' --tests '*AudioContentRepositoryBannerTest.should*Reservation*' --tests '*DefaultHomeRecommendationQueryRepositoryTest.should*Reservation*' --tests '*DefaultAudioRecommendationQueryRepositoryTest.should*Reservation*' --console=plain`. +- 종료 초 RED 결과: 최초 nullable inclusive predicate와 선택적 UTC seam 추가 후, 초 절삭을 추가하기 전에 실행했다. exit 1, `BUILD FAILED in 1m 22s`, 68건 중 8건 실패, errors/skipped 0. 네 경로 모두 `end-second-123456789`/`end-second-999999999`에서 expected `[candidate]`(오디오 v2는 CDN URL), actual `[]`였다. 나머지 60건은 통과해 최초 필터 RED의 4건도 GREEN을 확인했다. `TIMESTAMP(0)` 저장/flush/clear/재조회 값은 정확히 일치했으며 실패 원인은 미정규화 now 비교였다. 이후 공통 helper 내부에서 now를 한 번만 `truncatedTo(ChronoUnit.SECONDS)` 처리한다. +- GREEN 구현: `AudioContentBannerVisibility.kt`의 `QAudioContentBanner.isVisibleAt(now)`가 `(startDate IS NULL OR startDate <= currentSecond) AND (endDate IS NULL OR endDate >= currentSecond)`를 반환한다. Files의 네 query 구현에서 마지막 optional 생성자 인자 `nowProvider = { LocalDateTime.now(ZoneOffset.UTC) }`를 추가하고 해당 조회의 `.where(...)`에서 helper와 provider를 각각 한 번만 호출한다. 기존 positional 생성자 호출과 Spring 주입은 컴파일/통합 테스트로 확인했다. 기존 join/필터/order/limit/projection 및 port/method/response signature는 그대로다. +- 최종 고정 시각 검증: 공유 16-case matrix × 4경로 = 64건이다. 양쪽 NULL, 시작/종료 단독의 전·동등·후, 양쪽 범위 안·이전·이후·동등, `23:59:59.123456789`/`.999999999` 종료 초 포함, 다음 초 시작 전 제외, UTC 자정 시작 동등·전날 만료를 검증한다. H2 두 컬럼을 각 matrix에서 `TIMESTAMP(0)`로 맞춘 뒤 persist/flush/clear/재조회하여 원래 기대 시각과 정확히 비교한다. 최초 RED의 4건도 같은 기대 결과를 유지한 고정 UTC fixture로 전환했다. +- 기존 조건 회귀: v2의 미래/만료 두 선행 orders 행을 제외하고 `limit=2`를 정상 두 행이 채우는지 확인했다. 메인/구형에 탭·성인·활성·언어 적용 여부와 orders 정렬 보존 테스트 2건을 보강했다. 기존 홈 v2의 활성 대상·양방향 차단·언어·탭·성인·랜덤 tie-breaker/limit 테스트는 유지했고, 오디오 v2의 기존 배너 테스트에 비활성 creator 및 역방향 차단 대상도 추가했다. 실행 SQL에서도 네 경로의 괄호 묶인 nullable AND 조건과 v2의 order/limit 이전 where 적용을 확인했다. +- 첫 통합 GREEN: 아래 최종 명령에서 `ktlintCheck tasks --all`만 제외한 동일 6클래스 focused 명령 — exit 0, `BUILD SUCCESSFUL in 38s`, 237건 통과. 별도 `./gradlew ktlintCheck --console=plain`는 새 테스트의 긴 `saveBanner` 한 줄에서만 실패했다. `apply_patch`로 해당 호출의 인자 줄바꿈만 수정하고 전역 formatter는 실행하지 않았다. + +```bash +./gradlew test --tests 'kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerRepositoryTest' --tests 'kr.co.vividnext.sodalive.content.AudioContentRepositoryBannerTest' --tests 'kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerServiceTest' --tests 'kr.co.vividnext.sodalive.admin.content.banner.AdminContentBannerControllerIntegrationTest' ktlintCheck tasks --all --console=plain +``` + +- 최종 재검증: 위 명령 exit 0, `BUILD SUCCESSFUL in 37s`, 실제 `:test` 재실행 및 main/test/script ktlint 통과, Gradle task 목록 유효성 확인. `build/test-results/test/TEST-*.xml`를 직접 확인한 집계는 아래와 같으며 failures/errors/skipped는 모두 0이다. + +| 테스트 클래스 | tests | failures | errors | skipped | +|---|---:|---:|---:|---:| +| `AudioContentBannerRepositoryTest` | 19 | 0 | 0 | 0 | +| `AudioContentRepositoryBannerTest` | 18 | 0 | 0 | 0 | +| `DefaultHomeRecommendationQueryRepositoryTest` | 98 | 0 | 0 | 0 | +| `DefaultAudioRecommendationQueryRepositoryTest` | 29 | 0 | 0 | 0 | +| `AdminContentBannerControllerIntegrationTest` | 72 | 0 | 0 | 0 | +| `AdminContentBannerServiceTest` | 1 | 0 | 0 | 0 | +| 합계 | 237 | 0 | 0 | 0 | + +- 범위/검증 경계: production은 helper 1파일과 공개 query 4파일만 변경했다. 테스트는 기존 3클래스, 신규 구형 query 1클래스와 matrix 1파일이며 관리자·DTO·설정·dependency·DDL 파일은 수정하지 않았다. 공개 조회는 실제 JPA/QueryDSL/H2로 호출했고 관리자 73건은 기존 MockMvc/service 회귀 그대로다. LSP 대신 Kotlin main/test 컴파일과 ktlint로 확인했다. 초기 재컴파일에서 기존 다른 테스트의 unnecessary `!!`/unchecked-cast 경고와 기존 Gradle deprecation 안내가 있었고 관련 없는 코드는 변경하지 않았다. +- 사용자 지정 범위대로 전체 `test`/`build`, 운영 MySQL/DDL 실행, 별도 live HTTP 공개 API 검증은 수행하지 않았다. 공개 persistence 동작은 H2 실행 결과이며 운영 JDBC/session timezone 왕복 검증을 대체하지 않는다. P1-T2만 완료하고 활성/다음 Goal은 `P1-GATE`로 이동한다. 최종 Gate의 독립 판정과 rollout 확인은 parent에게 남기며 Phase/Gate를 완료 처리하지 않는다. + +## 최종 검증 기록 + +- 문서 작성 검토: 필수 project guide 및 sample PRD/plan과 대상 코드·테스트 경로를 대조했다. +- 구현 검증은 수행하지 않았다. 이 산출물은 요구사항/실행 계획이며 product/test 파일은 변경하지 않았다. + +### P1-T1 구현 후 추가 검증 — 2026-10-27 + +- 최종 `./gradlew ktlintCheck tasks --all --console=plain` — exit 0, `BUILD SUCCESSFUL in 16s`. main/test/Kotlin script ktlint와 문서 변경 후 Gradle 명령 목록을 확인했다. 앞선 `./gradlew ktlintCheck --console=plain`도 exit 0, `BUILD SUCCESSFUL in 15s`였다. +- 기존 문서 작성 검토와 구현 미실행 항목은 계획 작성 시점의 이력이다. 이후 P1-T1 구현 및 실제 RED/GREEN 결과는 Progress의 실행 증거와 최종 66건 XML 집계에 기록했다. 보고서는 `build/reports/tests/test/index.html`, 원본 집계는 `build/test-results/test/TEST-*.xml`에서 확인했다. +- LSP 도구는 host에서 제공되지 않는다. focused 명령이 실행한 `compileKotlin`/`compileTestKotlin`과 ktlint로 정적 검증을 대체하며 LSP 검증으로 보고하지 않는다. +- 전체 `test`/`build`는 실행하지 않았다. 관리자 예약 저장 경계에 한정된 P1-T1이며 공통 설정·보안·직렬화를 변경하지 않아 계획/프로젝트 가이드에 따라 focused 3클래스로 회귀 범위를 제한했다. 최초 전체 테스트 소스 컴파일 시 기존 다른 테스트의 non-null/unchecked-cast 경고와 Gradle deprecation 경고가 있었으며 관련 없는 파일은 수정하지 않았다. + +### P1-GATE parent 독립 실행 — 2026-10-27 + +```bash +./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --tests '*AudioContentRepositoryBannerTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*DefaultAudioRecommendationQueryRepositoryTest' ktlintCheck bootJar tasks --all --console=plain +``` + +- 실제 `:test`를 강제 재실행해 exit 0, `BUILD SUCCESSFUL in 46s`를 확인했다. `bootJar`는 새로 패키징했고 ktlint는 변경 코드 기준 up-to-date 통과했다. Gradle 명령 유효성도 확인했다. +- XML 직접 집계는 관리자 통합 72 + 관리자 서비스 1 + 메인 배너 repository 19 + 구형 배너 repository 18 + 홈 v2 repository 98 + 콘텐츠 추천 v2 repository 29 = 237건이다. 모든 클래스의 failures/errors/skipped는 0이다. +- `git diff --check`는 exit 0이다. 최초 clean 상태 이후 변경은 계획한 관리자·공개 query·테스트 및 작업 문서에 한정되며 기존 사용자 변경을 되돌리거나 commit/push하지 않았다. +- 관리자 surface는 실제 Spring controller/service/repository의 MockMvc multipart POST/PUT와 GET을 재실행했고, 공개 query surface는 실제 QueryDSL/JPA/H2 driver 테스트로 재실행했다. 별도 실서버 curl/운영 HTTP 검증은 수행하지 않았다. +- 전체 `test`/`check`/`build`는 실행하지 않았다. 공통 보안/설정/직렬화 변경이 없고 직접 영향받는 관리자 및 네 공개 query의 focused 회귀로 범위를 한정했다. 실행한 `bootJar`는 전체 회귀 테스트를 의미하지 않는다. +- 운영 MySQL DDL 실행, JDBC/session timezone 및 MySQL TIMESTAMP 허용 범위의 실환경 왕복은 미검증이다. 운영 DB에는 연결·변경하지 않았으며 DDL은 앱 rollout 전에 승인된 배포 절차로 적용해야 한다. +- Oracle의 P1-T2/전체 변경 통합 리뷰를 요청한 상태다. 결과를 수집하고 판정하기 전에는 Gate/Phase를 완료 처리하지 않는다. + +### P1-GATE 최종 판정 — 2026-10-27 + +- 위 parent fresh 실행의 237건/실패 0, ktlint 및 bootJar 성공 결과와 변경 파일·DDL을 다시 대조했다. +- Oracle의 P1-T2/전체 변경 통합 검토 결과는 명세 준수 APPROVE, 코드 품질 APPROVE이며 미해결 actionable finding이 없다. 네 공개 조회의 DB 선필터링, 기본 UTC nowProvider, nullable inclusive 경계, 관리자 예약 무관 조회, QueryProjection 및 기존 응답/필터/정렬의 호환성을 확인했다. +- 상세 리뷰는 `reviews/banner-reservation-final-review.md`에 기록했다. 앞선 관리자 입력 타입 문제는 R1과 `reviews/admin-reservation-review.md`로 수정·검증 완료 상태를 보존했다. +- PRD 수용 기준과 P1-T1/R1/P1-T2/Gate가 모두 충족되어 Phase 1을 완료 처리한다. 개발 범위의 blocked 항목은 없다. 운영 DDL 적용과 실환경 MySQL/timezone 검증은 코드 완료와 구분한 배포 전 조치이며 실행하지 않았다. +- 사용자의 후속 지시로 이후 git 명령은 실행하지 않는다. 그 전에도 commit/staging/branch 변경은 하지 않았고, 후속 지시 이후에는 Gradle 명령·문서 내용만 확인했다. diff --git a/docs/20261027_콘텐츠배너예약기간/prd.md b/docs/20261027_콘텐츠배너예약기간/prd.md new file mode 100644 index 00000000..b70d6ae6 --- /dev/null +++ b/docs/20261027_콘텐츠배너예약기간/prd.md @@ -0,0 +1,82 @@ +# 오디오 콘텐츠 배너 예약 기간 PRD + +## 문서 정보 + +| 항목 | 내용 | +|---|---| +| 문서 상태 | 구현 완료 | +| 작성일 | 2026-10-27 | +| 최종 수정일 | 2026-10-27 | +| 대상 제품 | 관리자 오디오 콘텐츠 배너 예약 노출 | +| 작성자·결정권자 | 제품 승인 요구사항 | +| 관련 API Contract | 관리자 예약 필드 추가, 기존 공개 response 유지, 본 문서 §7 | +| 관련 구현 계획 | `docs/20261027_콘텐츠배너예약기간/plan-task.md` | +| 관련 review | `reviews/admin-reservation-review.md`, `reviews/banner-reservation-final-review.md` | + +## 1. Overview + +관리자는 오디오 콘텐츠 배너에 선택적인 노출 시작·종료 시각을 설정한다. 공개 배너 조회는 현재 시각이 해당 기간에 포함될 때만 배너를 반환하고, 기존 배너는 NULL 기간으로 계속 노출한다. + +## 2. Problem Statement + +현재 배너는 노출 시작과 종료 시점을 관리할 수 없어서 기간성 콘텐츠를 수동으로 활성화·비활성화해야 한다. 기간이 지나도 공개 조회에서 배너가 선택될 수 있다. + +## 3. Goals + +- 관리자가 예약 기간을 생성·조회·수정·해제할 수 있다. +- 공개 오디오 배너 조회 네 경로가 동일한 기간 조건을 적용하고 기존 필터·정렬·limit 동작을 보존한다. +- 기존 레코드의 동작과 공개 응답 스키마를 유지한다. + +## 4. Non-Goals + +- 오디오 이외 배너 유형, 예약 실행 scheduler, 활성 상태 자동 변경은 범위 밖이다. +- 공개 API 응답 필드, 권한·인증, 공통 시간대 설정 및 dependency 변경은 하지 않는다. +- 운영 DB에 DDL을 자동 실행하지 않는다. + +## 5. 사용자 및 권한 + +- 사용자는 기존 관리자 배너 API의 기존 권한으로 생성·조회·수정한다. 권한 범위는 변경하지 않는다. +- 공개 조회 권한과 성인·언어·탭·비활성·차단 대상 필터는 기존 계약을 유지한다. + +## 6. 핵심 흐름 + +1. 관리자가 생성 요청에 선택적 예약 시각을 입력한다. +2. 관리자가 목록에서 저장된 두 시각을 확인한다. 예약 여부로 관리자 목록 행을 숨기지 않는다. +3. 관리자가 수정을 생략하면 기존 값을 유지하고, 해당 필드를 JSON `null`로 보내면 그 경계만 해제한다. +4. 공개 조회는 예약 기간 안에 있는 활성 배너만 기존 조건에 따라 반환한다. + +## 7. 확정 기능 요구사항 및 계약 + +| ID | 상태 | 요구사항 및 수용 기준 | 계약/Goal | +|---|---|---|---| +| SCH-001 | 확정 | `startDate`, `endDate`는 nullable 시각이다. 둘 다 NULL이면 항상 노출, 시작만 있으면 `startDate <= now`, 종료만 있으면 `now <= endDate`, 둘 다 있으면 `startDate <= now <= endDate`이다. | `P1-T1`, `P1-T2` | +| SCH-002 | 확정 | 관리자 생성 입력의 두 필드는 optional이다. 관리자 목록은 두 값을 반환하되 기간으로 행을 필터링하지 않으며 기존 `isActive` 및 tab 조건을 유지한다. | `P1-T1` | +| SCH-003 | 확정 | 관리자 수정은 생략한 경계를 유지하고, 명시적 JSON null은 해당 값을 해제한다. effective start가 end보다 늦으면 `common.error.invalid_request`로 거부한다. 형식 오류도 같은 공통 오류를 사용하며, 검증은 이미지 업로드 전에 끝난다. | `P1-T1` | +| SCH-004 | 확정 | 관리자 입력 형식은 기존 이벤트 입력 관례인 KST 문자열 `yyyy-MM-dd HH:mm`, 저장은 UTC다. 끝 시각은 입력 분의 `:59`까지 포함한다. 응답 시각은 KST 동일 형식이다. 이는 기존 관리자 이벤트 관례를 적용한 구현 결정이며 사용자 지정 초 형식은 아니다. | `P1-T1` | +| SCH-005 | 확정 | DB는 두 컬럼을 MySQL `TIMESTAMP NULL DEFAULT NULL` 초 정밀도로 추가하고 모든 컬럼에 설명 COMMENT를 둔다. 기존 데이터는 NULL 유지, backfill 없음. DDL은 문서로 제공하며 적용이 앱 rollout보다 먼저다. 운영 DB 자동 실행 금지. | `P1-T1`, Gate | +| SCH-006 | 확정 | 공개 경로 네 곳 모두 기간 필터를 DB 조회에 적용해 limit 전에 제외한다. 기존 조건과 정렬을 보존한다. | `P1-T2` | +| SCH-007 | 확정 | 공개 응답 schema·권한·security는 그대로 유지한다. | 전체 | + +관리자 수정에서 JSON 필드 생략과 명시적 null을 구분하기 위해 raw JSON의 presence만 감지한다. 신규 dependency는 추가하지 않는다. + +공개 조회 경로는 `AudioContentQueryRepositoryImpl.getAudioContentMainBannerList(isAdult)`, `AudioContentBannerQueryRepositoryImpl.getAudioContentMainBannerList(tabId, isAdult, lang)`, `DefaultHomeRecommendationQueryRepository.findHomeBanners(limit, memberId, includeAdultBanners, lang)`, `DefaultAudioRecommendationQueryRepository.findBanners(limit, memberId, canViewAdultContent, lang)`이다. 각 경로에 이미 적용된 활성·탭·언어·성인·대상 소유자/차단 조건과 기존 정렬을 보존한다. + +## 8. 데이터·오류·시각 + +- 입력 필드명은 사용자 요청대로 `startDate`, `endDate`; 값은 `yyyy-MM-dd HH:mm` 문자열 또는 null이다. 응답은 nullable 문자열이다. +- 형식 오류 및 기간 역전은 기존 `common.error.invalid_request`로 응답한다. +- DDL 소수초 미지정 `TIMESTAMP`의 초 정밀도에 맞춰 초 단위 단일 값으로 저장·응답한다. 공개 조회의 현재 UTC 시각도 초 단위로 비교해 종료 분의 마지막 초 전체를 포함한다. 테스트는 고정 UTC 현재 시각 seam을 사용하고 실제 DB 저장 후 `flush`/`clear`/재조회로 왕복을 확인한다. H2 검증 컬럼은 `TIMESTAMP(0)`로 운영 DDL의 정밀도를 재현한다. + +## 9. 성공 기준과 추적 + +- [x] 관리자 create/update/list에서 선택적 기간, 생략 유지와 명시적 null 해제를 검증한다. (`SCH-002~005`, `P1-T1`) +- [x] 잘못된 입력과 역전 기간이 업로드 전 공통 오류로 거부된다. (`SCH-003`, `P1-T1`, `P1-T1-R1`) +- [x] 네 공개 경로가 경계 시각 포함 여부를 판정하고 예약 외 레코드를 limit 전에 제외한다. (`SCH-001`, `SCH-006`, `P1-T2`) +- [x] 기존 공개 필터·정렬, 관리자 isActive/tab 필터, 공개 응답 schema가 보존된다. (`SCH-002`, `SCH-006~007`, Gate) +- [x] DDL과 앱 배포 순서를 문서화하고 자동 운영 DB 적용이 없다. (`SCH-005`, Gate) + +## 10. 제외 항목 및 결정 기록 + +- 제외: 다른 banner type, scheduler, 공개 response 변경, 자동 isActive 전환, 신규 dependency, global time 설정, live DB 실행. +- 2026-10-27, DEC-001, 확정: 이벤트 관리자의 기존 KST 분 단위 입력/응답 관례를 적용하고 종료 분을 `:59`까지 포함한다. 요구사항은 timestamp 예약을 지정했으며 seconds format을 지정하지 않았으므로 기존 관리자 관례를 따른다. +- 2026-10-27, DEC-002, 확정: 기간 필터는 공개 네 조회에만 적용한다. 관리자 목록은 예약과 무관하게 기존 활성/tab 정책을 유지한다. diff --git a/docs/20261027_콘텐츠배너예약기간/reviews/admin-reservation-review.md b/docs/20261027_콘텐츠배너예약기간/reviews/admin-reservation-review.md new file mode 100644 index 00000000..2874b108 --- /dev/null +++ b/docs/20261027_콘텐츠배너예약기간/reviews/admin-reservation-review.md @@ -0,0 +1,45 @@ +# 관리자 콘텐츠 배너 예약 리뷰 + +## 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 대상 | P1-T1, P1-T1-R1의 working tree 변경 | +| 일자 | 2026-10-27 | +| 리뷰어 | 독립 scoped reviewer, parent 실행 검증 | +| 기준 | `../prd.md` SCH-001~005, `../plan-task.md` | +| 상태 | 수정 검증 완료 | + +## 범위와 근거 + +- 관리자 create/update/list, nullable entity 시각, KST/UTC 변환, MySQL DDL 및 실제 HTTP/DB 테스트를 검토했다. +- `AdminContentBannerService.kt`, `GetAdminContentBannerResponse.kt`, `AdminContentBannerRepository.kt` 및 요청 DTO와 entity의 변경을 확인했다. +- `AdminContentBannerControllerIntegrationTest.kt`의 multipart POST/PUT/GET, H2 `TIMESTAMP(0)` 별도 트랜잭션 재조회 및 S3 미호출 assertion을 확인했다. +- 공개 기간 필터는 다음 P1-T2 범위이므로 이 리뷰에서 판정하지 않았다. 운영 MySQL과 JDBC/session timezone은 검증하지 않았다. + +## 발견 사항 + +| ID | 심각도 | 상태 | 내용 | 후속 Goal | +|---|---|---|---|---| +| REV-ADMIN-001 | Medium | 수정 완료 | 예약 필드에 JSON 객체/배열이 오면 DTO 매핑 단계에서 실패해 `common.error.unknown`으로 반환됨 | P1-T1-R1 | + +### REV-ADMIN-001 + +- 관찰: `startDate: {}` 또는 `endDate: []`에서 Jackson 매핑 예외가 날짜 검증 전에 발생했고, 일반 예외 처리기가 예약 오류 계약과 다른 결과를 반환했다. +- 재현: create/update × start/end × 객체/배열의 HTTP 사례 8개를 추가했다. 실제 RED에서 기존 64건은 통과하고 신규 8건만 실패했다. +- 조치: 같은 JSON 트리에서 두 예약 필드의 타입을 DTO 변환 전에 검사한다. 문자열/null/생략만 허용하며 다른 필드의 기존 매핑 계약이나 전역 예외 처리는 바꾸지 않았다. +- 검증: 오류 키 `common.error.invalid_request`, S3 미호출, 생성 미저장 및 수정 전후 DB row 일치를 확인했다. 기존 생략/null·시간대·관리자 목록 테스트도 유지했다. + +## 실행 증거 + +```bash +./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --console=plain +``` + +parent가 위 명령을 실행해 exit 0, `BUILD SUCCESSFUL in 36s`를 확인했다. XML 집계는 통합 72 + 기존 서비스 1 + 공개 repository 1 = 74건이며 failures/errors/skipped는 모두 0이다. 구현자의 `ktlintCheck tasks --all`도 exit 0이다. LSP 도구가 없어 Kotlin 컴파일/ktlint를 사용했다. + +## 종료 판정 + +- 최초 발견 사항은 기존 완료 이력을 되돌리지 않고 P1-T1-R1로 누적했다. +- scoped 재검토 결과 REV-ADMIN-001은 ADDRESSED, 명세 준수와 코드 품질 모두 APPROVE다. 수정 범위의 새 actionable finding은 없다. +- 관리자 기능의 미해결 finding은 없다. P1-T2와 최종 Gate는 별도로 수행한다. diff --git a/docs/20261027_콘텐츠배너예약기간/reviews/banner-reservation-final-review.md b/docs/20261027_콘텐츠배너예약기간/reviews/banner-reservation-final-review.md new file mode 100644 index 00000000..abda5d2a --- /dev/null +++ b/docs/20261027_콘텐츠배너예약기간/reviews/banner-reservation-final-review.md @@ -0,0 +1,46 @@ +# 콘텐츠 배너 예약 최종 리뷰 + +## 리뷰 정보 + +| 항목 | 내용 | +|---|---| +| 대상 | P1-T2와 전체 working tree 변경, P1-GATE | +| 일자 | 2026-10-27 | +| 리뷰어 | Oracle 독립 통합 리뷰, parent 실행 검증 | +| 기준 | `../prd.md`, `../plan-task.md`, `admin-reservation-review.md` | +| 상태 | 판정 완료 | + +## 목적과 범위 + +관리자 예약 설정에서 공개 배너 노출 판정까지 승인된 계약이 연결되는지 확인했다. 관리자 DTO/service/repository/응답·entity, 네 공개 query와 공유 predicate, 관련 테스트·DDL을 검토했다. 새로운 기능 설계나 다른 배너 도메인, 전역 보안·시간대 설정 변경은 범위 밖이다. + +## 검토 근거 + +- 공개 query: `AudioContentQueryRepositoryImpl.getAudioContentMainBannerList`, `AudioContentBannerQueryRepositoryImpl.getAudioContentMainBannerList`, `DefaultHomeRecommendationQueryRepository.findHomeBanners`, `DefaultAudioRecommendationQueryRepository.findBanners` 모두 기존 조건에 공유 `isVisibleAt(now)`를 AND 적용한다. v2는 limit 전에 예약을 제외한다. +- 경계: NULL 제한 없음과 양 끝 포함, 초 단위 UTC now, 종료 분 마지막 초의 나노초·자정 경계를 확인했다. 네 생성자의 optional nowProvider는 기존 호출과 Spring 주입을 유지한다. +- 관리자: 엄격한 문자열/JSON 타입 및 effective 기간 검증이 S3·entity 변경보다 앞선다. update 생략 유지/명시 null 해제, KST 입력/응답·UTC 저장, 관리자 날짜 무관 조회와 기존 active/tab 조건을 확인했다. +- 응답: QueryProjection 보조 생성자와 생성 Q 타입의 `LocalDateTime` 인자 일치 및 UTC→KST 문자열 변환을 확인했다. 공개 서비스의 기존 DTO 매핑은 예약 필드를 공개 응답에 추가하지 않는다. +- 테스트: 공유 16-case matrix × 4 query, 실제 H2 `TIMESTAMP(0)` 저장/flush/clear/재조회, v2 limit 충족 및 관리자 multipart HTTP 계약을 확인했다. + +## 실행 증거 + +parent가 다음 명령을 실행했다. Oracle은 같은 테스트를 중복 실행하지 않고 정적 검토를 수행했다. + +```bash +./gradlew test --rerun --tests '*AdminContentBannerControllerIntegrationTest' --tests '*AdminContentBannerServiceTest' --tests '*AudioContentBannerRepositoryTest' --tests '*AudioContentRepositoryBannerTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*DefaultAudioRecommendationQueryRepositoryTest' ktlintCheck bootJar tasks --all --console=plain +``` + +- exit 0, `BUILD SUCCESSFUL in 46s`; test 강제 재실행, ktlint 통과와 실행 JAR 패키징 성공. +- XML 직접 집계: 72 + 1 + 19 + 18 + 98 + 29 = 237건, failures/errors/skipped 모두 0. +- `git diff --check` exit 0. LSP 도구가 없어 main/test Kotlin 컴파일과 ktlint로 정적 검증을 대체했다. +- 관리자 surface는 MockMvc POST/PUT/GET, 공개 persistence surface는 실제 QueryDSL/JPA/H2로 실행했다. 별도 공개 live HTTP/curl과 운영 DB는 검증하지 않았다. + +## 발견 사항과 종료 판정 + +**확정 발견 사항 없음.** 명세 준수와 코드 품질 모두 APPROVE다. P1-T2/전체 변경을 함께 검토했으며 누락된 조회 경로나 Gate를 막는 테스트 공백을 발견하지 못했다. 신규 회귀 Task 전환 항목은 없다. + +앞선 관리자 JSON 객체/배열 오류는 P1-T1-R1로 수정·검증됐으며 이번 전체 검토에서도 유지됨을 확인했다. parent의 fresh 실행 결과와 함께 P1-GATE 완료 근거로 사용한다. + +## 잔여 검증 경계 + +전체 test/check/build는 실행하지 않았고, 직접 영향받는 여섯 클래스 회귀와 bootJar 패키징만 실행했다. 운영 MySQL의 TIMESTAMP 허용 범위, JDBC/session timezone 및 실제 DDL 왕복은 미검증이며 입증된 코드 결함으로 판정하지 않았다. nullable 컬럼 추가 DDL은 backfill 없이 기존 NULL 노출을 유지하며, 앱 rollout 전에 별도 승인 절차로 적용해야 한다.