Test #462

Merged
klaus merged 13 commits from test into main 2026-10-30 03:15:43 +00:00
47 changed files with 4505 additions and 6 deletions
@@ -0,0 +1,75 @@
# 일반용 이벤트 상세 API 클라이언트 구현 프롬프트
아래 프롬프트는 이번에 구현한 서버 계약이다. 운영 배포 완료를 의미하지 않는다.
```text
기존 앱의 네트워크 계층과 화면 패턴을 사용해 일반 사용자용 이벤트 상세 조회를 연결해 주세요.
관리자 상세 API를 만들거나 기존 이벤트 목록/팝업 API를 변경하지 마세요.
[요청]
GET {기존 API_BASE_URL}/api/v2/events/{id}
- id: 기존 이벤트 목록/배너의 이벤트 id. 양수 64비트 정수이며 필수입니다.
- 요청 본문과 query parameter는 없습니다.
- 비로그인 호출이 가능합니다. 비로그인에는 Authorization 헤더를 보내지 마세요.
- 로그인 시 기존 Authorization: Bearer {accessToken}을 사용하세요.
- 오류 메시지 언어는 기존 Accept-Language 정책을 따릅니다.
- 이벤트 언어는 Accept-Language가 아니라 서버의 국가 판정에 따릅니다.
CloudFront-Viewer-Country 및 기존 서버 보정 정책을 사용하므로 국가를 임의로 바꾸지 마세요.
[성공: HTTP 200, application/json]
{
"success": true,
"message": null,
"data": {
"id": 123,
"thumbnailImageUrl": "https://cdn.example.com/event/thumbnail.png",
"detailImageUrl": "https://cdn.example.com/event/detail.png",
"popupImageUrl": null,
"link": "https://example.com/promotion"
},
"errorProperty": null
}
- data는 배열이 아닌 이벤트 단일 객체입니다.
- id: 정수, thumbnailImageUrl: 문자열.
- detailImageUrl, popupImageUrl, link: 문자열 또는 null.
- title, content, startDate, endDate, lang, isAdult, isPopup, totalCount는 반환하지 않습니다.
- 이미지 URL은 서버가 구성한 값을 사용하고 CDN 주소를 다시 붙이지 마세요.
- link는 이미지 URL과 별개인 이동 목적지이며 저장값 그대로 반환합니다.
- 상세 이미지가 null이어도 link가 있는 정상 이벤트일 수 있습니다.
- 이미지와 link가 모두 있을 때의 우선순위나 자동 이동은 API가 결정하지 않습니다.
기존 앱의 표시/이동 정책을 유지하고, 임의로 자동 리다이렉트를 추가하지 마세요.
- 서버는 link의 URL 형식이나 허용 스킴을 검증하지 않습니다.
링크 열기 시 기존 앱의 안전한 외부 URL 처리 정책을 적용하세요.
[조회 불가: HTTP 200]
{
"success": false,
"message": "잘못된 요청입니다.",
"data": null,
"errorProperty": null
}
- 미존재 ID, 0/음수 ID, 비활성, 시작 전/종료 후, 언어 불일치, 성인 조건 불일치가 동일한 응답입니다.
- 숫자가 아니거나 Long 범위를 넘는 id는 HTTP 200 실패 응답과 기존 공통 오류 메시지를 반환합니다.
- HTTP 200만으로 성공 판정하지 말고 success=true와 유효한 data를 확인하세요.
- message는 지역화되므로 문구로 오류 유형을 분기하지 마세요.
- 서버 내부 messageKey는 응답에 없으며 미존재와 비노출 사유를 구별할 수 없습니다.
- 이 API의 조회 불가가 HTTP 404라고 가정하지 마세요.
- 네트워크 오류와 비정상 HTTP 상태/인증 오류는 기존 앱의 공통 처리 정책을 유지하세요.
모든 오류 응답이 위 JSON 형태라고 가정하지 마세요.
[서버 노출 정책: 기존 GET /event와 동일]
- 활성이고 시작 시각 <= 현재 시각 <= 종료 시각인 이벤트만 조회됩니다.
- 서버 판정 국가가 JP이면 JA 이벤트, 그 외에는 KO 이벤트만 조회됩니다.
- 비로그인/성인 미인증 회원: isAdult=null 또는 false 이벤트.
- 성인 인증 회원: isAdult=null 또는 true 이벤트. 기존 정책상 false 이벤트는 제외됩니다.
- 관리자 계정: 성인 조건만 생략하며 활성/기간/언어 조건은 유지됩니다.
- 위 정책은 서버가 적용합니다. 클라이언트가 목록과 다른 우회 조회를 구현하지 마세요.
- 로그인 상태/국가가 바뀌면 이전 조회 결과를 무조건 재사용하지 마세요.
[클라이언트 수용 기준]
- 이벤트 id로 상세 API를 호출해 loading/success/unavailable/network-error를 구분합니다.
- nullable 이미지/link 및 링크만 있는 이벤트를 정상 처리합니다.
- HTTP 200 success=false에서도 기존 데이터를 성공 화면으로 표시하지 않습니다.
- 로그인 여부에 따른 헤더와 실제 서버 결과를 사용하고, 응답에 없는 제목/본문/날짜를 만들어 내지 않습니다.
- 기존 목록 GET /event, 팝업 GET /event/popup, 관리자 API는 그대로 유지합니다.
```
@@ -0,0 +1,152 @@
# 일반용 이벤트 상세 조회 구현 계획
**Goal:** 일반 사용자가 기존 노출 정책을 통과한 이벤트 한 건을 v2 API로 조회한다.
**Architecture:** 기존 EventRepository의 ID 조회와 EventItem을 재사용한다. 신규 controller와 read-only service는 v2 아래 배치하고 기존 controller/service는 변경하지 않는다.
**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JPA, JUnit 5, H2.
**Spec:** [prd.md](prd.md)
**실행:** subagent-driven-development; 커밋/푸시는 별도 승인 없이 하지 않는다.
## 현재 상태
- 상태: 구현 및 검증 완료 (운영 배포 제외)
- 현재 Phase: 1
- 활성/다음 Goal: 없음 (P1-T1, P1-GATE 완료)
- 순서: P1-T1 → P1-GATE. 동시에 하나만 실행한다.
## Global Constraints
- 승인된 GET `/api/v2/events/{id}`만 추가. 관리자 상세/legacy 변경/DB·의존성 변경 제외.
- 기존 성인/언어 정책 및 이미지 변환을 그대로 유지한다.
- 모든 신규 API/하위 구현은 v2 패키지에 둔다. 불필요한 추상화/port는 만들지 않는다.
- 기존 테스트 삭제·완화, 비밀값 기록 금지. 소유 임시 자원만 정리한다.
## Review Focus
- 인증된 성인에게 `isAdult=false` 이벤트를 추가로 노출하지 않는지 확인한다.
- 관리자도 언어·기간·활성 조건을 지키는지 확인한다.
- 익명 허용이 쓰기/collection/추가 하위 경로로 확장되지 않는지 확인한다.
- null 이미지, 외부 HTTPS 이미지, 외부 link를 변경하거나 관리 엔티티를 수정하지 않는지 확인한다.
- 미존재와 비노출 오류가 동일하고 상세 정보를 누출하지 않는지 확인한다.
### Phase 1: v2 단건 조회
- [x] **Task 1.1: 일반 이벤트 상세 조회와 보안 회귀 검증**
**Goal P1-T1:** 기존 목록과 같은 노출 정책을 적용한 단일 EventItem을 반환한다.
- 시작 조건: PRD 확정, 기존 event/admin focused test 성공.
- 완료 증거: RED/GREEN 기록, S1~S3 테스트 성공, 실제 HTTP 응답 증거.
- 범위 밖: 전체 빌드/독립 리뷰는 P1-GATE; legacy 리팩터링과 추가 기능.
**Files:**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt`
- 필요한 서비스 경계 테스트는 같은 v2/api/event 패키지에 통합 테스트로 추가한다.
**Interfaces:**
- Consumes: `EventRepository`, `AuthRepository.getAuthIdByMemberId`, `MemberContentPreferenceService.resolveCountryCode`, `EventItem`.
- Produces: `EventDetailService.getEvent(id: Long, member: Member?): EventItem`.
- Controller: `/api/v2/events`, `@GetMapping("/{id}")`, nullable `@AuthenticationPrincipal`.
- Security: GET `/api/v2/events/*`만 permitAll; `/**` 금지.
- [x] **RED:** 먼저 실제 HTTP 정상 요청 테스트를 작성하고, 조회 불가·성인 3상태·언어·관리자·응답 변환·인접 보안 시나리오를 추가한다.
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain`에서 미구현 route/security로 정상 요청이 실패함을 기록한다.
- [x] **GREEN:** ID 단건 로드, 양수 검사, 활성/현재 시각/언어/성인 필터, EventItem 변환, 좁은 보안 허용을 구현한다.
- [x] **GREEN 확인:** 동일 focused test로 모든 시나리오 통과를 확인한다.
- [x] **REFACTOR:** 새 중복만 필요 시 정리하고 기존 event/admin 회귀를 다시 실행한다.
**검증 세부:**
- 실제 DB에 저장한 KO/JA 및 nullable adult 필드를 비교한다. 인증 일반 회원의 auth 기록은 실제 DB로 준비한다.
- `isAdult` null/false/true 각각을 익명·미인증·인증·관리자에 대해 기존 `/event`와 비교한다.
- 미래/종료 테스트는 현재 시각에서 충분히 떨어진 값을 사용해 flake를 피한다. 기간 조건의 양끝 포함은 코드 및 가능한 기존 seam으로 검증한다.
- 이미지/링크 null, 상대 경로, HTTPS, 기존 http 문자열 보정 및 모든 응답 필드를 확인한다.
- missing/zero/negative/nonnumeric/overflow ID, non-GET, 하위 추가 경로, legacy 목록/팝업/admin 접근을 검증한다.
- RANDOM_PORT/TestRestTemplate의 상태/본문을 test output으로 남긴다. 인증은 동일 컨텍스트 MockMvc로도 검증 가능하다.
### Phase 1 Gate
- [x] **Goal P1-GATE: 동작·회귀·빌드·리뷰·정리 완료**
- 시작 조건: P1-T1 완료.
- 완료 증거: 아래 명령과 HTTP 증거, 독립 reviewer 승인, 자원 정리 확인.
- 범위 밖: 기존 전체 테스트 실패/린트 위반의 무관한 수정.
- TDD 예외 사유: 읽기 전용 완료 검증이며 테스트 추가가 아닌 실행·리뷰 단계다.
```bash
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain
./gradlew test --console=plain
./gradlew bootJar ktlintCheck --console=plain
./gradlew tasks --all --console=plain
```
- LSP 도구가 노출되어 있으면 변경 파일 진단, 없으면 Kotlin compile/ktlint 결과와 미실행 사실을 기록한다.
- 사용자 ulw 지시 및 보안 matcher 추가로 전체 테스트를 실행한다. 실패가 기존 상태에서도 재현되면 별도 제한으로 기록한다.
- 고위험 reviewer 1명이 목표·S1~S3·diff·실행 증거를 확인한다. 범위 내 blocker만 수정한다.
- 소유 임시 폴더/QA 프로세스가 정리되었음을 확인한다. 공유 Gradle 캐시는 삭제하지 않는다.
- 후속 사용자 요청의 전달물: `api-client-prompt.md`에 구현된 요청/응답/오류/노출 정책을 기록하고 최종 응답에도 프롬프트로 제공한다. 문서 작업의 TDD는 해당 없으며 실제 HTTP 응답 및 코드와 대조한다.
## Progress
### 사전 검증 — 2026-10-28
- worktree: clean.
- `./gradlew test --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain`: BUILD SUCCESSFUL, 1m 1s. 구현 전 기존 목록/팝업/관리자 테스트 기준선 확보.
- 부모 세션에는 lsp_diagnostics 도구가 노출되지 않았다. 진단 성공으로 보고하지 않는다.
### P1-T1 RED — 2026-10-28
- 새 production 타입을 참조하지 않는 RANDOM_PORT/TestRestTemplate + 같은 컨텍스트 MockMvc 통합 테스트를 먼저 작성했다. S3 외 노출 정책·repository·인증 기록은 mock 없이 실제 H2로 검증한다.
- 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`.
- 첫 실행은 도구의 120초 제한으로 test compile 중 중단되어 RED 근거로 사용하지 않았다. 같은 명령을 600초 제한으로 재실행: BUILD FAILED, 8m 59s, 28 tests completed / 15 failed / 0 errors. compile 성공 후 실제 HTTP assertion에서 expected 200 OK / actual 401 UNAUTHORIZED로 실패했다. 기존 보호 경로 13건은 통과했다.
- 정상 요청 RED 원문: `HTTP GET /api/v2/events/19 country=null -> 401 {"timestamp":"2026-10-28T09:03:37.443+00:00","status":401,"error":"Unauthorized","path":"/api/v2/events/19"}`. `shouldReturnAnonymousEventDetailWithLegacyFieldsWithoutMutatingTheEntity`의 HTTP 상태 assertion 실패이며 missing-class compile 실패가 아니다.
- RED 원본은 소유 임시 폴더의 `red-retry.log` 및 당시 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`에서 확인했다. XML은 다음 실행으로 갱신되므로 핵심 결과와 응답을 이 기록에 보존한다.
- 실행 자원: `--no-daemon`과 in-process Kotlin compile을 사용했다. 소유 임시 root는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/event-detail-p1-t1.xH3EUF`이며 작업 종료 시 삭제하고 공유 캐시/다른 세션 프로세스는 건드리지 않는다.
### P1-T1 GREEN 및 인접 회귀 — 2026-10-28
- 변경 파일은 신규 `EventDetailController.kt`, `EventDetailService.kt`, 신규 `EventDetailControllerIntegrationTest.kt`, `SecurityConfig.kt`의 GET matcher 한 줄, 이 계획 문서뿐이다. 기존 엔티티/서비스/컨트롤러와 PRD는 변경하지 않았다.
- `EventRepository.findByIdOrNull`로 단건을 로드하고 read-only 서비스에서 기존 목록과 동일한 정책을 확인한다. 전체 목록 조회, 새 port/DTO/의존성, 별도 parser/exception handler는 추가하지 않았다. 관리자 우회는 성인 조건에만 적용한다.
- focused GREEN 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process` → BUILD SUCCESSFUL, 8m 26s, 28 tests / 0 failures / 0 errors / 0 skipped.
- 인접 회귀 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process` → BUILD SUCCESSFUL, 3m 45s. XML 합계 46 tests / 0 failures / 0 errors / 0 skipped: 신규 28, 기존 EventController 5 / EventRepository 5 / EventService 4 / AdminEventBannerController 4.
- S1: RANDOM_PORT 실제 HTTP로 익명 KO/JP/기타 국가/헤더 누락, 5개 필드 전체, nullable 이미지·link, HTTPS 보존, 상대/http 이미지 CDN 접두사, 외부 link 보존을 확인했다. `/event` 항목과 상세를 완전 비교하고 DB 재조회로 이미지 필드가 변하지 않음을 확인했다.
- S2: 실제 H2 auth 기록이 있는/없는 일반 회원과 관리자는 같은 컨텍스트 MockMvc로 검증했다. KO/JA × adult null/false/true × 익명/미인증/인증/관리자 24조합의 상세와 기존 목록을 비교했다. 인증 성인은 false를 제외하고 관리자는 false/true/null을 허용한다. 비활성/미래/종료/언어 불일치는 관리자에게도 미존재와 동일한 실패 응답이다. 양수 검사·미존재는 HTTP 200 invalid_request 및 실제 SodaException messageKey, malformed/overflow는 기존 MethodArgumentTypeMismatchException과 공통 unknown 응답을 확인했다.
- 기간은 현재 시각에서 하루 이상 떨어진 값으로 테스트했다. 정확히 같은 시각의 포함 경계는 기존 주입 seam이 없어 시계를 새로 추상화하지 않고 코드 비교로 확인했다: legacy `startDate.loe(now)` / `endDate.goe(now)`, 신규는 `startDate > now` / `endDate < now`만 거부하여 양끝을 포함한다.
- S3: 실제 HTTP 보안 13건에서 익명 POST/PUT/DELETE, collection, 추가 하위 경로, legacy 팝업/쓰기, 관리자 목록/단건 경로 모두 401을 확인했다. 익명 기존 `/event` 목록은 계속 200이며 기존 목록/팝업/관리자 회귀도 통과했다.
- 검증 중 제한: 첫 GREEN은 테스트 소스 compile 중 600초 도구 제한, 첫 인접 회귀는 180초 제한으로 종료되어 통과 근거로 사용하지 않았다. 제한을 늘려 재실행했다. 중간 GREEN의 2건 실패는 MockMvc `contentAsString`의 기본 문자셋으로 한글을 잘못 해석한 테스트 문제였다. JSON은 원본 bytes로 파싱하고 로그만 명시적 UTF-8로 읽도록 수정했으며 기대값/정책 assertion은 완화하지 않았다.
- 진단: 이 세션에도 `lsp_diagnostics` 도구가 없어 미실행. Gradle의 production/test Kotlin compile은 성공했다. 변경 파일에서 compiler 경고는 없었고 기존 다른 파일의 불필요한 `!!`/unchecked cast/미사용 인자 및 Gradle deprecated 경고는 수정하지 않았다. 전체 suite, bootJar, ktlintCheck, tasks, 독립 리뷰는 부모 담당 P1-GATE로 남겨 실행하지 않았다.
- HTTP 원본 artifact: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`의 `system-out`에 상태/본문을 기록했다. HTML은 `build/reports/tests/test/classes/kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.html`. 다음 Gradle test가 덮어쓸 수 있어 핵심 응답을 아래 보존한다.
```text
HTTP GET /api/v2/events/22 country=null -> 200 {"success":true,"message":null,"data":{"id":22,"thumbnailImageUrl":"https://cdn.test/thumbnail.png","detailImageUrl":null,"popupImageUrl":null,"link":"https://external.test/promotion?from=soda&target=event#detail"},"errorProperty":null}
HTTP GET /api/v2/events/17 country=JP -> 200 {"success":true,"message":null,"data":{"id":17,"thumbnailImageUrl":"https://cdn.test/thumbnail.png","detailImageUrl":"https://cdn.test/detail.png","popupImageUrl":"https://cdn.test/popup.png","link":"https://external.test/event"},"errorProperty":null}
HTTP GET /api/v2/events/0 country=null -> 200 {"success":false,"message":"잘못된 요청입니다.","data":null,"errorProperty":null}
HTTP GET /api/v2/events/1/extra -> 401 {"timestamp":"2026-10-28T09:37:20.055+00:00","status":401,"error":"Unauthorized","path":"/api/v2/events/1/extra"}
HTTP GET /event/popup -> 401 {"timestamp":"2026-10-28T09:37:20.121+00:00","status":401,"error":"Unauthorized","path":"/event/popup"}
```
### P1-T1 Teardown Receipt — 2026-10-28
- 마지막 테스트 executor PID 18510 종료와 소유 root에 열린 파일 없음(`lsof -nP +D`)을 확인했다. mktemp root 소유 표식을 읽고 허용 parent의 canonical path·직접 하위 이름·비심볼릭링크 조건을 확인한 뒤 이 세션의 `event-detail-p1-t1.xH3EUF`만 삭제했다. macOS `/var`의 canonical path가 `/private/var`여서 첫 경로 가드는 삭제를 거부했고, 양쪽 parent를 동일하게 정규화한 후 삭제했다.
- 실제 반환: `TEARDOWN OK: owned event-detail-p1-t1.xH3EUF removed; test executor exited; no open files; shared caches untouched`. RED/GREEN의 핵심 결과와 HTTP 응답은 위에 보존했으며 Gradle XML/HTML은 worktree build 폴더에 남아 있다.
- P1-T1만 완료했다. P1-GATE 체크박스·기준·상단 실행 상태는 부모 세션 소유로 유지하며 커밋/푸시/worktree/다른 에이전트/전체 suite·build·review는 실행하지 않았다.
### P1-GATE 부모 검증 — 2026-10-28
- 부모가 새 controller/service와 전체 통합 테스트, SecurityConfig의 GET matcher 한 줄을 직접 검토했다. 기존 API 변경은 없다.
- `./gradlew bootJar --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 1m 9s.
- 최초 ktlintCheck는 새 테스트 CsvSource의 줄바꿈 3건을 지적했다. 데이터/기대값은 유지하고 각 인자를 별도 줄로 배치했다.
- `./gradlew ktlintCheck --console=plain --no-daemon`: BUILD SUCCESSFUL, 1m 33s. 전체 Kotlin main/test/script 검사 통과.
- 첫 전체 테스트는 사용자 중단으로 끝났으며 성공 근거에서 제외했다. 재개 후 `./gradlew test --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 19m 29s.
- 최종 XML 집계: 326 suites, 2,584 tests, failures=0, errors=0, skipped=0. 새 EventDetailControllerIntegrationTest 28건과 기존 event/admin 테스트도 포함한다.
- 실제 HTTP 표면은 RANDOM_PORT의 TestRestTemplate 호출이다. 부모 전체 테스트 실행에서도 성공/비노출/인접 보안 응답을 확인했다. 인증 사용자 행렬은 실제 DB fixture와 MockMvc로 검증했다.
- 최종 HTTP artifact: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml`, timestamp `2026-10-28T10:19:56`.
- 대표 응답: `/api/v2/events/22` → HTTP 200, success=true, id=22, thumbnailImageUrl=https://cdn.test/thumbnail.png, detailImageUrl=null, popupImageUrl=null, 외부 link 보존. `/api/v2/events/0` → HTTP 200, success=false, data=null. `/event/popup` 익명 → HTTP 401.
- `./gradlew tasks --all --console=plain`: BUILD SUCCESSFUL. 문서의 Gradle 명령 존재 확인.
- 이전 리뷰 작업은 완료 알림이 있었으나 중단 후 background_output/session_read 및 정확한 로컬 session ID 조회로 결과를 회수하지 못했다. 이를 승인으로 간주하지 않고 동일 범위의 대체 리뷰 하나만 요청했다.
- `lsp_diagnostics`는 부모/구현 세션 모두 도구 미제공으로 미실행. Kotlin 컴파일·ktlint 성공을 대체 증거로 기록한다. 운영 배포와 운영 DB 호출은 수행하지 않았다.
### P1-GATE 최종 판정 및 전달 — 2026-10-28
- `./gradlew bootJar ktlintCheck tasks --all --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: 최종 재확인 BUILD SUCCESSFUL, 17s. `git diff --check`도 통과했다.
- 독립 대체 리뷰: [reviews/event-detail-review.md](reviews/event-detail-review.md), APPROVE, 확정 발견 사항 없음. 부모가 리뷰 파일을 읽고 실제 코드·HTTP·전체 테스트 근거와 대조했다. 리뷰 환경은 임시 자원/프로세스 생성 0건이다.
- 클라이언트용 [api-client-prompt.md](api-client-prompt.md)를 실제 endpoint, 5개 응답 필드, nullable 값, HTTP 200 실패 envelope, JWT/언어·국가 처리와 대조했다. 기존 API와 관리자 범위는 그대로다.
- 부모 최종 `pgrep`에서 GradleWrapperMain/Gradle Test Executor/소유 root 관련 프로세스가 없었다. 부모 root는 빈 opencode 폴더만 남아 `rmdir`로 제거했다. 소유 notepad와 구현 root도 부재를 확인했다.
- 정리 read-back: `TEARDOWN OK: parent root, implementation root and notepad absent; shared caches untouched`. 공유 캐시나 다른 세션의 파일/프로세스는 삭제하지 않았다.
- 완료 범위: 승인 API·문서·시나리오 S1~S3·전체 테스트·빌드·린트·리뷰·소유 자원 정리. 제한: LSP 미제공으로 미실행, 기간 동일 시각 경계는 코드 대조, 인증 viewer는 MockMvc 검증. 운영 배포·운영 DB 검증·커밋·푸시는 하지 않았다.
### 커밋 전 재검증 — 2026-10-29
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.event.*' --tests 'kr.co.vividnext.sodalive.event.*' --tests 'kr.co.vividnext.sodalive.admin.event.banner.*' bootJar ktlintCheck --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 3m 55s. 테스트 5 suites / 46 tests / failures=0 / errors=0 / skipped=0. bootJar·ktlintCheck는 변경 없음으로 UP-TO-DATE다.
- `git diff --check` 및 구현·테스트의 `git diff --staged --check` 통과. 커밋 대상의 비밀값·credential artifact 포함 여부를 확인했다.
- 전체 회귀는 P1-GATE의 2,584건 성공 이후 구현 변경이 없어 재실행하지 않았다. 대신 신규 상세·기존 이벤트·관리자 배너 회귀를 실행했다.
## 종료 기준
승인 endpoint의 정상/경계/회귀가 실제 요청으로 검증되고, 테스트·빌드·사용 가능한 진단 및 독립 리뷰를 마치고 소유 QA 자원이 정리되면 종료한다. 실행 불가능하거나 기존 실패인 항목은 근거와 함께 명시하고 완료로 가장하지 않는다.
@@ -0,0 +1,54 @@
# 일반용 이벤트 상세 조회 PRD
## 문서 정보
- 작성일: 2026-10-28
- 상태: 구현 및 검증 완료 (운영 배포 제외)
- 결정권자: 사용자; 구현 담당: Sisyphus
- 구현 계획: [plan-task.md](plan-task.md)
- 클라이언트 전달 계약: [api-client-prompt.md](api-client-prompt.md)
- 최종 리뷰: [reviews/event-detail-review.md](reviews/event-detail-review.md), APPROVE
## 목표와 범위
이벤트 ID만으로 일반용 이벤트 정보를 조회할 수 있도록 `GET /api/v2/events/{id}`를 추가한다.
기존 목록과 동일한 노출 조건으로 상세 이미지와 외부 이동 링크를 반환한다.
관리자 상세 API, 기존 `/event` 계약 변경, 프론트엔드, DB 변경, 신규 의존성은 제외한다.
## 확정 요구사항
| ID | 요구사항 | 수용 기준 |
|---|---|---|
| EVT-001 | 비로그인 가능한 v2 단건 조회 | 정상 ID 요청은 HTTP 200, `success=true`, 단일 EventItem 반환 |
| EVT-002 | 기존 목록과 동일한 노출 정책 | 활성, 시작/종료 시각 포함 범위, 국가별 언어, 성인 인증 조건 일치 |
| EVT-003 | 기존 응답 필드 재사용 | id, thumbnailImageUrl, detailImageUrl, popupImageUrl, link 보존 |
| EVT-004 | 미존재와 비노출 동일 처리 | HTTP 200, `success=false`, `data=null`, 기존 invalid_request 메시지 |
| EVT-005 | 인접 API 보안 유지 | 익명 쓰기·하위 추가 경로·기존 팝업·관리자 요청은 계속 보호 |
## API 계약
- Method/path: `GET /api/v2/events/{id}`. ID는 양수 Long.
- 인증: 선택적 기존 JWT. 인증이 없으면 비성인 기준. 잘못된 JWT 처리는 기존 보안 정책 유지.
- 성공: `ApiResponse.ok(EventItem)`. 응답 필드는 기존 목록 항목과 동일하며 제목/기간 필드를 추가하지 않는다.
- 이미지: `https://`로 시작하면 그대로, 나머지 문자열은 기존 CloudFront 호스트와 `/`를 앞에 붙인다. nullable 이미지는 null 유지.
- `link`: 저장값 그대로 반환하고 서버에서 이동하거나 URL을 요청하지 않는다.
- 양수가 아닌 ID, 미존재, 비노출: `SodaException(messageKey = "common.error.invalid_request")`로 기존 HTTP 200 실패 envelope 사용.
- 숫자가 아니거나 Long 범위를 넘는 ID: 기존 Spring 바인딩과 공통 오류 처리 유지. 새 parser/handler 없음.
## 노출 정책
- `isActive == true`, `startDate <= now <= endDate`.
- `MemberContentPreferenceService.resolveCountryCode(member)` 결과가 JP면 JA, 그 외 KO.
- 관리자 역할은 성인 조건만 생략하며 활성/기간/언어는 생략하지 않는다.
- 비로그인 및 인증 기록 없는 일반 회원: 이벤트 `isAdult`가 null 또는 false.
- 인증 기록 있는 일반 회원: 이벤트 `isAdult`가 null 또는 true. 기존 목록처럼 false 이벤트는 제외한다.
- ID로 단건 조회하며 전체 목록을 가져와 검색하지 않는다.
## 검증 시나리오
| 시나리오 | 이진 성공 조건 | 실제 검증 표면 |
|---|---|---|
| S1 정상 | 익명 KO/JP, null/HTTPS/상대 이미지, 외부 link가 기존 항목과 동일 | RANDOM_PORT + TestRestTemplate 실제 HTTP 및 H2 |
| S2 경계/권한 | 미존재·비활성·예정·종료·언어 불일치·성인 제한에서 데이터 미노출; 일반/인증/관리자 정책 일치 | 실제 HTTP + 인증 MockMvc + 실제 DB |
| S3 회귀 | 기존 목록 응답 유지, 익명 팝업/관리자/POST·PUT·DELETE·추가 하위 경로 거부 | 실제 HTTP, 기존 event/admin 통합 테스트 |
## Decision Log
- 사용자 확정: 관리자 상세 불필요, 일반 상세만 추가; v2 경로 `/api/v2/events/{id}` 승인; 나머지 기존 정책 적용.
- 구현 선택: 기존 EventItem/엔티티/repository 재사용, v2 controller와 read-only service만 추가. 단건 로드 후 정책 확인으로 불필요한 port/DTO 계층을 만들지 않는다.
- 구현 선택: 미조회 오류는 기존 이벤트 수정/삭제와 공통 예외 관례를 따른다. 별도 HTTP 404 계약을 만들지 않는다.
- 사용자의 ulw 지시에 따라 실제 HTTP 증거, 전체 테스트/빌드와 독립 리뷰를 수행한다. 런타임/도구 제한은 숨기지 않는다.
- 후속 사용자 요청: 최종 결과에 클라이언트 개발자가 사용할 API 계약을 프롬프트 형태로 제공한다. 구현된 서버 계약만 명시하고 새로운 화면/서버 기능을 추가하지 않는다.
@@ -0,0 +1,62 @@
# 일반 이벤트 상세 조회 최종 코드 리뷰
**판정: APPROVE. 확정 발견 사항 없음.**
## 1. 리뷰 정보와 범위
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | P1-T1 구현 및 P1-GATE의 코드·검증 증거 |
| 기준 상태 | 현재 working tree의 신규 파일 직접 열람 및 SecurityConfig의 GET matcher |
| 리뷰 일자 / 리뷰어 | 2026-10-28 / Sisyphus-Junior |
| 기준 문서 | [prd.md](../prd.md) EVT-001~005, [plan-task.md](../plan-task.md) P1-T1·P1-GATE |
| 리뷰 상태 | 판정 완료. 호스트 중단 전 리뷰의 판정은 승계하지 않음 |
변경 내용: 익명 접근 가능한 `GET /api/v2/events/{id}`가 기존 목록의 노출 정책을 적용해 단일 `EventItem`을 반환한다. 관리자 상세나 추가 응답 필드는 만들지 않는다.
신규 controller/service/test, 보안 matcher와 공통 인증·예외 경로, 기존 event Service/Repository/DTO 및 인접 회귀 결과를 검토했다. 제품·테스트·계획 문서 수정, 테스트·빌드 재실행, 별도 HTTP 실행, 에이전트·커밋은 제외했다. 다중 사용자 요청을 전제로 단건 조회와 상태 변경 여부를 확인했으며 부하 측정은 하지 않았다.
## 2. 요구사항 대조
| 기준 | 판정 및 코드·테스트 근거 |
|---|---|
| EVT-001: 일반용 단건 GET만 추가 | [EventDetailController.kt:12](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/adapter/in/web/EventDetailController.kt:12)의 단일 GET mapping, nullable principal, `ApiResponse.ok` 확인. 관리자 endpoint는 추가하지 않음. [EventDetailService.kt:25](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt:25)는 read-only이며 ID로 조회하고 전체 목록을 검색하지 않음. |
| EVT-002: 활성·기간·언어 | [EventDetailService.kt:31](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt:31)의 `startDate > now` / `endDate < now` 거부는 [EventRepository.kt:21](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt:21)의 `loe` / `goe`와 동일하게 양끝을 포함함. [EventService.kt:92](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt:92)와 같은 country resolver로 JP→JA, 그 외→KO. 활성·기간·언어 검사는 관리자 분기보다 먼저 수행됨. |
| EVT-002: 성인 3상태 및 관리자 우회 | [EventDetailService.kt:38](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt:38)는 실제 auth 기록으로 판단함. 익명·미인증은 null/false, 인증 일반 회원은 null/true만 허용하므로 인증 성인에게 false를 노출하지 않음. 관리자는 성인 조건만 생략함. [EventService.kt:56](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt:56)와 [EventRepository.kt:26](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt:26)에 일치. 신규 테스트의 KO/JA × 3상태 × 4종 viewer 비교 및 관리자 비노출 검증 확인. |
| EVT-003: 정확히 5개 필드·이미지·link | [EventDetailService.kt:45](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/event/application/EventDetailService.kt:45)는 [GetEventResponse.kt:13](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/event/GetEventResponse.kt:13)의 `id`, `thumbnailImageUrl`, `detailImageUrl`, `popupImageUrl`, `link`만 반환함. HTTPS 보존, 그 외 CDN 접두사는 기존 Service와 같고 nullable 이미지·link를 보존함. 외부 link에 HTTP 요청·리다이렉트하지 않으며 엔티티를 수정하지 않음. |
| EVT-004·005: 실패 envelope·보안 경계 | 양수 아님·미존재·비노출은 같은 `SodaException(common.error.invalid_request)`를 사용하며 [SodaExceptionHandler.kt:26](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/common/SodaExceptionHandler.kt:26)의 HTTP 200 실패 envelope 유지. 비숫자·Long 초과는 기존 바인딩·공통 오류 처리 유지. [SecurityConfig.kt:191](/Users/klaus/Develop/sodalive/Server/sodalive_/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt:191)는 GET과 단일 segment `*`만 허용하며 쓰기·collection·추가 하위 경로·팝업·관리자 경로는 기존 인증 규칙 적용. JWT 필터를 생략하는 설정이 아니므로 기존 잘못된 JWT 처리 경로도 유지됨. |
핵심 테스트: [EventDetailControllerIntegrationTest.kt:74](/Users/klaus/Develop/sodalive/Server/sodalive_/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/event/EventDetailControllerIntegrationTest.kt:74)의 응답 완전 비교·DB 재조회, `:165`의 성인 행렬, `:212`의 관리자 비노출, `:244`의 ID 오류, `:271`의 인접 보안 검증을 읽었다. RANDOM_PORT/TestRestTemplate은 실제 HTTP, 인증 MockMvc는 동일 컨텍스트와 실제 H2 member/auth/event fixture를 사용한다. 서비스·repository·인증 기록을 mock으로 대체하지 않으며 S3Uploader만 mock이다.
## 3. 검토한 실행 증거
아래는 기존 실행 결과의 확인이며 이 리뷰에서 실행한 테스트가 아니다.
| 출처 | 결과 및 확인 범위 |
|---|---|
| [신규 suite XML:2](/Users/klaus/Develop/sodalive/Server/sodalive_/build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.event.EventDetailControllerIntegrationTest.xml:2) 직접 열람 | 2026-10-28T10:19:56, 28 tests, failures/errors/skipped 모두 0. testcase 목록과 실제 HTTP·MockMvc `system-out` 확인. |
| 같은 XML의 실제 응답 직접 열람 | `:10378` 익명 200·정확한 5필드·nullable 이미지·외부 link 보존. `:6484`, `:7407` 인증 일반 회원의 false 이벤트 거부. `:13745` 이후 관리자 비활성·미래·종료·언어 불일치 모두 동일 실패. `:10026`~`:10326`의 인접 보안 13건 모두 401. `:4568` 양수 아닌 ID도 HTTP 200, `success=false`, `data=null`, 기존 메시지 확인. |
| 기존 회귀 XML 직접 열람 | `EventControllerIntegrationTest` 5, `EventRepositoryTest` 5, `EventServiceTest` 4, `AdminEventBannerControllerIntegrationTest` 4건. 각 `build/test-results/test/TEST-kr.co.vividnext.sodalive.<클래스 전체 이름>.xml`의 testsuite/testcase를 확인했으며 합계 18건 모두 failures/errors/skipped 0. |
| 부모 세션 제공 전체 suite 증거 | `./gradlew test --console=plain --no-daemon -Pkotlin.compiler.execution.strategy=in-process`: BUILD SUCCESSFUL, 19m 29s, 326 suites / 2584 tests / failures/errors/skips 0. 전체 집계는 부모 제공 증거이며 이 리뷰에서 재집계하지 않음. |
| 부모 세션 제공 빌드·린트 증거 | `bootJar` 성공, `ktlintCheck`는 CsvSource 줄바꿈 수정 후 성공. Kotlin production/test compile 성공을 대체 진단 증거로 사용하며 LSP 성공으로 표현하지 않음. |
## 4. 발견 사항과 검증 한계
**확정 발견 사항 없음.** 승인된 EVT-001~005를 위반하는 구체 사례나 차단할 회귀를 발견하지 못했다. 회귀 수정 Task/goal 전환 항목 없음.
- 정확히 같은 시각의 기간 경계는 고정 시계 실행 테스트가 아니라 기존 QueryDSL 포함 비교와 신규 거부 조건의 코드 대조로 확인했다. 계획에서 허용한 검증 방식이며 현재 비교 연산에 결함은 없다.
- 인증 viewer는 JWT 발급·전송을 포함한 별도 실제 HTTP가 아니라 인증 MockMvc와 실제 DB fixture로 검증됐다. 이는 승인된 S2 검증 표면이며 기존 JWT 필터 경로는 코드로 확인했다.
- `lsp_diagnostics`는 부모와 이 리뷰 환경에 도구가 없어 미실행이다. 이는 진단 도구의 제한이지 확인된 코드 결함이 아니다. 테스트·빌드·린트 성공은 위에 출처를 구분해 기록했으며 재실행하지 않았다.
## 5. 리뷰 종료 판정과 정리
| 항목 | 결과 |
|---|---|
| 승인 요구사항·구현·보안·테스트 범위 확인 | 충족 |
| 발견 사항 판정 / plan 전환 | 확정 발견 사항 없음 / 해당 없음 |
| 실행 증거와 미검증 부분 구분 | 충족 |
| 최종 결론 | **APPROVE**. 이 구현의 반영을 차단할 항목 없음 |
이 판정은 구현과 제공된 증거에 대한 승인이다. 부모 소유의 P1-GATE 체크박스·상단 상태나 부모 QA 자원의 최종 정리 상태를 대신 갱신·인증하지 않는다.
**Teardown Receipt:** 이 리뷰에서 shell·테스트·빌드·서버·에이전트를 실행하지 않았고 소유 임시 root/프로세스를 생성하지 않았다. 정리 대상 0, 남은 리뷰 소유 자원 0. 공유 캐시 삭제 없음. 저장소 변경은 이 리뷰 파일뿐이다.
@@ -0,0 +1,65 @@
# 최근 공지사항 v2 API 계약
## 1. 요청
| 항목 | 계약 |
|---|---|
| Method / Path | `GET /api/v2/notices/latest` |
| 접근 | 인증 없이 허용, Authorization 필수 아님 |
| 입력 | path 변수·query·body 없음 |
| 성공 | HTTP 200, JSON `ApiResponse<List<NoticeItem>>` |
| 페이지네이션 | 없음. 항상 최대 3개, page/size/total/hasNext 없음 |
## 2. 데이터 규칙
`isActive=true`인 공지만 id 내림차순으로 최대 3개 반환한다. createdAt 최신순이 아니다. 기존 `NoticeItem`과 목록 쿼리를 재사용하며 아래 필드는 변경하지 않는다.
| 필드 | 타입 | 의미 |
|---|---|---|
| id | JSON number / Kotlin Long | 기존 공지 식별자 |
| title | string | 원래 제목 전체 |
| content | string | 원래 본문 전체, 축약·변환하지 않음 |
| date | string | 기존 UTC createdAt을 Asia/Seoul로 변환한 `yyyy-MM-dd` |
UTC `2026-10-27 15:00:00`은 `2026-10-28`이다. 날짜 formatter·timezone·필드 이름·타입을 새로 정의하지 않는다.
## 3. 성공 envelope와 예시
공통 `ApiResponse`는 success/message/data/errorProperty 네 필드를 모두 갖는다. 성공의 `success=true`, data는 null이 아닌 0~3개 배열이며 `message:null`, `errorProperty:null`이 생략되지 않고 포함된다. 2026-10-28 focused 실제 HTTP 테스트 11개에서 전체 JSON tree 비교로 확인했다. 전역 Jackson 설정은 변경하지 않는다.
```json
{
"success": true,
"message": null,
"data": [
{"id": 104, "title": "서비스 안내", "content": "공지 본문 전체\n두 번째 줄", "date": "2026-10-28"},
{"id": 103, "title": "점검 안내", "content": "점검 안내 전체", "date": "2026-10-27"},
{"id": 102, "title": "업데이트 안내", "content": "업데이트 안내 전체", "date": "2026-10-26"}
],
"errorProperty": null
}
```
활성 공지가 없으면 (전부 비활성인 경우 포함):
```json
{"success":true,"message":null,"data":[],"errorProperty":null}
```
활성 공지가 1개 또는 2개면 실제 개수만 반환하며 채우거나 반복하지 않는다. 예시 id는 설명용이며 고정 데이터나 실제 배포 응답이 아니다.
## 4. 호환성과 오류
- 기존 `GET /notice/latest`는 기존 단일 객체 또는 `data:null` 계약을 유지한다. 홈 응답도 그대로다.
- 새 permitAll은 정확한 GET 경로 하나에만 적용한다. 실제 HTTP 검증에서 익명 `POST /api/v2/notices/latest`와 `GET /api/v2/notices/private`는 HTTP 401이었다. 기존 보안 오류 응답은 아래와 같이 성공 envelope와 다르다. timestamp 값은 요청마다 달라진다.
```json
{"timestamp":"2026-10-28T06:28:16.011+00:00","status":401,"error":"Unauthorized","path":"/api/v2/notices/latest"}
```
- 기존 공통 오류 처리의 `ApiResponse.error`는 `success=false`다. 일부 공통 예외는 HTTP 200에도 오류 envelope가 오므로 클라이언트는 HTTP 상태와 success를 함께 확인한다. HTTP/보안 오류가 모두 같은 envelope라고 가정하지 않는다.
- 조회 실패를 정상 `[]`로 바꾸지 않는다. 새 오류 코드·status·전역 예외 정책·자동 fallback은 추가하지 않는다.
## 5. 제공 상태
구현 및 focused 실제 HTTP 검증 완료(공지 11개와 기존 배포 엔드포인트 4개, 총 15 tests, failures/errors/skipped 모두 0). `bootJar`와 전체 `ktlintCheck`는 통과했으나 전체 `build --continue`는 test 단계에서 20분 제한으로 종료되어 최종 Gate는 미완료다. 실응답·명령 결과와 독립 리뷰 상태는 [plan-task.md](plan-task.md)에 기록했다. 운영 배포는 수행하지 않았으며 배포 완료로 간주하지 않는다.
@@ -0,0 +1,22 @@
# 클라이언트 전달용 프롬프트
아래 내용을 클라이언트 작업자에게 전달한다. 서버 코드 구현·로컬 검증과 실제 배포는 별개다.
```text
기존 공지 UI, 레이아웃, 스타일, 진입점, 상세 이동과 내비게이션을 유지하면서 최근 공지 데이터만 아래 새 계약으로 연결해 주세요. UI 재설계나 캐러셀 추가는 하지 마세요.
API: 익명 GET /api/v2/notices/latest
요청 body/query와 인증 필수 요구는 없습니다.
응답: ApiResponse<List<NoticeItem>>
NoticeItem: id(number), title(string), content(string), date(string, yyyy-MM-dd).
data는 활성 공지 중 id 내림차순 최대 3개입니다. 순서를 다시 정렬하지 말고 서버 순서를 사용하세요. date는 이미 기존 UTC→Asia/Seoul 규칙으로 포맷된 문자열이므로 다시 timezone 변환하지 마세요. title/content는 전체 문자열을 유지하세요.
활성 공지가 없으면 data:[]이며 null이 아닙니다. 1/2개면 해당 개수만 표시하고 3개를 채우려고 중복하지 마세요. 기존 단일 공지 v1과 홈 응답 스키마는 변경되지 않습니다.
서버 배포가 확인되기 전에는 개발 환경의 명시적 mock 모드로만 작업하세요. 이 서버 작업은 운영 배포를 수행하지 않습니다. 실제 404/오류를 mock이나 v1로 자동 fallback하지 말고, 배포 후 명시적으로 API 연결을 전환하세요. production mock은 금지합니다.
공통 envelope의 success를 HTTP 상태와 함께 검사하세요. 성공 응답에는 message:null과 errorProperty:null이 생략 없이 포함됩니다(실제 HTTP 검증 완료). 기존 정책상 HTTP 200에도 success:false가 올 수 있습니다. 조회 실패를 정상 빈 목록으로 숨기지 말고 기존 화면의 오류 처리와 로딩 상태를 유지하세요. 보안/HTTP 오류가 모두 동일한 envelope라고 가정하지 마세요.
0/1/2/3개 fixture를 각각 테스트하세요. 0개일 때 기존 빈 상태, 1/2개일 때 중복 없음, 3개일 때 id 역순 유지, 상세 이동 시 동일 id/전체 본문 보존을 확인하세요. 긴 제목·여러 줄 본문·KST 날짜 경계도 기존 UI 안에서 확인하세요.
범위 밖: 캐러셀, 자동 fallback, 캐싱, 페이지네이션, 홈 응답 변경, 기존 내비게이션 변경.
계약: docs/20261028_최근공지사항_v2/api-contract.md
```
@@ -0,0 +1,165 @@
# 최근 공지사항 v2 구현 계획
> 실행 방식: 승인된 설계를 기존 작업 디렉터리에서 TDD로 구현하고 독립 검토한다. 새 worktree·commit·배포는 수행하지 않는다. 전체 테스트는 최종 Gate에서 실행한다.
| 항목 | 내용 |
|---|---|
| 작성일 | 2026-10-28 |
| 상태 | P1-T1 완료, P1-GATE 전체 회귀 시간 초과로 미완료, 미배포 |
| 요구사항 | [prd.md](prd.md) |
| API 기준 | [api-contract.md](api-contract.md) |
| 현재 Phase / 활성 Goal | Phase 1 / P1-GATE 전체 회귀 결과 확보 필요 |
**Goal:** 기존 v1·홈을 유지하면서 누구나 최근 활성 공지를 최대 3개 조회한다.
**Architecture:** 새 web controller → 읽기 전용 application service → 기존 repository 목록 쿼리. 쿼리·DTO·공통 JSON 정책은 유지한다.
**Tech Stack:** Kotlin 1.6.21, Java 17, Spring Boot 2.7.14, JUnit 5, H2, embedded Redis, TestRestTemplate.
## 전역 제약과 리뷰 초점
- 새 GET 한 경로만 permitAll로 허용한다. 주변 경로나 다른 메서드를 공개하지 않는다.
- `NoticeItem(id: Long, title: String, content: String, date: String)` 및 UTC→Asia/Seoul `yyyy-MM-dd`를 보존한다.
- createdAt이 id 순서와 반대여도 id로 정렬하며 비활성 최신 id를 제외한다.
- 빈 배열, 1/2개 배열, 전체 본문, KST 자정, v1 객체/null을 HTTP로 검증한다.
- 외부 서비스·운영 앱·스키마·의존성·UI·캐시·신규 추상화는 범위 밖이다.
### Phase 1: 최근 공지 v2 수직 슬라이스
- [x] **Task 1.1: P1-T1 실제 HTTP 조회 구현**
**Objective:** 익명 GET의 배열 계약과 기존 호환성을 실제 HTTP/DB 왕복으로 검증한다.
**시작 조건:** 사용자 승인 설계, PRD와 이 계획 작성 완료.
**완료 증거:** 의도한 RED, focused GREEN, 실응답 관찰, 계약과 검증 기록, 소유 임시 리소스 정리.
**범위 밖:** 전체 테스트·빌드·독립 리뷰는 P1-GATE 소유이며 배포는 전체 작업 범위 밖이다.
**Files:**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`의 기존 notice 규칙 바로 옆 GET matcher 한 줄
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt`
- Docs: 이 폴더의 `prd.md`, `plan-task.md`, `api-contract.md`, `client-prompt.md`
**Interfaces:**
- Consumes: `ServiceServiceNoticeRepository.getNoticeList(pageable: Pageable): List<NoticeItem>`
- Produces: `LatestNoticeService.getLatestNotices(): List<NoticeItem>`는 `PageRequest.of(0, 3)` 사용
- HTTP: `LatestNoticeController.getLatestNotices()`는 `ApiResponse.ok(service.getLatestNotices())` 반환
- [x] **RED:** RANDOM_PORT/TestRestTemplate/실제 repository·SecurityConfig, 고유 H2, 기존 날짜 alias, EmbeddedRedisInitializer, AFTER_CLASS DirtiesContext로 테스트 작성. 아직 없는 controller 타입은 참조하지 않는다.
- [x] **RED 확인:** 아래 focused 명령으로 미허용·미구현 GET의 `expected 200, actual 401` 같은 HTTP assertion 실패를 확인하고 기록한다. 컴파일/인프라 실패는 RED로 세지 않는다.
- [x] **GREEN:** production 2개 파일과 정확한 GET matcher 한 줄만 추가한다.
- [x] **GREEN 확인:** 동일 focused 명령으로 4+활성·비활성·id 정렬·본문·KST 경계·0/1/2·v1·권한 테스트가 통과하는지 확인한다.
- [x] **REFACTOR:** 불필요한 정리 없이 focused 회귀와 diff 점검, HTTP 출력 및 문서 정합성 기록을 남긴다.
```bash
./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest
./gradlew --no-daemon tasks --all
git diff --check
```
**Expected:** focused 테스트 실패 0, HTTP 200 + data 배열, 익명 POST/인접 경로 401, v1 객체/null, 명령 유효성 검증 exit 0.
#### Phase 1 Gate: P1-GATE
**Objective:** 최종 전체 회귀·빌드·독립 리뷰로 전달 가능 상태를 판정한다.
**시작 조건:** P1-T1 증거와 리소스 정리 완료.
**완료 증거:** 전체 테스트·빌드 exit 0, 독립 리뷰 결과와 잔여 위험 기록.
**범위 밖:** 실패를 숨기는 test 삭제·완화, 무관한 수정·배포.
- [ ] **[blocked] 전체 회귀 완료:** `./gradlew --no-daemon build --continue`의 전체 `test`가 20분 제한으로 종료됐다. 충분한 실행 시간이 확보된 환경에서 전체 테스트 exit 0을 확인하기 전 Gate는 완료하지 않는다.
- [x] `./gradlew --no-daemon bootJar ktlintCheck`로 패키징과 전체 lint를 검증한다.
- [x] 공지 HTTP 11개와 기존 배포 엔드포인트 HTTP 4개를 함께 재실행해 15개 통과를 확인한다.
- [x] [독립 리뷰](reviews/implementation-review.md)의 규격 적합·코드/테스트 품질 승인과 확정 발견 사항 없음을 기록한다. 이는 전체 회귀 Gate 통과나 배포 승인이 아니다.
- [x] 미배포임을 명시한다. 배포 자체는 작업 범위 밖이다.
## 실행 자원과 정리 의무
- 소유 임시 root: `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/notice-v2-impl.thzdr5`
- 모든 shell은 `TMPDIR`를 위 root로, `NODE_COMPILE_CACHE=$TMPDIR/node-compile-cache`, `BUN_RUNTIME_TRANSPILER_CACHE_PATH=$TMPDIR/bun-transpiler`로 export한다.
- Gradle은 `--no-daemon`을 사용한다. 테스트 context는 AFTER_CLASS에 닫고 embedded Redis는 기존 initializer의 JVM 종료 hook으로 종료한다.
- [x] 보고 전 이 실행의 소유 프로세스가 종료됐는지 확인하고 위 root만 삭제한다. 공유 캐시·다른 실행자의 파일과 프로세스는 건드리지 않는다.
- LSP 도구는 현재 host에 없어 실행할 수 없다. focused Kotlin 컴파일 결과를 기록하되 LSP 검증으로 표현하지 않는다.
## Progress
### P1-T1 시작: 2026-10-28
- `test` 브랜치 초기 `git status --short --branch`: 변경 없음.
- 루트 AGENTS, 필수 agent guides, PRD/계획 샘플, TDD `writing-good-tests.md` 및 기존 쿼리·v1·홈·HTTP 테스트를 확인했다.
- 첫 baseline: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest`는 120초 tool timeout으로 종료. 테스트 결과 XML이 없어 성공/RED로 판정하지 않는다. 원인 관찰을 위해 `--info`와 600초 제한으로 재시도한다.
- 이 단계에서 production 코드 변경은 없다.
### P1-T1 baseline 및 RED 작성: 2026-10-28
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest --info`: exit 0, 4 tests / 0 failures / 0 errors, 2m33s. 기존 HTTP/보안 테스트 기반 동작 확인.
- 관찰: 기존 translation/charge 스케줄러가 H2에서 `for update skip locked` 문법 오류를 로그로 남긴다. baseline에서도 발생했으며 이 범위에서 수정하지 않는다.
- fixture는 실제 repository로 저장·flush한 뒤 별도 JDBC 갱신으로 createdAt을 고정한다. BaseEntity의 PrePersist가 현재 시각으로 덮어쓰므로 이 순서가 필요하다. 서버의 조회는 별도 HTTP thread/DB 왕복이다.
- 전체 JSON tree를 literal 기대값과 비교해 null 필드 포함 여부·필드 이름·본문·날짜까지 확인한다. 생성된 id만 실제 저장 결과를 사용한다.
### P1-T1 RED 확인: 2026-10-28
- production 코드 추가 전 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest` 실행: exit 1, 3m42s, 11 tests / 7 failures / 0 errors / 0 skipped.
- 7개 v2 GET 검증이 모두 `expected: <200 OK> but was: <401 UNAUTHORIZED>` HTTP assertion으로 실패했다. Kotlin 컴파일과 로컬 H2/Redis/서버 초기화는 성공했다.
- 기존 v1 객체/null 및 익명 POST/인접 경로 거부의 나머지 4개 검증은 통과했다.
- RED 실응답: `HTTP GET /api/v2/notices/latest -> 401 {"timestamp":"2026-10-28T06:18:18.170+00:00","status":401,"error":"Unauthorized","path":"/api/v2/notices/latest"}`.
- 당시 XML: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest.xml`. 다음 Gradle test 실행이 덮어쓰므로 실패 수와 핵심 응답을 이 문서에 보존한다.
### P1-T1 GREEN 및 HTTP 응답 관찰: 2026-10-28
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest`: exit 0, 9m11s, 11 tests / 0 failures / 0 errors / 0 skipped. production/test Kotlin 컴파일도 실행·성공했다. 이 결과는 전체 `build` 성공을 뜻하지 않는다.
- 실제 HTTP 출력은 테스트의 `println`과 XML `<system-out>`에 남긴다. 확인한 artifact: `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest.xml`, HTML: `build/reports/tests/test/index.html`. 다음 테스트 실행으로 덮어쓸 수 있어 아래 핵심 응답을 보존한다.
- 익명 GET의 3개 응답은 id 12/11/10이며 date는 2026-10-28/2026-10-28/2026-10-29여서 createdAt 최신순이 아님을 확인했다. 더 큰 비활성 id는 제외됐다. HTML 모양 텍스트·인용부호·줄바꿈을 포함한 본문도 전체 일치했다.
- UTC 14:59:59/15:00:00의 KST 날짜, 0/1/2개, 전부 비활성, v1 객체/null, 익명 POST/인접 GET의 401을 모두 실서버 응답으로 확인했다. message/errorProperty의 명시적 null 포함도 JSON tree 완전 비교로 확정했다.
- 코드 정리로 바꿀 중복이 없어 REFACTOR production 수정은 하지 않았다. `git diff`에서 기존 notice/home 변경은 없고 SecurityConfig는 GET matcher 한 줄뿐이다.
```text
HTTP GET /api/v2/notices/latest -> 200 {"success":true,"message":null,"data":[{"id":12,"title":"최근 서비스 공지","content":"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"","date":"2026-10-28"},{"id":11,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"},{"id":10,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"}],"errorProperty":null}
HTTP GET /api/v2/notices/latest -> 200 {"success":true,"message":null,"data":[],"errorProperty":null}
HTTP GET /notice/latest -> 200 {"success":true,"message":null,"data":null,"errorProperty":null}
HTTP POST /api/v2/notices/latest -> 401 {"timestamp":"2026-10-28T06:28:16.011+00:00","status":401,"error":"Unauthorized","path":"/api/v2/notices/latest"}
HTTP GET /api/v2/notices/private -> 401 {"timestamp":"2026-10-28T06:28:16.112+00:00","status":401,"error":"Unauthorized","path":"/api/v2/notices/private"}
```
## 검증 기록
전체 테스트·빌드·독립 리뷰는 사용자 지정 역할 분담에 따라 이 실행에서 생략하고 P1-GATE에 남긴다. 테스트 클래스 추가에 대한 단일 실행 예시는 위 focused 명령을 사용한다.
- `./gradlew --no-daemon tasks --all`: exit 0, 31s. 문서의 `test`/`build`/관련 Gradle 명령이 등록됐음을 확인했다.
- `git diff --check`: exit 0. 신규 파일은 read 도구로 확인하고 변경 범위 목록을 `git status --short --untracked-files=all`로 점검했다.
- PRD의 NOTICE-001~005는 모두 P1-T1의 HTTP 검증으로 연결된다. v1/home는 코드 diff가 없고 v1은 HTTP 회귀도 확인했다. 홈 전체 응답을 호출하는 별도 회귀는 이 실행에서 수행하지 않았다.
- LSP 진단 도구 미제공, lint·전체 테스트·전체 빌드·독립 리뷰·운영 MySQL·배포 검증은 미실행이다.
- 기존 관찰 사항: H2에서 translation/charge 스케줄러의 `skip locked` 오류 로그가 GREEN에도 남는다(테스트 실패는 0). 기존 다른 테스트의 불필요 `!!`/unchecked cast 컴파일 경고, Netty macOS resolver 및 Gradle deprecation 경고도 이 범위에서 수정하지 않았다.
### P1-T1 정리 영수증 및 인계: 2026-10-28
- GREEN XML에서 Tomcat 종료와 HikariPool shutdown completed를 확인했다. Gradle test JVM도 종료됐고 프로세스 목록에 이 실행의 test worker, Redis, Kotlin compile daemon, Gradle 8.1.1 daemon은 남아 있지 않다.
- 별도 실행자의 기존 Gradle 8.14.3 daemon(pid 49992)은 소유 대상이 아니므로 종료하지 않았다.
- 소유 root가 빈 디렉터리임을 확인하고 `rmdir "$TMPDIR" && test ! -e "$TMPDIR"` 실행: exit 0. 삭제한 root는 위 `notice-v2-impl.thzdr5` 하나뿐이며 공유 캐시는 변경하지 않았다.
- P1-T1은 focused 증거와 자원 정리까지 완료했다. 전체 테스트·빌드·독립 리뷰와 운영 배포 판단은 부모 실행자의 P1-GATE로 남긴다.
### P1-GATE 실행: 2026-10-28
- `./gradlew --no-daemon build --continue`: `bootJar`/`assemble` 완료 후 전체 `:test` 단계에서 도구의 1,200,000ms 제한으로 종료됐다. 전체 테스트 수·성공 여부를 판정할 최종 결과는 없으며 전체 build 성공으로 표시하지 않는다. 원인이 특정 테스트 실패인지 단순 소요 시간인지 이 실행으로 확정하지 못했다.
- `./gradlew --no-daemon bootJar ktlintCheck`: exit 0, `BUILD SUCCESSFUL in 59s`. 산출물은 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`이며 main/test/script 전체 ktlint를 통과했다.
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest --tests kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest`: exit 0, `BUILD SUCCESSFUL in 3m 29s`. 두 XML에서 11+4=15 tests, failures/errors/skipped 모두 0을 확인했다.
- 최신 공지 HTTP XML timestamp는 `2026-10-28T06:57:06`, 기존 배포 HTTP XML timestamp는 `2026-10-28T06:56:23`이다. `TestRestTemplate`가 RANDOM_PORT 실서버에 요청했으며, 공지 응답의 id 12/11/10 순서·전체 필드·빈 배열·KST 날짜와 v1 객체/null, 익명 POST/인접 GET 401을 출력 및 전체 JSON 비교로 재검증했다.
- XML에서 세 테스트 서버의 `Stopping service [Tomcat]`를 확인했다. 최종 소유 자원 부재 확인은 아래 정리 기록에 남긴다.
- LSP 도구는 제공되지 않아 실행하지 못했다. Kotlin 컴파일·ktlint 성공을 LSP 실행으로 대신 표기하지 않는다. 운영 MySQL·배포·클라이언트 앱 검증은 범위 밖이다.
- 최초 독립 리뷰는 완료 알림 후 결과 조회 오류와 세션 미발견으로 판정 내용을 복구하지 못했다. 근거 없는 승인으로 처리하지 않고, 대체 독립 리뷰를 파일로 기록하도록 요청했다.
- 대체 독립 리뷰 결과: 규격 적합, 코드/테스트 품질 승인, 확정 발견 사항 없음. [implementation-review.md](reviews/implementation-review.md)에 검토 근거와 한계를 기록했다. 이후 production/test 변경은 없으며 위 15개 HTTP 재실행 결과를 이 계획에 보완했다.
- 최종 `git diff --check`: exit 0. 전체 회귀 제한을 숨기기 위한 테스트 변경·삭제·skip은 없다. commit/push/worktree 생성/배포는 수행하지 않았다.
### P1-GATE 소유 자원 정리: 2026-10-28
- 최신 HTTP XML에서 Tomcat 종료를 확인했고, 실행 후 프로세스 목록에 이 작업의 Gradle/test worker/Redis는 남아 있지 않았다. 별도 실행자의 기존 Java 프로세스는 종료하지 않았다.
- 빈 하위 `opencode/`와 검증 root `notice-v2.xbE00p`를 `rmdir`로 삭제했다. `test ! -e`로 검증 root, 구현 root `notice-v2-impl.thzdr5`, 작업 노트의 부재를 확인했다(exit 0).
- 승인된 임시 상위 디렉터리를 다시 읽어 `notice-v2` 실행/리뷰 root가 남아 있지 않음을 확인했다. 공유 캐시와 다른 작업의 디렉터리는 보존했다. 대체 리뷰어는 shell/임시 자원을 생성하지 않았다.
- 최종 상태: 구현과 로컬 HTTP 15개·패키징·lint·독립 코드 리뷰는 완료. **전체 회귀는 시간 초과로 미검증이며 P1-GATE는 보류**다. 재개 시 이미 완료한 구현을 반복하지 말고 충분한 실행 시간의 환경에서 전체 테스트 종료 결과를 확보한다.
@@ -0,0 +1,48 @@
# 최근 공지사항 v2 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 상태 | 구현·HTTP 15개·패키징·lint 검증 완료, 전체 회귀 시간 초과로 최종 Gate 미완료, 미배포 |
| 작성일 | 2026-10-28 |
| 결정권자 | 요청 사용자 |
| 계약 | [api-contract.md](api-contract.md) |
| 구현 계획 | [plan-task.md](plan-task.md) |
| 클라이언트 전달 | [client-prompt.md](client-prompt.md) |
## 목표와 문제
기존 `/notice/latest`는 공지 하나 또는 null을 반환한다. 이 계약과 홈 응답을 유지하면서 클라이언트가 최근 활성 공지 최대 3개를 익명으로 조회할 수 있는 별도 API를 제공한다. 최신은 작성 시각이 아니라 기존 공지 정책인 id 내림차순이다.
## 확정 요구사항과 수용 기준
| ID | 요구사항 | 수용 기준 | 추적 |
|---|---|---|---|
| NOTICE-001 | 익명 `GET /api/v2/notices/latest` | HTTP 200, `ApiResponse<List<NoticeItem>>` | 계약 §1, P1-T1 |
| NOTICE-002 | 활성 공지 중 id 내림차순 최대 3개 | 활성 4개 이상과 더 큰 비활성 id fixture에서 정확한 3개 반환, createdAt 정렬 금지 | 계약 §2, P1-T1 |
| NOTICE-003 | 기존 필드와 날짜 보존 | id/title/content/date 전체 일치, 본문 생략 없음, UTC→Asia/Seoul `yyyy-MM-dd` 날짜 경계 검증 | 계약 §2, P1-T1 |
| NOTICE-004 | 적은 데이터도 배열 | 0/1/2개 및 전부 비활성 시 각각 0/1/2개와 빈 배열 | 계약 §3, P1-T1 |
| NOTICE-005 | 기존 동작·권한 보존 | v1 객체/null 유지, 익명 POST와 인접 보호 경로는 401 | 계약 §4, P1-T1 |
## 구현 제약과 제외
- Kotlin 1.6.21, Java 17, Spring Boot 2.7.14 및 기존 의존성을 사용한다.
- 신규 v2 controller와 읽기 전용 service가 기존 `ServiceServiceNoticeRepository.getNoticeList(PageRequest.of(0, 3))`를 호출한다. 기존 `NoticeItem`과 `ApiResponse`를 재사용한다.
- 공개 보안 허용은 정확히 새 GET 한 경로만 추가한다. 공지 본문은 기존 활성 공지의 공개 데이터이며 새 사용자·인증 데이터는 취급하지 않는다.
- v1 공지 함수, 홈 응답, 기존 repository 쿼리, DB 스키마, 공통 JSON·오류 정책과 의존성은 변경하지 않는다.
- UI·캐러셀·자동 fallback·캐시·페이지네이션·신규 DTO·port·범용 facade·배포는 제외한다.
## 검증과 배포 상태
실제 SecurityConfig, RANDOM_PORT HTTP 서버, TestRestTemplate, 실제 repository, 로컬 H2와 embedded Redis로 통합 검증한다. 새 구현 클래스 참조 없이 HTTP로 먼저 실패를 확인한다. H2는 기존 MySQL 날짜 함수 alias로 쿼리를 실행하며 운영 MySQL 실검증은 별도다.
공지 HTTP 11개와 기존 배포 엔드포인트 HTTP 4개가 통과했고, `bootJar`와 전체 `ktlintCheck`가 성공했다. 전체 `build --continue`는 test 단계에서 20분 제한으로 종료되어 전체 회귀는 미검증이다. 최종 Gate 상태와 독립 리뷰는 구현 계획에 기록한다. 구현·로컬 검증은 배포 완료를 뜻하지 않으며 클라이언트는 배포 확인 전 명시적 mock을 사용한다.
## Decision Log
| 날짜 | ID | 결정 | 근거 |
|---|---|---|---|
| 2026-10-28 | NOTICE-D1 | 기존 목록 쿼리를 page 0/size 3으로 호출 | 기존 쿼리에 활성 필터·id 역순·offset/limit·날짜 projection이 존재 |
| 2026-10-28 | NOTICE-D2 | 기존 v1과 홈은 전혀 수정하지 않음 | 사용자 승인 범위 및 객체/null 호환성 |
| 2026-10-28 | NOTICE-D3 | 전체 Gate는 부모 실행자가 수행 | 명시적 작업 분담, 이 작업은 focused HTTP 검증까지만 수행 |
@@ -0,0 +1,62 @@
# 최근 공지사항 v2 구현 리뷰
## 1. 리뷰 정보와 범위
| 항목 | 내용 |
|---|---|
| 대상 | 2026-10-28 작업 트리의 P1-T1 구현 및 P1-GATE 독립 리뷰 부분 |
| 리뷰어 / 상태 | 독립 리뷰 에이전트 / 판정 완료 |
| 기준 | [PRD](../prd.md)의 NOTICE-001~005, [계획](../plan-task.md), [API 계약](../api-contract.md), [클라이언트 전달문](../client-prompt.md), [리뷰 샘플](../../sample/sample-review.md) |
| 방법 | 신규 controller/service, SecurityConfig, 통합 테스트, 기존 repository/DTO/v1 및 홈 호출 경로를 읽기 전용 대조 |
| 제외 | 코드·테스트 수정, 명령·테스트·실서버 재실행, 운영 MySQL·배포 검증 |
이전 리뷰 결과는 회수되지 않아 재사용하지 않았다. 이번 판정은 현재 파일을 직접 읽은 정적 검토와 부모 실행자가 전달한 실행 증거를 구분한다. Git SHA/diff는 이번 리뷰에서 독립 확인하지 않았으며, 기존 파일의 변경 없음은 부모의 diff 관찰을 함께 근거로 삼았다.
## 2. 판정 기준과 계약 대조
확정 계약 위반·보안 노출·주요 회귀는 수정 필요 항목으로 판정한다. 재현 조건과 코드·테스트 근거가 없는 추측이나 공개된 환경 한계는 코드 결함으로 올리지 않는다. 아래 경로는 저장소 루트 기준이며 숫자는 줄 번호다.
| 기준 | 판정 및 근거 |
|---|---|
| NOTICE-001: 익명 GET 및 배열 envelope | 적합. `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/adapter/in/web/LatestNoticeController.kt:10`의 경로와 `:13`의 `ApiResponse.ok`가 목록을 직접 반환한다. `src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt:3`의 기존 네 필드를 재사용한다. |
| NOTICE-002: 활성·id DESC·DB 최대 3개 | 적합. `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/notice/application/LatestNoticeService.kt:12`가 `getNoticeList(PageRequest.of(0, 3))`를 호출한다. `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceServiceNoticeRepository.kt:44`의 활성 필터, `:45`의 offset, `:46`의 DB limit, `:47`의 id 역순을 그대로 사용한다. 전체 조회 후 메모리 절단이나 totalCount 조회가 없다. |
| NOTICE-003: 필드·전체 본문·KST | 적합. `src/main/kotlin/kr/co/vividnext/sodalive/notice/GetNoticeResponse.kt:10`의 `NoticeItem(id: Long, title: String, content: String, date: String)`을 재사용한다. `ServiceServiceNoticeRepository.kt:36`은 원래 제목·본문을 projection하고 `:67`은 UTC→Asia/Seoul 및 `%Y-%m-%d`를 유지한다. 신규 변환·축약이 없다. |
| NOTICE-004: 0/1/2/3+ 및 전부 비활성 | 적합. service의 non-null 목록을 그대로 반환하므로 빈 목록도 `[]`이고 패딩·반복이 없다. 통합 테스트 `:59`, `:101`, `:110`, `:125`, `:142`가 활성 4개와 더 큰 비활성 id, 0/1/2개, 전부 비활성을 검증한다. |
| NOTICE-005: v1·홈·권한 보존 | 적합. `src/main/kotlin/kr/co/vividnext/sodalive/notice/ServiceNoticeController.kt:39` → `ServiceNoticeService.kt:51` → `ServiceServiceNoticeRepository.kt:51`은 단일/null 경로다. `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt:52`와 `GetContentMainTabHomeResponse.kt:13`도 기존 `NoticeItem?`를 유지한다. `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt:194`는 정확한 GET 한 경로만 허용하고 `:214`의 나머지 인증 규칙을 유지한다. |
## 3. 코드·테스트 품질
- **품질 판정: 적합.** 읽기 전용 트랜잭션 service와 얇은 controller만 추가하며 기존 repository·DTO·envelope를 재사용한다. 신규 port·facade·캐시·페이지네이션·의존성·오류 fallback 등 불필요한 추상화가 없다.
- 테스트 기준 파일은 `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/notice/LatestNoticeControllerIntegrationTest.kt`다. `:26`의 RANDOM_PORT, `:33`의 embedded Redis, `:35`의 실제 repository/JDBC와 TestRestTemplate을 사용하며 controller/service를 mock하지 않는다. 파라미터 확장 포함 11건이다.
- `:193`은 실제 저장·flush 후 JDBC로 고정 시각을 저장한다. 이는 `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:29`의 PrePersist 덮어쓰기를 피하고 별도 HTTP 요청이 DB를 읽게 한다. `:185`는 상태와 전체 JSON tree를 비교하므로 null 필드 누락·추가 필드·본문 변형·배열 순서도 검출한다.
- `:59`의 id와 createdAt 역순 fixture, `:84`의 UTC 14:59:59/15:00:00 KST 경계, `:154`/`:171`의 v1 객체/null, `:176`의 POST·인접 GET 거부가 핵심 회귀를 덮는다. H2 날짜 alias는 기존 `src/test/kotlin/kr/co/vividnext/sodalive/support/H2MysqlDateFunctions.kt:10`을 사용하므로 운영 MySQL 검증과는 구분한다.
- 클라이언트 전달문도 배열·서버 순서·날짜 재변환 금지·미배포 시 명시적 mock·자동 fallback 금지를 계약과 일치하게 안내한다.
## 4. 실행 증거와 검증 한계
아래 실행 결과는 **부모 실행자가 실제 관찰하여 전달한 증거**다. 리뷰어는 명령, XML 또는 HTTP 호출을 재검증하지 않았다.
| 전달된 검증 | 결과와 한계 |
|---|---|
| focused RED | 11 tests / 7 failures. 미구현·미허용 v2 GET이 HTTP 401로 실패했으며 컴파일·인프라 실패를 RED로 세지 않았다. |
| focused GREEN XML | timestamp `2026-10-28T06:28:11`, 11 tests / 0 failures / 0 errors / 0 skipped. 전체 테스트 성공의 증거는 아니다. |
| 실제 HTTP 관찰 | v2 HTTP 200의 id 12/11/10 배열과 `[]`, v1 null/객체, 익명 POST 및 인접 GET의 401을 확인했다. 전체 필드·KST 경계는 위 focused 테스트와 계획의 응답 기록으로 연결된다. |
| `./gradlew --no-daemon bootJar ktlintCheck` | exit 0, 59초. 패키징·lint 성공이며 전체 `build` 성공으로 확대하지 않는다. |
| `./gradlew --no-daemon build --continue` | `:test`에서 1,200,000ms 후 타임아웃. **전체 테스트 및 전체 build 검증은 pending/blocked이며 P1-GATE 완료 불가.** 타임아웃 후 focused HTTP 재실행은 부모 담당 예정으로, 이 리뷰 시점에는 결과 미확인이다. |
LSP 도구는 미제공이다. 운영 MySQL의 실제 timezone 처리·배포 및 홈 전체 HTTP 응답 회귀는 미검증이다. 이 한계만을 이유로 무관한 코드 수정을 요구하지 않는다. 이번 리뷰는 shell·런타임 자원·임시 root를 생성하지 않았다.
## 5. 발견 사항과 후속 전환
**확정 발견 사항 없음.** 승인 계약과 검토 코드·테스트에서 수정이 필요한 결함을 확인하지 못했다. 신규 회귀 수정 Task/goal 전환 항목 없음.
## 6. 리뷰 종료 판정
**규격 판정: 적합. 코드·테스트 품질 판정: 승인(검토 범위 내). 독립 리뷰는 완료하되 최종 P1-GATE는 전체 회귀·전체 build 미확인으로 보류한다. 배포 승인을 뜻하지 않는다.**
남은 담당은 부모 실행자다. 타임아웃 후 focused HTTP 재실행 결과와 전체 회귀·전체 build의 종료 결과를 별도로 기록하고, 기존 문서의 Gate 상태·역할 표현을 최종 증거에 맞춰 정리한다. 이 리뷰에서는 다른 파일이나 완료 체크박스를 변경하지 않는다.
## 7. 실행자 후속 증거
- 2026-10-28: 리뷰와 독립적으로 공지·기존 배포 엔드포인트 HTTP 테스트를 함께 재실행했다. `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.notice.LatestNoticeControllerIntegrationTest --tests kr.co.vividnext.sodalive.deployment.DeploymentEndpointIntegrationTest`가 exit 0, 3m29s로 끝났으며 XML은 11+4=15 tests, failures/errors/skipped 모두 0이다.
- production/test 수정 없이 문서의 현재 상태를 갱신했다. 전체 회귀의 시간 초과와 P1-GATE 보류는 그대로 유지한다. 이 기록은 리뷰어의 재실행 주장이 아니라 실행자의 추가 증거다.
@@ -0,0 +1,90 @@
# 관리자 구현 프롬프트: 가로형 서브 배너
이 프롬프트는 관리자 작업에 단독으로 사용할 수 있다. 작업 대상 repository의 실제 stack, 관리자 인증, API client, multipart 전송 및 form/list 패턴을 먼저 확인한다. 특정 framework를 전제하지 않는다. 이 작업은 frontend-only이며 서버, 저장소, schema, migration, 배포 작업으로 확장하지 않는다.
**상태:** 관리자 CRUD API 구현·검증 완료, 미배포. 백엔드 전체 테스트·lint·빌드와 독립 코드 리뷰를 통과했다. 화면 배치, 폼 표현, 삭제 확인 등 UI 선택은 작성자 추천이며 계약으로 가장하지 않는다.
## 목표
가로형 서브 배너의 목록·생성·수정·삭제 UI를 제공한다. 레코드 하나는 `HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION` placement 하나에 속한다. 별도 상세 조회는 없으므로 목록 데이터로 수정 form을 채운다.
## API 계약
아래 계약은 구현되어 있지만 아직 배포되지 않았다. 배포된 target을 확인한 뒤 그 target에만 실제 연결한다. target 오류 후 환경 변경이나 mock fallback을 자동 적용하지 않는다. fixture는 명시적인 UI 검증에서만 사용하며 mock 상태를 서버 성공으로 표시하지 않는다.
- `GET /admin/sub-banners?placement={placement}`: 필수 placement query, 삭제되지 않은 활성 배너를 기간과 무관하게 반환한다.
- `POST /admin/sub-banners`: 생성.
- `PUT /admin/sub-banners/{id}`: 기본 JSON 네 필드 교체. 두 날짜 경계만 선택적으로 생략 보존 또는 명시적 null 해제한다.
- `DELETE /admin/sub-banners/{id}`: 소프트 삭제. 성공 시 목록에서 제거한다. 삭제된 id의 수정·재삭제는 404이며, 응답은 기존 HTTP 200과 `data:null`이다. `isActive`를 직접 보내거나 복원·활성 토글 UI를 추가하지 않는다.
- 기존 관리자 API client로 `Authorization: Bearer <token>`을 전송하고 `ADMIN` 역할이 필요하다. 익명 요청은 HTTP 401이다. 유효한 비관리자 요청은 현재 정책상 HTTP 200 `success:false`이며 403을 보장하지 않는다.
- 성공은 HTTP 200 및 네 필드 `ApiResponse<T>`: `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를 쓴다.
- 앱은 두 추천 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` 생략은 기존 이미지 유지, 포함은 교체다.
- `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`를 명시한다.
- 변환된 경계 instant는 UTC `1970-01-01 00:00:01`부터 `2038-01-19 03:14:07` 사이여야 한다. 이에 따른 KST 입력 범위는 시작 `1970-01-01 09:01`부터 `2038-01-19 12:14`, 종료 `1970-01-01 09:00`부터 `2038-01-19 12:13`이다. 이 범위 안의 과거 날짜는 유효하다. 잘못된 날짜/type 또는 effective start가 end보다 늦으면 HTTP 400이며 값 보정은 없다. update에서는 저장된 값과 새 값의 조합으로 검증한다.
- **작성자 제안:** form submit 경계에서 주소를 trim하고 blank를 `null`로 정규화한 뒤, 그 결과 세 주소 중 적어도 하나가 nonblank인지 검증한다. 이는 XOR 조건이 아니며 두 개 이상도 유효하다. nonblank URI 원문은 trim 외 재인코딩·변형하지 않는다.
- 웹 URL은 host를 가진 절대 http(s) URL이어야 한다. Android/iOS 딥링크는 문법적으로 유효한 절대 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 임의 scheme allowlist는 없다.
- 서비스가 검증하는 JSON/URI/date/type/placement 오류, 빈 파일 또는 비이미지 업로드는 HTTP 400이다. 유효한 정수 ID의 수정·삭제 대상이 없으면 HTTP 404이며 상태 오류 및 익명 401은 표준 Spring Boot `{timestamp,status,error,path}` body를 쓴다. 이는 `ApiResponse`가 아니다. multipart 파트 누락 또는 malformed multipart는 HTTP 200 `success:false`다. 프레임워크 바인딩 오류까지 모두 400이라고 가정하지 않는다. 유효한 비관리자는 HTTP 200 `success:false`다. 모든 응답에서 HTTP 상태와 `success`를 함께 확인하며 403을 전제하지 않는다.
- 파일 내용은 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}
```
POST/PUT 성공 응답 예시:
```json
{
"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},
"errorProperty": null
}
```
## 폼과 목록 동작
- 관리자 API는 placement별 삭제되지 않은 모든 배너를 id 오름차순으로 반환한다. 클라이언트에서 재정렬하지 않고 전부 표시한다. 별도 정렬 편집은 추가하지 않는다.
- 폼은 이미지, 세 주소, KST 시작/종료 날짜 선택기를 제공한다. 날짜 입력을 비우면 `null`을 보내며 빈 문자열은 보내지 않는다. 각 경계를 별도로 지울 수 있다. 화면에 KST를 표시하고 브라우저 지역 시각 또는 UTC ISO로 바꾸지 않는다.
- 편집은 목록의 전체 객체를 기반으로 모든 값을 채운다. 기존 이미지 URL/선택 상태, 주소 및 기간 값을 오류 후 보존한다. 기간만 바꾸는 요청은 기존 placement와 세 주소를 그대로 전송하고, image를 생략해 기존 이미지를 유지한다. 주소를 임의로 null로 초기화하지 않는다.
- 관리자 목록은 삭제되지 않은 배너의 미래/현재/만료/무기간 상태를 모두 표시하며 노출 기간으로 필터링하지 않는다. 기간 밖 레코드도 열어 수정할 수 있지만 소프트 삭제된 배너는 표시·수정하지 않는다. 같은 placement의 서버 id 오름차순을 유지한다.
- 업데이트 기본 네 JSON 필드는 항상 보내되, 날짜 필드는 둘 다 보내거나 필요한 경계만 보내거나 생략할 수 있다. 생략은 보존, 명시적 null은 해당 경계 해제다. API의 생략 보존 의미는 UI가 두 날짜를 모두 보낼 때도 유지된다.
- 최소 한 주소 검증, 주소 trim/null 직렬화, URI 원형 보존을 적용한다.
- 날짜 선택값은 KST 분 문자열로 직렬화한다. 시간대 변환으로 날짜나 분을 바꾸지 않는다. create의 날짜 미입력은 null/no-bound로 보내거나 생략할 수 있다. update에서 미입력 경계를 보존하려면 해당 필드를 생략하고, 사용자가 지웠으면 명시적 null을 보낸다.
- 저장 후 해당 placement 목록을 갱신한다. 실패 시 입력값과 기존 이미지 상태를 보존하고 실패를 알린다. autosave는 두지 않는다.
- 삭제 확인, 중복 제출 방지, 상태 표시 및 성공 후 갱신은 기존 관리자 UI 패턴을 따른다. mutation 자동 재시도는 하지 않으며 timeout 결과가 불명확하면 상태를 조회한다.
- 조회 실패를 빈 성공으로 취급하지 않는다.
- 필요하면 두 딥링크 주소를 입력해 특정 OS만 지원하는 레코드임을 설명할 수 있으나 별도 기능으로 확장하지 않는다.
## 주소 조합 acceptance matrix (작성자 제안)
`web`, `android`, `ios` 열은 해당 주소가 nonblank인 경우다. 빈 값은 null로 전송한다. Valid 여부는 최소 한 값 요구에 따른다.
Android/iOS 탭 동작은 작성자 추천이며 제품 승인된 UI 동작이 아니다. 권장 흐름은 현재 OS 딥링크 우선, 없으면 웹 URL이다.
| web | android | ios | 유효 | Android 동작 | iOS 동작 |
|---|---|---|---|---|---|
| 없음 | 없음 | 없음 | 아니오 | 표시 안 함 | 표시 안 함 |
| 있음 | 없음 | 없음 | 예 | 웹 | 웹 |
| 없음 | 있음 | 없음 | 예 | Android 딥링크 | 표시 안 함 |
| 없음 | 없음 | 있음 | 예 | 표시 안 함 | iOS 딥링크 |
| 있음 | 있음 | 없음 | 예 | Android 딥링크 우선, 실패 시 웹 | 웹 |
| 있음 | 없음 | 있음 | 예 | 웹 | iOS 딥링크 우선, 실패 시 웹 |
| 없음 | 있음 | 있음 | 예 | Android 딥링크 | iOS 딥링크 |
| 있음 | 있음 | 있음 | 예 | Android 딥링크 우선, 실패 시 웹 | iOS 딥링크 우선, 실패 시 웹 |
## 필수 테스트와 완료 확인
- 여덟 주소 조합의 form 유효성 및 Android/iOS 대상 판단.
- 전부 null로 수정할 때 오류, `null`로 한 필드 지우기, 공백 trim, URI 원형 보존.
- 두 날짜 null, 시작만, 종료만, 같은 분, 경계의 `:00` 및 `:59`, 과거 날짜가 유효한 경우를 확인한다.
- create 날짜 생략/null, update 날짜 생략 보존, 개별 null 해제, 두 경계 해제, loaded date 재전송, 유효 범위가 retained/new 값 조합으로 검증됨을 확인한다.
- 잘못된 날짜/type, blank, 초, `Z`/offset 및 시작이 종료보다 늦은 값이 거부되고 자동 교환/rollover되지 않는지 확인한다.
- 미래/현재/만료/무기간 레코드가 관리자 목록에 남고 기간 외 레코드도 편집 가능한지 확인한다. 일정만 수정하고 image를 생략하면 기존 이미지가 유지되어야 한다.
- 여러 항목 표시, placement별 목록 분리, create/update/delete 성공과 실패.
- 실패 시 입력·이미지 상태 보존, 삭제 확인, 중복 제출 방지 및 timeout 후 중복 POST 방지 흐름.
- 인증/권한 실패와 목록 조회 실패를 empty로 오인하지 않는 상태.
프롬프트는 frontend-only로 실행한다. 백엔드 검증은 완료됐지만 운영 DB 적용·배포는 하지 않았다. 배포 target이 확인되기 전 운영 요청이나 배포 성공을 주장하지 않는다. UI를 수정하는 작업은 기존 프로젝트 방식으로 검증하고, 서버 코드 및 API는 변경하지 않는다.
@@ -0,0 +1,141 @@
# 가로형 서브 배너 API 계약
> **상태: 소프트 삭제·두 추천 API 통합 구현 및 검증 완료, 미배포.** 최종 전체 테스트 2,682개(실패·오류·무시 0), lint·bootJar 및 독립 리뷰를 통과했다. 운영 DB 적용과 배포는 수행하지 않았다.
## 1. 공통 형식
애플리케이션 성공 응답은 네 필드를 가진 `ApiResponse<T>` 형식이다. 아래는 관리자 빈 목록 예시이며, 두 추천 GET의 data는 §2의 응답 객체다.
```json
{
"success": true,
"message": null,
"data": [],
"errorProperty": null
}
```
`data`는 조회의 응답 객체 또는 목록, 생성·수정의 전체 배너 객체, 삭제의 `null`이다. 성공 요청은 HTTP 200이다. 일부 요청 오류도 기존 handler에 따라 HTTP 200의 `success:false`로 응답하므로 HTTP 상태와 `success`를 모두 확인한다.
```json
{
"id": 123,
"placement": "HOME_RECOMMENDATION",
"imageUrl": "https://cdn.example.test/sub-banner.jpg",
"webUrl": "https://example.test/promo?x=a%2Fb",
"androidDeepLink": "sampleapp://promo/item?id=a%2Fb",
"iosDeepLink": null
}
```
| 필드 | 타입 | 규칙 |
|---|---|---|
| `id` | integer JSON number | 불투명 식별자. 소비 클라이언트의 정밀도 안전 ID 전략을 사용한다. |
| `placement` | enum | `HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION` |
| `imageUrl` | string | 서버가 생성한 저장 키를 기반으로 만든 절대 `http` 또는 `https` URL. 클라이언트 파일명은 저장 키가 아니다. |
| `webUrl` | string 또는 null | 절대 `http` 또는 `https` URL |
| `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는 없다.
관리자 전용 `AdminSubBanner`는 `SubBanner`의 기본 필드에 nullable `startDate`, `endDate`를 더한 형태다. 관리자 GET 및 POST/PUT 응답은 모두 `AdminSubBanner`를 반환한다. 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이다.
관리자 기간 의미는 기존 콘텐츠 배너 예약과 동일하다. null/null은 무제한, 시작만 있으면 시작부터, 종료만 있으면 해당 종료 경계까지 포함하며 시작과 종료가 같은 분인 것도 유효하다. 입력·응답은 분 단위지만 실제 노출 판정은 초 단위로 양 끝을 포함한다. 시작 경계는 입력 분의 `:00`, 종료 경계는 입력 분의 `:59`다. 예를 들어 종료 입력이 `2026-11-01 12:00`이면 `12:00:59`까지 포함하고 `12:01:00`부터 제외한다. API 문자열에는 초, `Z`, offset을 표현하거나 받지 않는다.
요청 날짜는 선택적이다. 생성 시 생략 또는 null은 해당 경계를 제한 없음으로 둔다. 수정 시 생략은 저장된 경계를 유지하고 명시적 null은 그 경계만 해제한다. 두 날짜의 유효 범위는 수정 후 유지되는 값과 새 값을 합쳐 검증한다. 입력은 엄격한 Asia/Seoul `yyyy-MM-dd HH:mm` 분 단위다. 잘못된 형식, 문자열이 아닌 값, 빈 문자열, 초/시간대가 포함된 값, 존재하지 않는 KST 현지 시각(1988-05-08 02:30), 시작이 종료보다 늦은 값은 HTTP 400이다. 존재하는 겹침 시각은 기존의 앞선 offset을 사용한다. 자동 교환이나 날짜 넘김 보정, 과거 날짜 제한은 없다.
UTC로 변환된 경계 instant는 `1970-01-01 00:00:01`부터 `2038-01-19 03:14:07` 사이여야 한다. 이에 따라 시작 입력 허용 범위는 KST `1970-01-01 09:01`부터 `2038-01-19 12:14`, 종료 입력은 `1970-01-01 09:00`부터 `2038-01-19 12:13`이다. 범위 바깥을 포함해 변환 instant가 저장 가능 범위를 벗어나면 HTTP 400이다. 이 범위 안의 유효한 과거 날짜는 허용된다.
## 2. 앱 추천 조회
| 항목 | 계약 |
|---|---|
| Method / Path | `GET /api/v2/home/recommendations` |
| Query | 서브 배너용 query 없음. endpoint가 `HOME_RECOMMENDATION` placement를 결정한다. |
| 접근 | 익명 조회 가능. 로그인 사용자도 기존 클라이언트 헤더를 유지해 조회한다. |
| 성공 | HTTP 200, `ApiResponse<HomeRecommendationResponse>` |
| `data` | 기존 홈 추천 응답 객체의 모든 필드와 `subBanners: SubBanner[]`를 포함한다. 기존 `banners` 및 추천 섹션은 유지한다. |
| `data.subBanners` | 해당 placement의 활성 배너 중 현재 노출 기간 안에 있는 전체 항목, id 오름차순. 페이지네이션·묵시적 절단 없음. 결과가 없으면 `[]`. 날짜·활성 필드는 응답하지 않는다. |
```json
{
"success": true,
"message": null,
"data": {
"lives": [],
"banners": [],
"recentlyActiveCreators": [],
"recentDebutCreators": [],
"firstAudioContents": [],
"aiCharacters": [],
"genreCreators": [],
"cheerCreators": [],
"popularCommunityPosts": [],
"subBanners": [{"id":123,"placement":"HOME_RECOMMENDATION","imageUrl":"https://cdn.example.test/sub-banner.jpg","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null}]
},
"errorProperty": null
}
```
`GET /api/v2/audio/recommendations`도 익명 조회 가능하며 로그인 사용자는 기존 클라이언트 헤더를 유지한다. 이 endpoint는 `CONTENT_RECOMMENDATION`을 사용하고 기존 `banners`, `originalSeries`, `latestAudios`, `newAndHotAudios`, `freeAudios`, `pointAudios`, `mostCommentedAudios`, `recommendedAudios` 필드에 `subBanners: SubBanner[]`를 추가한다. `data.subBanners`에는 동일한 전체·활성·기간 필터 및 id 오름차순 규칙이 적용되며, 빈 결과는 `[]`다. 두 API 모두 기존 추천 `data`를 배너 배열로 대체하지 않으며 별도 placement query를 보내지 않는다.
`GET /api/v2/sub-banners`는 더 이상 공개 앱 조회 경로가 아니며 별도 요청으로 사용하지 않는다. 이를 새 alias로 취급하지 않는다. 서버가 반환한 항목에 기존 OS별 링크 필터를 적용하며 날짜와 활성 상태는 클라이언트에서 판단하지 않는다.
두 추천 GET은 Authorization 헤더 없이 200으로 조회할 수 있다. 아래 인증 오류 설명은 관리자 등 인증이 필요한 경로에 적용되며, 앱 추천 조회에 새 로그인 조건을 추가하는 것이 아니다. 서브 배너 전용 HTTP 요청·서버 fallback은 없다.
## 3. 관리자 목록과 변경
관리자 경로는 `Authorization: Bearer <token>` 인증과 `ADMIN` 역할이 필요하다. 유효한 비관리자 요청이 반드시 403을 반환하는 것은 아니며 현재 정책 응답은 HTTP 200 `success:false`다.
| Method / Path | 목적 | 성공 data |
|---|---|---|
| `GET /admin/sub-banners?placement={placement}` | placement별 삭제되지 않은 활성 레코드, 기간 무관. 필수 query, id 오름차순 | `AdminSubBanner[]` |
| `POST /admin/sub-banners` | 신규 생성 | 생성된 `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는 추가하지 않는다.
POST와 PUT은 `multipart/form-data`다. `image` 파트는 파일이며, `request` 파트는 `Content-Type: application/json`인 JSON이다. 별도 이미지 업로드 endpoint는 없다.
```json
{
"placement": "HOME_RECOMMENDATION",
"webUrl": "https://example.test/promo",
"androidDeepLink": "sampleapp://promo",
"iosDeepLink": null,
"startDate": "2026-11-01 09:30",
"endDate": null
}
```
- POST: `image` 필수.
- PUT은 기존 네 필드 `placement`, `webUrl`, `androidDeepLink`, `iosDeepLink`를 모두 보낸다. 이 네 필드와 placement는 기존처럼 필수 replacement 값이다. 주소 `null`은 해당 주소를 지운다. 이는 전체 JSON replacement라고 무조건 부르는 대신, 아래 두 날짜 필드만 예외적으로 생략 보존한다고 명시한다.
- `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` 생략 시 기존 이미지를 유지하고, 포함 시 교체한다.
- 수정 화면은 목록 응답의 전체 객체로 채우며 별도 상세 GET은 두지 않는다.
- 파일 내용은 이미지여야 하며 MIME 감지로 검사한다. GIF를 허용한다. 빈 파일과 비이미지는 HTTP 400이다. 현재 서버 설정의 최대 파일/요청 크기는 각각 1024 MB이며, 이는 제품 권장 용량이 아니다. reverse proxy나 배포 설정은 더 낮은 제한을 둘 수 있다.
관리자 목록에는 삭제되지 않은 활성 레코드의 미래 시작·현재 노출·종료된 기간·양쪽 null 상태가 모두 포함되며 기간으로 숨기지 않는다. 소프트 삭제된 행은 기간과 무관하게 제외한다. 같은 placement/id 오름차순을 사용한다.
## 4. 기존 배너 예약 근거
예약 의미는 기존 콘텐츠 배너 예약 동작을 따른다. 날짜의 외부 API 표현은 위 형식과 경계 규칙을 따른다.
## 5. 오류와 재시도
서비스가 검증하는 JSON/URI/날짜/타입/placement 오류와 업로드된 빈 파일 또는 비이미지는 HTTP 400이다. 유효한 정수 ID로 조회한 수정·삭제 대상이 없으면 HTTP 404이며, 이들 상태 오류는 표준 Spring Boot 형식 `{"timestamp":...,"status":...,"error":...,"path":...}`을 사용하고 `ApiResponse`가 아니다. 인증되지 않은 요청은 HTTP 401과 같은 표준 형식이다. 유효한 비관리자 요청은 현재 권한 정책에 따라 HTTP 200 `ApiResponse`의 `success:false`를 반환하며 403을 보장하지 않는다. `image`/`request` multipart 파트 누락 및 malformed multipart는 기존 handler에 따라 HTTP 200 `success:false`다. 프레임워크 바인딩 오류까지 모두 400이라고 가정하지 않는다. 소비자는 모든 응답에서 HTTP 상태와 `success`를 함께 확인하며 구체적인 오류 코드나 `errorProperty` 값을 가정하지 않는다.
```json
{"success":false,"message":"요청을 처리할 수 없습니다.","data":null,"errorProperty":null}
```
이 예시는 HTTP 200의 `success:false` 응답 예시다. 표준 Spring Boot 상태 오류의 body는 위 ApiResponse 형식이 아니다. `Idempotency-Key`는 제공되지 않는다. POST/PUT/DELETE를 자동 재시도하지 않고 중복 제출을 막는다. 결과를 알 수 없는 timeout 뒤, 특히 POST는 재시도 전 GET으로 상태를 대조한다.
## 6. 제공 상태와 호환성
관리자 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 적용과 배포는 별도 승인 대상으로 이번 작업에서는 수행하지 않는다.
@@ -0,0 +1,102 @@
# 앱 구현 프롬프트: 가로형 서브 배너
이 프롬프트는 앱 작업에 단독으로 사용할 수 있다. 현재 repository에 접근해 실제 앱 stack과 기존 화면·URL opener·API client 패턴을 먼저 확인하고 그 패턴에 맞춰 작업하라. 특정 framework를 가정하지 말고 기존 API를 변경하지 않는다.
**상태:** 소프트 삭제와 두 추천 API 통합의 백엔드 구현·검증 완료, 미배포. 전체 테스트·lint·빌드와 독립 리뷰를 통과했다. 제공 환경을 확인하기 전 운영 연동 또는 서버 배포가 완료됐다고 가정하지 않는다.
## 목표
메인 홈 추천과 메인 콘텐츠 추천 화면에 가로형 서브 배너 영역을 추가한다. 각 placement 목록에 여러 배너가 있을 수 있다. 아래 API와 데이터 계약은 구현된 계약이다. 화면 배치, 표현, 링크 우선순위, fetch 실패 시 UI 처리는 작성자 추천이며 기존 앱 패턴을 따른다.
## API 계약
추천 API 통합 계약:
- 홈: `GET /api/v2/home/recommendations`, 익명 조회 가능, `data.subBanners`의 placement는 `HOME_RECOMMENDATION`으로 고정.
- 콘텐츠 추천: `GET /api/v2/audio/recommendations`, 익명 조회 가능, `data.subBanners`의 placement는 `CONTENT_RECOMMENDATION`으로 고정.
- 비로그인 사용자는 Authorization 헤더 없이 두 GET을 조회할 수 있다. 로그인 화면으로 차단하지 않는다. 로그인 사용자는 기존 클라이언트 헤더를 그대로 유지하며, 별도 서브 배너 요청을 추가하지 않는다.
- 응답은 기존 추천 `data` 객체에 `subBanners: SubBanner[]`를 추가한 `ApiResponse`다. 기존 추천 필드와 `banners`를 보존하며 `data`를 배너 배열로 바꾸지 않는다. 서브 배너용 placement query는 보내지 않는다.
- 성공은 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는 보존한다.
- 더 이상 사용하지 않을 별도 앱 요청은 `GET /api/v2/sub-banners`다. 앱에서 해당 endpoint를 호출하거나 대체용 두 번째 요청을 추가하지 않는다. 삭제된 경로의 구체적인 오류 status를 가정하지 않는다.
- 기간 판단은 서버가 맡는다. 기기 시계로 기간을 다시 계산하거나 로컬 예약 timer를 추가하지 않는다.
- 서브 배너만을 위한 별도 fetch는 없다. 추천 API의 통신 실패는 기존 추천 화면의 오류 정책으로 처리하며 정상 `subBanners:[]`로 바꾸지 않는다. 개별 배너의 이미지 로딩·링크 실행 실패는 기존 추천 콘텐츠의 표시와 분리한다.
홈 추천 성공 응답 예시 (기존 추천 필드는 예시 데이터로 유지, 서브 배너 두 항목):
```json
{
"success": true,
"message": null,
"data": {
"lives": [],
"banners": [],
"recentlyActiveCreators": [],
"recentDebutCreators": [],
"firstAudioContents": [],
"aiCharacters": [],
"genreCreators": [],
"cheerCreators": [],
"popularCommunityPosts": [],
"subBanners": [
{"id": 101, "placement": "HOME_RECOMMENDATION", "imageUrl": "https://cdn.example.test/one.jpg", "webUrl": "https://example.test/one", "androidDeepLink": "sampleapp://one", "iosDeepLink": null},
{"id": 102, "placement": "HOME_RECOMMENDATION", "imageUrl": "https://cdn.example.test/two.jpg", "webUrl": null, "androidDeepLink": null, "iosDeepLink": "sampleapp://two"}
]
},
"errorProperty": null
}
```
빈 목록 응답:
```json
{"success":true,"message":null,"data":{"lives":[],"banners":[],"recentlyActiveCreators":[],"recentDebutCreators":[],"firstAudioContents":[],"aiCharacters":[],"genreCreators":[],"cheerCreators":[],"popularCommunityPosts":[],"subBanners":[]},"errorProperty":null}
```
콘텐츠 추천도 기존 `data` 구조를 유지한다. 빈 데이터 예시는 다음과 같으며, 배너가 있으면 `subBanners`에 `CONTENT_RECOMMENDATION` 항목이 들어간다.
```json
{"success":true,"message":null,"data":{"banners":[],"subBanners":[],"originalSeries":[],"latestAudios":[],"newAndHotAudios":[],"freeAudios":[],"pointAudios":[],"mostCommentedAudios":[],"recommendedAudios":[]},"errorProperty":null}
```
## 표시 규칙
- 서버 응답의 id 오름차순은 유지한다. 아래 UI 선택은 작성자 추천이다: 모든 표시 대상 항목을 수직으로 나열하고 새 slider/autoplay는 추가하지 않는다.
- placement별 화면 내 정확한 위치는 기존 화면을 살펴 정한다. main banner 뒤에 배치하는 것은 작성자 추천이며 확정된 API 동작이 아니다.
- 작성자 추천: 이미지 원본 비율에 맞춰 표시하고, 필터 결과가 없을 때 영역과 여백을 숨긴다. 기존 화면 레이아웃에 맞게 적용하며 이를 API 계약으로 간주하지 않는다.
- 표시 조건은 대상 OS 딥링크 또는 웹 URL 보유다. 대상 OS 딥링크가 있어도 앱 설치/launch 가능 여부로 사전 필터링하지 않는 것은 작성자 추천이다.
- 기간 필터가 적용된 서버 결과에 기존 OS별 링크 필터를 그대로 적용한다. 공개 날짜 필드를 읽거나 클라이언트에서 기간을 판단하지 않는다.
## 링크 처리와 보안
작성자 추천: 탭 시 대상 OS 딥링크가 있으면 우선 기존 플랫폼 URL opener로 열고, 없으면 웹 URL을 연다. 딥링크 시도 실패 시 웹 URL이 있으면 한 번 대체 시도할 수 있다. 다른 OS 딥링크를 대체 수단으로 쓰지 않는다. 구체적인 우선순위와 실패 피드백은 기존 앱 링크 처리 패턴을 따른다.
웹 URL은 절대 `http`/`https` URL이며 host가 필요하다. 딥링크는 절대 URI이고 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 클라이언트에서도 임의 입력을 실행하거나 문자열 연결/eval하지 않는다. URI 원문은 재인코딩·정규화하지 않는다. 네트워크 응답은 외부 입력으로 검증한다.
## 링크 존재 조합 확인표 (작성자 추천)
`있음`은 해당 주소가 nonblank임을 뜻한다. 아래 탭 동작은 작성자 추천이며 UI 의무 계약이 아니다.
| web | Android | iOS | 유효 표시 대상 | Android 탭 | iOS 탭 |
|---|---|---|---|---|---|
| 없음 | 없음 | 없음 | 없음 | 표시 안 함 | 표시 안 함 |
| 있음 | 없음 | 없음 | 양쪽 | 웹 | 웹 |
| 없음 | 있음 | 없음 | Android | 딥링크 | 표시 안 함 |
| 없음 | 없음 | 있음 | iOS | 표시 안 함 | 딥링크 |
| 있음 | 있음 | 없음 | 양쪽 | 딥링크 우선, 실패 시 웹 | 웹 |
| 있음 | 없음 | 있음 | 양쪽 | 웹 | 딥링크 우선, 실패 시 웹 |
| 없음 | 있음 | 있음 | 양쪽 | Android 딥링크 | iOS 딥링크 |
| 있음 | 있음 | 있음 | 양쪽 | 딥링크 우선, 실패 시 웹 | 딥링크 우선, 실패 시 웹 |
## 수용 및 검증
실제 앱 구조에 맞는 테스트를 추가한다. 최소 검증 범위:
- 모든 항목 표시, 응답 순서 유지, 홈/콘텐츠 placement 격리, 빈 목록에서 gap 없음.
- URI 입력을 보존하면서 대상 OS 링크 우선, OS 링크 부재 시 웹, 딥링크 실패 시 웹 fallback 한 번, 양쪽 실패 시 비치명 상태.
- 다른 OS 링크를 실행하지 않고 잘못된 scheme을 거부한다.
- 비로그인 두 GET의 `data.subBanners`를 소비하며 별도 배너 요청이 발생하지 않는다. 추천 API 오류와 정상 빈 배열을 구분하고, 개별 이미지·링크 오류는 다른 추천 콘텐츠를 깨뜨리지 않는다.
- fixture 검증은 명시 모드에 한정되고 정상 운영 요청에 자동 적용되지 않는다.
최소 변경으로 구현하고 관련 focused test, 프로젝트 type/lint 검사, 실제 화면에서 두 영역·빈 상태·tap 동작을 확인한다. 서버나 API를 구현하지 말고, 서버 준비를 완료했다고 보고하지 않는다.
@@ -0,0 +1,15 @@
CREATE TABLE sub_banner (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '서브 배너 ID',
placement VARCHAR(32) NOT NULL COMMENT '노출 위치: HOME_RECOMMENDATION, CONTENT_RECOMMENDATION',
image_path LONGTEXT NOT NULL COMMENT '배너 이미지 S3 object key',
web_url LONGTEXT NULL COMMENT '웹 이동 URL',
android_deep_link LONGTEXT NULL COMMENT 'Android 이동 딥링크',
ios_deep_link LONGTEXT NULL COMMENT 'iOS 이동 딥링크',
start_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 시작 시각 (UTC 초 단위, NULL이면 시작 제한 없음)',
end_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 종료 시각 (UTC 초 단위, NULL이면 종료 제한 없음)',
is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부 (false이면 삭제)',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
PRIMARY KEY (id),
INDEX idx_sub_banner_placement_id (placement, id)
) COMMENT = '가로형 서브 배너';
@@ -0,0 +1,547 @@
# 가로형 서브 배너 백엔드 구현 계획
| 문서 항목 | 내용 |
|---|---|
| 상태 | P5 소프트 삭제·P6 추천 통합 구현 및 전체 검증 완료, 미배포 |
| 작성일 | 2026-10-29 |
| 요구사항 기준 | [prd.md](prd.md) |
| API 기준 | [api-contract.md](api-contract.md), 실제 구현·HTTP 검증 계약 |
| 현재 Phase | Phase 2~6 완료 |
| 현재 활성 Goal | 없음 |
## 목표
두 placement의 서브 배너를 독립 테이블과 백엔드 API로 제공한다. 기존 문서 준비 Goal `DOC-T1`~`DOC-T5`는 완료된 역사적 문서 작업이다. 이후 사용자가 문서대로 백엔드 코드 및 DDL 생성을 승인했으며, 운영 DB 적용·배포는 범위 밖이다.
## 범위
### 포함
- `prd.md`의 요구사항 요약과 결정 상태 기록
- `api-contract.md`의 실제 API 계약
- 독립 `v2.subbanner` 기능 모듈·DDL·관리자 CRUD·소프트 삭제 및 기존 두 추천 GET 응답 합성
- 현재 구현 작업은 신규 subbanner 코드·테스트·DDL 파일 생성과 실제 계약 문서 동기화를 포함한다. 이전 문서 전용 작업은 별도 완료 이력으로 보존한다.
### 제외
- **현재 제외:** 운영 DB DDL 적용·배포. 코드 변경이 포함된 이번 구현에서는 테스트·lint·빌드를 실행했다. 문서 전용 변경에 대한 Gradle 생략 규칙은 유지한다.
- **향후 구현 범위 밖:** 앱/관리자 실제 UI, 기존 대형 배너 변경, scheduler/job, 순서 편집, 활성화 switch, analytics
- 배포, 운영 DB 직접 변경, 기존 콘텐츠 배너 schema migration
- 운영 배포가 완료됐다는 주장
- 신규 dependency, global exception rewrite, 공통 utility wrapper
- 앱/client network 통합은 backend 구현 후 별도 계약 검토와 제공이 끝날 때까지 차단한다. backend를 알 수 없는 외부 block으로 취급하지 않는다.
## 문서 변환 Goal
### `DOC-T5` 백엔드 계획 문서 변환
**Goal 실행 `DOC-T5`:** 사용자 승인 범위에서 PRD와 계획 문서만 backend 요구사항 및 향후 구현 계획으로 변환한다.
- **시작 조건:** `DOC-T1`~`DOC-T4`의 기존 문서 결과가 있고 사용자가 backend 문서 보완과 문서 검증 지침 수정을 승인했다.
- **완료 증거:** 지정된 기존 문서 세 파일만 수정, 신규 backend Goal은 전부 미체크, 과거 DOC 체크박스/Gates/Progress와 이전 Gradle 증거 보존.
- **범위 밖:** API 구현 승인, 코드·SQL 신규 파일 작성, Gradle/test/build 실행, 앱·관리자 prompt 변경.
- **TDD 예외 사유:** 문서 전용 변경이다.
- **대체 검증 방법:** PRD 요구 ID↔Goal 추적, 정확한 path/signature 일치, 과거 완료 기록과 미실행 검증 상태를 수동 대조한다.
- [x] PRD와 backend Phase/Goal 초안 작성 완료.
- [x] 부모 작업자가 문서 대조·공백 검사 및 범위를 확인했다. 아래 `DOC-T5` 최종 검증 기록을 따른다.
## 백엔드 구현 공통 제약
- 기술 기준은 저장소의 Kotlin·Java 17·Spring Boot 2.7.14·JPA/QueryDSL·JUnit 5와 Gradle Wrapper를 그대로 사용한다. 버전·dependency 변경은 계획에 포함하지 않는다.
- 서브 배너 도메인·관리자는 `v2/subbanner`에 두고 기존 `v2/api/home` 및 `v2/api/content/recommendation`의 facade/DTO에서 응답을 합성한다. 대응 테스트 경로도 같은 책임을 따른다.
- 데이터 계층은 한 개의 독립 기능 모듈로 유지한다. `SubBannerController`는 P6에서 제거했고, `AdminSubBannerController`와 `SubBannerService`·persistence·6필드 public DTO는 재사용한다. 단일 클래스 파일명은 ktlint 규칙을 따른다.
- 현재 앱 조회는 `GET /api/v2/home/recommendations`, `GET /api/v2/audio/recommendations`의 `data.subBanners`다. 관리자 경로는 `/admin/sub-banners`를 유지한다. 이전 Phase의 standalone GET 기록은 당시 구현 이력이며 현재 계약은 [api-contract.md](api-contract.md)를 따른다.
- 파일/링크/날짜 입력을 경계에서 검사한다. 오류는 기존 `ApiResponse`/`SodaExceptionHandler` 사실을 확인하고 global handler는 고치지 않는다.
- DDL은 `docs/20261029_가로형서브배너/create-sub-banner.sql`이며 P5에서 is_active 기본 true를 추가했다. 테스트용 Flyway/자동 live migration을 만들지 않는다.
- 기존 S3Uploader, 이미지 검증기 및 QueryDSL/JPA를 재사용한다. GIF는 허용하며 S3 object 자동 삭제/정리는 제공하지 않는다. 새 dependency는 추가하지 않았다.
- 시간은 KST 분 입력→UTC 초 단위 DB 값→KST 분 응답이다. `TIMESTAMP` DDL precision을 확인하고 service에서 한번 얻은 `now`를 초 단위로 정규화한다.
- 각 단계의 원래 명령은 계획이며 실제 실행 결과는 해당 Progress를 따른다. 문서 전용 변경은 Gradle을 실행하지 않고, 코드 변경의 Gate는 테스트·lint·빌드 증거로 판정한다.
## Historical Phase 1: 문서 작성 완료 기록
아래 `DOC-T1`~`DOC-T4`, Gate와 Progress는 기존 문서 작업 이력이다. 새 backend Phase의 완료 상태나 구현 착수 근거가 아니다.
**Phase 결과:** 요구사항, 제안 API와 두 독립 구현 프롬프트가 서로 일치하는 문서 묶음.
### Task 1.1 요구사항과 준비 계획
**Goal 실행 `DOC-T1`:** 샘플에 맞춰 PRD와 문서 작성 계획을 작성한다.
- **시작 조건:** 사용자 확정 요구, 프로젝트 문서 가이드 및 샘플 확인.
- **완료 증거:** 정확한 파일 두 개 작성, `USER REQUIREMENTS`와 `AUTHOR PROPOSALS` 구분.
- **범위 밖:** 코드 구현, 추가 계획 문서.
- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다.
- **대체 검증 방법:** 요구사항 표와 문서 가이드 대조.
**Files:** `docs/20261029_가로형서브배너/prd.md`, `docs/20261029_가로형서브배너/plan-task.md`
- [x] 요구사항, 비목표, 외부 의존, 결정 기록 작성.
- [x] 구현 계획을 문서 준비 범위로 한정.
### Task 1.2 계약 및 독립 프롬프트
**Goal 실행 `DOC-T2`:** API 제안과 독립 실행 가능한 앱·관리자 프롬프트를 작성한다.
- **시작 조건:** `DOC-T1` 문서 완료.
- **완료 증거:** 요청된 세 파일 작성, API 필드/라우트/오류 상태가 PRD와 일치, 각 prompt에 필요한 계약을 자체 포함.
- **범위 밖:** 서버 구현 지시, 실 API 연결, 특정 앱/admin 코드베이스 존재 가정.
- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다.
- **대체 검증 방법:** PRD/API/prompt 사이 필드, 인증, 주소 처리, 차단 조건 교차 점검.
**Files:** `docs/20261029_가로형서브배너/api-contract.md`, `docs/20261029_가로형서브배너/app-prompt.md`, `docs/20261029_가로형서브배너/admin-prompt.md`
- [x] endpoint와 전송 형식을 하나의 최소 제안으로 고정한다.
- [x] 두 prompt를 별도 사용 가능하게 만들고 백엔드 구현 지시를 제외한다.
- [x] 콘솔에 prompt 본문을 출력하지 않는다.
### Task 1.3 출처 표기 및 독립 사용성 보정
**Goal 실행 `DOC-T3`:** 초안 검토에서 발견한 확정 요구·제안 혼용을 고치고 프롬프트의 독립 사용성을 검증한다.
- **시작 조건:** `DOC-T2` 작성 완료와 부모 검토.
- **완료 증거:** 제안 상태, 응답 예시, 주소 조합표, URL 검증 및 순서 설명을 부모가 직접 대조.
- **범위 밖:** 새로운 제품 결정, 서버 구현 및 실제 앱·관리자 연결.
- **Files:** 같은 디렉터리의 `prd.md`, `api-contract.md`, `app-prompt.md`, `admin-prompt.md`.
- **TDD 예외 사유:** 실행 코드가 없는 문서 보정.
- **대체 검증 방법:** 아래 Progress의 직접 대조·독립 검토·공백 검사.
- [x] 사용자 확정 범위와 작성자 제안을 분리하고 두 프롬프트의 계약·검증 사례를 보완했다.
### DOC-T4 후속: 관리자 노출 기간
**Goal 실행 `DOC-T4`:** 사용자 확인을 받은 콘텐츠 배너 동등 노출 기간 설정을 기존 다섯 문서에 일관되게 반영한다.
- **시작 조건:** 사용자가 관리자 페이지에서 콘텐츠 배너와 동일하게 노출 기간 설정이 필요하다고 확인했다.
- **범위:** 기존 문서만 수정한다. 구현 계획의 TDD 예외는 문서 전용 변경으로 실행 동작이 없기 때문이다.
- **Files:** `docs/20261029_가로형서브배너/prd.md`, `docs/20261029_가로형서브배너/plan-task.md`, `docs/20261029_가로형서브배너/api-contract.md`, `docs/20261029_가로형서브배너/app-prompt.md`, `docs/20261029_가로형서브배너/admin-prompt.md`.
- **완료 기준:** 시작·종료 경계 의미, KST 입력/응답 형식, create/update 생략·null 규칙, 기간 역전 검증, 공개 응답 DTO 불변, 관리자 기간 무관 목록, 앱 기간 필터와 OS 필터가 모든 문서에서 일치한다. 관리자 프롬프트에 KST 입력, 경계 개별 해제, 오류/편집 보존 및 기간 외 기록 편집 기준을 포함한다.
- **TDD 예외 사유:** 문서 전용 변경으로 실행 동작이 없다.
- **대체 검증 방법:** 다섯 문서의 용어·JSON 예시·요청/응답 규칙 상호 대조와 공백 검사.
- **부모 검증:** 아래 후속 `DOC-T4-GATE`와 부모 작업자의 `./gradlew tasks --all` 검증은 별도로 수행한다. 과거 `DOC-GATE` 완료 기록은 유지한다.
- [x] 다섯 기존 문서에 후속 요구와 독립 프롬프트 세부사항을 반영한다.
- [x] 부모가 문서 내용을 검토하고 후속 검증을 수행했다.
#### 후속 Gate `DOC-T4-GATE`
- **시작 조건:** `DOC-T4` 문서 보완 완료.
- **완료 증거:** 기존 콘텐츠 배너 규칙과 다섯 문서 대조, 공백 검사, Gradle 문서 검증 명령 결과 기록.
- **범위 밖:** 실제 API·관리자 화면 구현 및 실연동 완료 판정.
- [x] 부모가 노출 기간 문서의 후속 검증을 완료했다.
### Phase 1 Gate `DOC-GATE`
- **시작 조건:** `DOC-T1`, `DOC-T2`, `DOC-T3` 작성 완료.
- **완료 증거:** 요청된 파일만 관리했으며 문서 내용과 경로를 대조.
- **범위 밖:** 제품 기능 구현 완료, API 제공·배포 완료 판정.
- [x] 부모 작업자가 변경 파일과 요구사항 추적을 검증했다.
`DOC-GATE`의 기존 완료 기록은 `DOC-T3` 당시 검증 기록이다. `DOC-T4` 후속 반영은 별도 `DOC-T4-GATE`와 아래 부모 검증 기록으로 판정하며 과거 완료 기록으로 대체하지 않는다.
검증 명령: 부모 작업자가 `./gradlew tasks --all`을 실행했고 `BUILD SUCCESSFUL`, 종료 코드 0을 확인했다. 상세 증거는 아래에 누적한다.
## Historical DOC-T1~DOC-T4 변경 금지
아래 지침은 당시 문서-only 작업 범위의 기록이며 새 backend 계획에는 적용하지 않는다.
- 기존 다섯 파일 외의 파일을 변경하지 않는다.
- backend, DB/schema/migration, architecture, 배포 구현 지시를 작성하지 않는다.
- 제안 경로를 실제 배포 경로라고 표현하지 않는다.
- 요청된 다섯 파일 이외의 파일을 만들지 않는다.
## Progress
### `DOC-T1` 1차 실행, 2026-10-29
- 상태: 완료
- 무엇을: PRD와 문서 준비 계획 작성.
- 왜: 앱/관리자 구현 요청 전에 사용자 요구와 문서 범위를 분명히 하기 위해.
- 어떻게: 프로젝트 작업절차·문서유지보수 가이드 및 샘플과 기존 API 근거를 읽고 요구사항을 분리해 기록.
- 남은 항목: `DOC-T2`, 부모 최종 검증.
- 다음 행동: 계약과 프롬프트 작성.
### `DOC-T2` 1차 실행, 2026-10-29
- 상태: 작성 완료, 부모 검증 대기
- 무엇을: API 제안, 독립 앱 프롬프트, 독립 관리자 프롬프트 작성.
- 왜: 별도 담당자가 현재 코드베이스나 prompt 원문 없이도 요구와 제안 계약을 이해하도록 하기 위해.
- 어떻게: PRD의 요구사항 및 차단 경계를 계약 모델·endpoint·수용 기준에 대조.
- 남은 항목: 부모 작업자의 문서 검토와 `./gradlew tasks --all`.
- 다음 행동: 부모가 Gate 검증을 기록한다.
### `DOC-T3` 후속 보정 및 `DOC-GATE`, 2026-10-29
- 상태: 문서 보정·최종 검증 완료. 제품 기능과 신규 API는 미구현이다.
- 무엇을: 사용자 확정 요구와 작성자 제안의 출처를 정정하고, 두 독립 프롬프트의 JSON 예시·8가지 주소 조합·오류 처리·multipart·null 삭제 규칙을 대조했다.
- 왜: 실제로 제공되지 않은 API나 승인되지 않은 링크 우선순위를 확정 계약처럼 전달하지 않기 위해.
- 어떻게: 부모가 다섯 파일 전체와 기존 추천/관리자 Controller·배너 DTO·공통 응답 근거를 직접 읽었다. 앱의 위험 URI scheme 표기를 `javascript`로 정정하고 대소문자 무관 검사를 명시했다.
- 독립 검토: 첫 검토 에이전트는 모델 호환성 오류로 실행되지 않았다. 대체 검토는 완료됐으며 순서 충돌 후보 1건을 보고했다. 부모 대조 결과 서버의 id 오름차순과 클라이언트의 응답 순서 보존은 양립하므로 충돌은 아니다. 오해를 줄이도록 두 프롬프트에 서버 순서와 클라이언트 재정렬 금지를 함께 적었다. 그 외 중대한 누락은 보고되지 않았다.
- 명령 검증: `./gradlew tasks --all` 재실행은 `BUILD SUCCESSFUL in 999ms`, 종료 코드 0이었다. 기존 Gradle deprecation 안내가 있었다.
- 공백 검증: 다섯 파일 각각 `GIT_MASTER=1 /usr/bin/git diff --no-index --check -- /dev/null 파일경로` 실행, 출력 없음·종료 코드 0. 최초 셸 검사에서 zsh의 예약 변수 `path`로 명령 검색이 실패해 `doc_path`와 절대 git 경로로 고친 뒤 재실행했다. 파일 내용 문제는 아니었다.
- 변경 범위: `GIT_MASTER=1 /usr/bin/git -c core.quotePath=false status --short --untracked-files=all`에서 해당 디렉터리의 새 Markdown 다섯 개만 확인했다. 기존 파일 변경, 커밋, 배포는 없다.
- 생략·한계: 문서만 변경하여 전체 테스트·빌드 및 실제 앱/관리자 화면 QA는 실행하지 않았다. Markdown LSP 진단 도구가 제공되지 않아 직접 내용 대조와 공백 검사로 대체했다. 신규 API 실연동은 계약 합의·제공 후 별도 검증이 필요하다.
### `DOC-T4` 후속 반영, 2026-10-29
- 상태: 문서 수정 완료, 부모 검증 대기. 기존 `DOC-GATE`의 과거 완료 상태는 변경하지 않으며 이번 검증은 `DOC-T4-GATE`로 구분한다.
- 무엇을: 콘텐츠 배너와 같은 기간 설정 요구를 기존 다섯 문서에 반영한다.
- 왜: 관리자가 가로형 서브 배너에도 예약 노출 경계를 설정하고 목록에서 기간 외 레코드까지 관리할 수 있도록 계약 제안을 명확히 하기 위해.
- 어떻게: 기존 콘텐츠 배너 예약 PRD와 구현을 근거로 KST 분 정밀도, 종료 분 inclusive 경계, 생성/수정 null 의미를 문서화한다. 공개 `SubBanner`는 유지하고 기간 필터는 앱 조회 보장으로만 정의한다.
- 남은 항목: 부모 작업자의 문서 검토, `./gradlew tasks --all` 및 최종 문서 검증.
- 변경 범위: 지정된 기존 다섯 Markdown만 수정. 코드·신규 파일·API 구현은 없다.
- 생략·한계: 이 후속 문서 작업에서는 Gradle, 런타임 및 배포 검증을 수행하지 않았다. 신규 API는 제안 상태이며 구현·승인은 별도다.
### `DOC-T4-GATE` 부모 검증, 2026-10-29
- 상태: 문서 후속 검증 완료. 신규 API와 실제 관리자·앱 기능의 구현 완료를 뜻하지 않는다.
- 무엇을·왜: 다섯 문서를 직접 읽고 기존 콘텐츠 배너의 PRD·Service·응답 formatter·노출 조건과 대조하여 날짜 계약의 차이를 방지했다.
- 대조 결과: KST 분 입력/응답과 초 단위 포함 경계를 구분했고, null/null·한쪽 경계·동일 분·역전/잘못된 입력·수정 생략 유지와 null 해제 규칙이 일치한다. 관리자 목록은 기간 무관, 공개 목록은 기간 내 항목만 반환하며 공개 DTO와 기존 OS 링크 필터는 유지한다.
- 보정: 관리자 응답의 기존 필드 타입을 보존하고 요청·응답 JSON 예시의 기간을 맞췄다. 기간만 수정할 때 기존 주소와 이미지를 보존하도록 명시했으며, 과거 Gate와 후속 Gate를 분리했다.
- 명령: `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`, 종료 코드 0. 다섯 파일 각각 `GIT_MASTER=1 /usr/bin/git diff --no-index --check -- /dev/null 파일경로`도 출력 없음·종료 코드 0이었다.
- 범위: `GIT_MASTER=1 /usr/bin/git -c core.quotePath=false status --short --untracked-files=all`에서 기존 서브 배너 Markdown 다섯 개만 확인했다. 새 문서·코드 변경·커밋·배포는 없다.
- 생략·한계: 문서 변경이므로 전체 테스트·빌드 및 실제 관리자/앱 UI·API 연동은 검증하지 않았다. Markdown LSP 도구 미제공으로 직접 계약 대조와 공백 검사로 대체했다. 신규 API는 계약 제안 상태를 유지한다.
## Phase 2: 데이터 모델 및 기간 규칙
**Phase 결과:** 새로운 독립 테이블과 도메인/persistence 계층에서 배너를 저장하고 placement, 주소, 기간 규칙을 보장한다.
**선행조건:** 부모가 backend 요구사항과 제안 API를 검토하고 구현 착수를 별도 승인. 과거 Phase 1/DOC Gate 완료는 backend 구현 승인이 아니다.
### Task 2.1 독립 데이터 모델, 기간 처리와 DDL
**Goal 실행 `P2-T1`:** `SubBanner` 저장 모델과 독립 SQL artifact를 정의하고 DB 왕복으로 초 정밀도 및 기간 규칙을 증명한다.
- **요구사항:** `SB-001`, `SB-002`, `SB-006`, `SB-010`.
- **완료 증거:** 도메인 및 repository test, DDL의 테이블/컬럼 comment와 nullable UTC second-precision period, JPA flush/clear/reload 결과 기록.
- **시작 조건:** backend 착수 승인. 첫 구현 checkpoint에서 GIF 허용 여부와 S3 object 삭제/교체 실패 시 정책을 검토하고 사용자 확인이 필요한 선택은 구현 전에 결정 기록에 추가한다. 권고안은 기존 Tika `image/*` 검증을 재사용하고 GIF 허용, 기존 DB 변경 없음, S3 자동 cleanup 미보장이다.
- **범위 밖:** API/controller, multipart upload, 운영 DB 적용, 기존 content_banner migration.
- **TDD 예외 사유:** 없음.
**Files (모두 PROPOSED):**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerPlacement.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt`
- Create: `docs/20261029_가로형서브배너/create-sub-banner.sql`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt`
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt`
**Interfaces:**
- Produces: `enum class SubBannerPlacement { HOME_RECOMMENDATION, CONTENT_RECOMMENDATION }`; `data class SubBanner(val id: Long? = null, val placement: SubBannerPlacement, val imagePath: String, val webUrl: String?, val androidDeepLink: String?, val iosDeepLink: String?, val startDate: LocalDateTime?, val endDate: LocalDateTime?)`; `interface SubBannerRepository { fun findById(id: Long): SubBanner?; fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner>; fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner>; fun save(subBanner: SubBanner): SubBanner; fun deleteById(id: Long) }`. `imagePath`는 S3 object key이며 DTO에서만 기존 `cloud.aws.cloud-front.host`와 조합해 `imageUrl`을 만든다.
- Consumes: 기존 `BaseEntity`, QueryDSL/JPA와 프로젝트 DDL convention. 만든 repository의 `findVisibleByPlacement`에서 null 경계 독립 unbounded, 양끝 포함, id asc 정렬.
제안 테이블은 `sub_banner`이며 아래 매핑을 `SubBannerEntity` 및 향후 SQL에 함께 적용한다. 이는 검토용 저장 설계이지 기존 테이블이나 확정된 운영 스키마가 아니다.
| 컬럼 | 제안 MySQL 타입·규칙 | 매핑 |
|---|---|---|
| `id` | `BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY` | `BaseEntity.id` |
| `placement` | `VARCHAR(32) NOT NULL` | `SubBannerPlacement`, enum 이름 저장 |
| `image_path` | `LONGTEXT NOT NULL` | 서버가 생성한 S3 object key, 절대 URL 아님 |
| `web_url`, `android_deep_link`, `ios_deep_link` | 각각 `LONGTEXT NULL` | 기존 URI를 255자 등으로 잘라 저장하지 않음 |
| `start_date`, `end_date` | 각각 `TIMESTAMP NULL DEFAULT NULL` | UTC 초 단위, H2 검증 컬럼은 `TIMESTAMP(0)` |
| `is_active` | `BOOLEAN NOT NULL DEFAULT TRUE` | `SubBannerEntity.isActive`; P5 후속 소프트 삭제 시 false |
| `created_at` | `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` | `BaseEntity.createdAt` |
| `updated_at` | `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` | `BaseEntity.updatedAt` |
모든 컬럼·테이블에 MySQL COMMENT를 붙이고 `(placement, id)` 인덱스를 둔다. `BaseEntity`와 전역 시간 설정은 변경하지 않는다. DDL은 신규 table CREATE 한 개이며 rollback 제안은 데이터 보존 확인 및 별도 승인 이후 해당 신규 table만 DROP, 자동 rollback 실행은 금지다. 삭제는 승인된 구현에서 해당 DB row만 제거하는 제안이며 S3 object는 자동 삭제하지 않는다.
- [x] **RED:** 링크 불변식·기간·목록·수정·삭제의 실패 테스트를 먼저 작성했다.
- [x] **RED 확인:** 위 두 focused 클래스에서 26개 중 19개 assertion failure를 확인했다. 컴파일 실패는 RED 증거로 세지 않았다.
- [x] **GREEN:** domain, Entity, QueryDSL repository와 신규 테이블 DDL을 구현했다.
- [x] **GREEN 확인:** 같은 focused 테스트 26개 통과. 실제 H2 `TIMESTAMP(0)`의 flush/clear/reload, 긴 URI, 기간·placement·25개 초과 목록·수정·삭제를 검증했다.
- [x] **REFACTOR:** 직접 영향 배너·creatorname 회귀를 포함한 50개 테스트와 `ktlintCheck` 통과. 부모의 별도 focused 재실행도 26개 통과했다.
### Phase 2 Gate
**Goal 실행 `P2-GATE`:** 독립 schema와 시간/persistence 경계를 backend feature로 확정한다.
- **시작 조건:** `P2-T1` 완료.
- **완료 증거:** focused 테스트 통과, SQL review에서 신규 table 외 변경 없음, 결정 checkpoint 기록.
- **범위 밖:** 운영 DB 적용, app/admin 구현.
- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*'` 및 `./gradlew ktlintCheck`, 둘 다 **구현 시 실행, 이번 작업 미실행**. 전체 test/build는 공통 보안/설정 영향 또는 final Gate 등 project guide 조건에서만 실행한다.
- **Expected:** DB reload 뒤 날짜 정확도와 모든 허용 링크 조합 확인, ktlint 성공, live DDL 실행 0회.
- [x] `P2-T1` 완료 증거 확인. 독립 P2 리뷰는 명세·품질 승인, 차단 사항 없음.
- [x] DDL과 Entity의 테이블·컬럼·정밀도·index·COMMENT 일치 및 기존 데이터 비변경을 검토했다. 운영 적용·rollback은 별도 승인 대상이다.
| Goal | 선행조건 | 종속 산출물 |
|---|---|---|
| `P2-T1` | backend 착수 승인 | domain/repository 인터페이스, 신규 SQL artifact |
| `P2-GATE` | `P2-T1` | Phase 3 착수 조건 |
## Phase 3: 관리자 API 및 multipart 이미지
**Phase 결과:** ADMIN만 multipart 기반 CRUD를 수행하고, 유효성 검사 실패가 image upload/DB 변경 없이 끝난다.
**선행조건:** `P2-GATE` 완료 및 관리자 API contract 검토.
### Task 3.1 관리자 CRUD
**Goal 실행 `P3-T1`:** 관리자 CRUD와 이미지 업로드를 구현하고 요청 검증, 병합 기간, 응답 envelope를 통합 테스트로 확인한다.
- **요구사항:** `SB-003`~`SB-007`, `SB-011`, `SB-012`.
- **완료 증거:** create/list/update/delete 통합 테스트, DB 저장된 URI exact preservation, S3 object key의 기존 CDN URL 변환, 기간 KST exact roundtrip, 검증 실패 전 upload/save 없음.
- **시작 조건:** `P2-GATE`, ADMIN route/API draft review. 이미지 정책은 `P2-T1` decision checkpoint에서 확정.
- **범위 밖:** 공개 조회, ordering, cleanup scheduler, 공통 exception rewrite, 무단 400/404 보장.
**Files (Create는 신규 제안 경로, Reference는 기존 파일):**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerController.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt`
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt`
- Reference: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`의 관리자 경로는 기존 `anyRequest().authenticated()` 유지, 별도 permitAll 추가나 이 Task의 설정 수정은 없다.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt`
- 서비스 동작은 위 실제 HTTP→service→JPA 통합 테스트에서 검증한다. 같은 시나리오만 반복하는 별도 service unit test는 추가하지 않는다.
**Interfaces:**
- Consumes: `SubBannerRepository`, `SubBannerPlacement`, 기존 `S3Uploader.upload(inputStream: InputStream, bucket: String, filePath: String, metadata: ObjectMetadata? = null): String`의 반환 object key, 해당 key를 public `imageUrl`로 만드는 기존 CDN 변환 방식, `validateImage(file: MultipartFile, gifAllowed: Boolean)`.
- Produces: `SubBannerService.findAllByPlacement(placement: SubBannerPlacement): List<SubBanner>`, `create(image: MultipartFile, request: String): SubBanner`, `update(id: Long, image: MultipartFile?, request: String): SubBanner`, `delete(id: Long): Unit`. 서비스는 raw JSON의 필드 존재 여부·타입을 검사한 뒤 DTO로 변환하여 생략된 날짜와 명시적 null을 잃지 않는다. 기본 4필드의 누락도 검증한다. Controller at `/admin/sub-banners`: `GET list(placement): ApiResponse<List<AdminSubBannerResponse>>`; `POST create(image: MultipartFile, request: String): ApiResponse<AdminSubBannerResponse>`; `PUT /{id} update(id: Long, image: MultipartFile?, request: String): ApiResponse<AdminSubBannerResponse>`; `DELETE /{id} delete(id: Long): ApiResponse<Any>`. DELETE는 서비스의 Unit을 data로 전달하지 않고 `data:null`인 성공 응답을 만든다. Controller class ADMIN preauthorization. `AdminSubBannerResponse` has `id, placement, imageUrl, webUrl, androidDeepLink, iosDeepLink, startDate, endDate`; create/update request wire fields follow `api-contract.md`. 공개 서비스 메서드는 `P4-T1`에서 추가한다.
- [x] **RED:** 실제 HTTP/JWT/H2/S3 대체 경계에서 CRUD·8개 주소 조합·JSON/URI/날짜 검증·생략/null·이미지 실패·기간 무관 목록 테스트를 먼저 작성했다.
- [x] **RED:** 익명 및 모든 비관리자 역할 거부, 없는 id의 404·무변경 테스트도 작성했다.
- [x] **RED 확인:** 신규 HTTP 테스트 39개에서 assertion failure 39개를 확인했다. 이후 DST gap 회귀에서도 43개 중 2개가 기대 400/실제 200으로 실패했다.
- [x] **GREEN:** 관리자 controller·service·DTO를 구현했다. 검증 후에만 업로드/저장하며 트랜잭션·이미지 보존·row 삭제·실제 오류 계약을 적용했다.
- [x] **GREEN 확인:** 최초 39/39, DST 회귀 수정 후 실제 HTTP 43/43 통과. POST/PUT 시작·종료 gap 거부·upload 0·DB 불변과 정상 과거/overlap 왕복을 확인했다.
- [x] **REFACTOR:** 수정 전 직접 영향 회귀 161/161 및 수정 후 focused 43/43·`ktlintCheck` 통과. 파일명은 클래스명에 맞춰 단수형으로 정리했다.
### Phase 3 Gate
**Goal 실행 `P3-GATE`:** CRUD 및 업로드의 검증 순서와 계약 응답을 판정한다.
- **시작 조건:** `P3-T1` 완료.
- **완료 증거:** multipart integration test, 저장 후 DB 확인, validation-failure 무변경 증거, 공통 응답 envelope 대조.
- **범위 밖:** public GET, browser UI, S3 object cleanup 보장.
- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.AdminSubBannerControllerIntegrationTest'`와 `./gradlew ktlintCheck`. 실제 security 공통 영향을 수정했을 때는 `./gradlew test`로 검증을 확대한다. 실행 증거는 아래 Progress에 기록한다.
- **Expected:** 검증 실패는 upload/DB mutation 0회, 성공 POST/PUT 객체 및 DELETE null 응답, ADMIN만 변경.
- [x] `P3-T1` 완료 증거 확인. 독립 P3 리뷰의 DST blocker를 RED→GREEN으로 수정하고 scoped 재검토 승인.
- [x] 실제 400/404 Boot 오류 body, 익명 401, 비관리자·multipart 바인딩 오류의 기존 200 `success:false`를 HTTP로 확인했다. 소비자 문서의 최종 계약 동기화는 P4 최종 Gate에서 수행한다.
| Goal | 선행조건 | 종속 산출물 |
|---|---|---|
| `P3-T1` | `P2-GATE`와 API contract review | 관리자 CRUD endpoint 및 request/response DTO |
| `P3-GATE` | `P3-T1` | Phase 4 착수 조건 |
## Phase 4: 공개 기간 조회 및 보안 Gate
**Phase 결과:** 정확한 공개 GET만 익명 허용하고 현재 기간의 전체 목록을 id asc로 반환한다. 관리자/인접 method 보호를 end-to-end 확인한다.
**선행조건:** `P3-GATE` 완료.
### Task 4.1 공개 GET, 기간 filtering과 권한 통합 검증
**Goal 실행 `P4-T1`:** 익명 공개 조회와 접근 정책을 구현하고 운영 경로에 실제 공개 범위 외 노출이 없음을 검증한다.
- **요구사항:** `SB-007`~`SB-009`, `SB-011`.
- **완료 증거:** public/admin DTO 차이, placement/date/ordering, 정확한 GET 공개와 보호된 변형 경로 및 role test.
- **시작 조건:** `P3-GATE` 완료, proposed endpoint contract 확인.
- **범위 밖:** app OS link filtering, UI, pagination, active toggle, scheduler.
**Files (신규 경로 PROPOSED):**
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/SubBannerController.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt` (공개 조회 및 고정 시각 seam 추가)
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerResponse.kt`
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`, 정확한 `GET /api/v2/sub-banners`만 permitAll.
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/SubBannerControllerIntegrationTest.kt`
- 보안 시나리오는 같은 실제 HTTP/JWT/H2 context의 `SubBannerControllerIntegrationTest`에 통합했다. 중복 HTTP fixture와 별도 security test 클래스는 만들지 않았다.
**Interfaces:**
- Consumes: `P2-T1`의 `SubBannerRepository.findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner>`와 `P3-T1`의 CDN URL 조립 규칙.
- Produces: `SubBannerService.findVisibleByPlacement(placement: SubBannerPlacement): List<SubBanner>`와 `SubBannerController.getSubBanners(placement: SubBannerPlacement): ApiResponse<List<SubBannerResponse>>`. 서비스는 기존 `nowProvider: () -> LocalDateTime` 주입 패턴으로 UTC 시각을 한 번 얻어 초 단위 절삭 후 같은 값을 repository에 전달한다. public DTO는 정확히 `id, placement, imageUrl, webUrl, androidDeepLink, iosDeepLink`, 날짜 미포함. `SecurityConfig`에서 해당 GET만 익명 허용.
- [x] **RED:** 고정 시각·실제 DB·HTTP로 23개 공개 조회/권한 테스트를 작성했다. 두 placement, 전체 25개 목록, 빈 결과, nullable·초·소수초·자정 경계, 6필드 DTO 및 OS 비필터를 포함한다.
- [x] **RED 확인:** 공개 경로 미구현 상태에서 23개 중 22개 assertion failure(기대 200/400, 실제 401)를 확인했다.
- [x] **GREEN:** public controller/DTO·서비스 조회 및 정확한 GET matcher 한 줄을 추가했다. 기존 권한·추천 응답은 유지했다.
- [x] **GREEN 확인:** 실제 HTTP 23/23 통과. GET 외 method·HEAD·인접 경로·관리자 보호와 요청 시각 1회 조회를 검증했다.
- [x] **REFACTOR:** 기존 홈/오디오 추천 회귀 포함 96/96 및 ktlint 통과. 부모의 최종 전체 suite에서도 신규 92개를 포함한 2,676개가 통과했다.
### Phase 4 Gate
**Goal 실행 `P4-GATE`:** 신규 public/admin endpoint의 데이터·security·API 계약을 최종 검증한다.
- **시작 조건:** `P4-T1` 완료.
- **완료 증거:** Phase 2~4 focused tests 통과, 신규 SQL review, security behavior와 실제 공통 오류 응답 기록.
- **범위 밖:** API 배포/운영 DB 적용, 앱·관리자 client 연동 완료 주장.
- **검증:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*'` 및 관련 추천 회귀로 확인한 뒤 보안 설정 변경과 사용자 ulw 요구에 따라 부모가 `./gradlew test ktlintCheck bootJar --continue --console=plain`을 실행했다. 결과는 아래 최종 검증 기록을 따른다.
- **직접 영향 회귀:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest'`는 **구현 시 실행, 이번 작업 미실행**이다. 기존 추천 응답이 바뀌지 않았는지 확인한다.
- **Expected:** focused test 성공, backend artifact bootJar 성공, exact public GET만 anonymous, 관리자 CRUD ADMIN-only, 전체 8 조합 및 기간/DB 정밀도 규칙 통과. 운영 DDL 실행과 app integration 완료 판정 0건.
- [x] `P4-T1` 및 모든 선행 Gate 증거 확인. 전체 테스트·lint·bootJar와 독립 최종 코드 리뷰 통과.
- [x] API 계약·앱/관리자 프롬프트를 실제 구현에 맞추고 미배포·운영 DB 미적용 상태를 구분했다.
| Goal | 선행조건 | 종속 산출물 |
|---|---|---|
| `P4-T1` | `P3-GATE` | public GET, 기간 query, exact security matcher |
| `P4-GATE` | `P2-GATE`, `P3-GATE`, `P4-T1` | backend 구현 검증, 별도 client integration 재개 조건 |
### 백엔드 실행 의존성
```text
P2-T1 → P2-GATE → P3-T1 → P3-GATE → P4-T1 → P4-GATE
```
backend checkbox는 실제 검증 후 갱신한다. 문서 작성/부모 검토 완료만으로 구현 완료를 표시하지 않는다. 사용자 구현 승인에 따라 순차적으로 한 개의 Goal만 active한다.
## Current Progress
### `DOC-T5` 문서 변환 초안, 2026-10-29
- 상태: 작성 초안 완료, 작성자 대조 및 부모 검증 대기.
- 무엇을: 기존 `prd.md`와 `plan-task.md`에 backend requirements와 `P2`~`P4` 향후 implementation plan을 추가했다. DOC-T1~4 역사적 checkbox/Gate/Progress는 유지했다.
- 왜: 앱·관리자 실제 구현 전에 서버 기능과 persistence를 codebase 근거에 맞게 검토할 수 있도록 하기 위해.
- 어떻게: API draft, project guides 및 기존 security, S3, image validation, reservation, exception, JPA test patterns를 대조했다. API 전면 승인·배포·구현 상태로 표현하지 않았다.
- 검증: PRD ID와 Goal, exact paths/signatures, proposed API shapes, 향후 DDL, historical checkboxes 및 미착수 backend checkboxes를 직접 대조했다. 이번 작업에서 Gradle/test/build를 실행하지 않았다.
- 남은 항목: 작성자 문서 대조 및 부모 검토. backend 구현은 별도 승인 대기.
- 다음 행동: 부모가 이 문서 Gate를 검증하고 반영한다.
### `DOC-T5` 부모 최종 문서 검증, 2026-10-29
- 상태: 백엔드 PRD·계획 문서 보완 및 문서 검증 지침 수정 완료. 백엔드 구현은 미착수이며 `P2-T1`과 모든 후속 구현 Task/Gate는 미완료다.
- 무엇을·왜: 프롬프트 작성 계획을 실제 백엔드 개발에 사용할 수 있는 요구사항·파일·인터페이스·TDD·Gate 계획으로 보완하고, 문서만 바꿀 때 불필요한 Gradle 실행을 제거했다.
- 문서 대조: `SB-001`~`SB-012`를 `P2-T1`·`P3-T1`·`P4-T1`과 연결했다. 사용자 확정과 작성자 제안을 구분하고, 8개 주소 존재 조합 중 모두 없는 경우만 거부·나머지 7개 허용으로 PRD/테스트 이름/계약을 맞췄다. 기간 형식·생략/null·노출 경계·관리자/공개 DTO는 기존 API 초안과 일치한다.
- 파일·책임 대조: 신규 파일은 모두 제안 경로로 표시했다. 저장 object key인 `imagePath`와 응답 `imageUrl`을 구분하고 각 Phase의 서비스 제공 메서드, 변경 파일, 신규 테이블 매핑을 일치시켰다. 기존 구현과 회귀 테스트 경로를 직접 확인했다.
- 이력 대조: `DOC-T1`~`DOC-T4`의 완료 체크박스와 과거 Gradle 실행 결과는 삭제하거나 새 작업 증거로 재사용하지 않았다. 새 백엔드 Task/Gate의 체크박스는 모두 미체크다.
- 공백 검사: `GIT_MASTER=1 /usr/bin/git diff --check -- docs/agent-guides/문서유지보수.md`와 PRD/계획 각각의 `git diff --no-index --check -- /dev/null 파일경로`는 출력 없음·종료 코드 0이었다.
- 범위 대조: 현재 승인 범위에서 수정한 파일은 이 PRD·계획 및 `docs/agent-guides/문서유지보수.md` 세 개다. 이전 작업에서 존재한 앱/관리자 prompt와 API 초안은 변경 대상에 넣지 않았다. 새 코드·SQL·빌드 설정·배포 변경은 없다.
- 미실행·사유: 사용자 승인과 수정한 문서 전용 검증 지침에 따라 이번 작업에서는 `./gradlew tasks --all`·테스트·빌드를 실행하지 않았다. 위 구현 단계의 명령들은 향후 검증 계획이며 이번 실행 결과가 아니다. 실제 API 동작과 배포는 검증하지 않았다.
- 다음 행동: PRD/API 제안을 검토하고 별도로 구현 착수를 승인한 뒤 `P2-T1`부터 재개한다. 이미지/GIF 및 S3 객체 보존 정책의 검토 checkpoint를 건너뛰지 않는다.
### 백엔드 구현 착수, 2026-10-29
- 승인: 사용자가 "문서대로 백엔드 코드, DDL 생성 진행"을 요청했다. 기존 설계의 독립 리소스·API·권한·기간 계약을 구현 기준으로 사용한다. 운영 DB 및 AWS에 검증용 요청을 보내지 않는다.
- 실행 방식: 단일 구현 담당자에게 단계별 작업을 위임하고 부모가 파일·실행 증거를 검증한다. 기존 `test` 브랜치에서 진행하며 새 worktree를 자동 생성하거나 이전 문서 변경을 되돌리지 않는다.
- 결정: GIF는 기존 이미지 검증기의 허용 옵션으로 지원하며, 삭제는 DB row에 한정하고 S3 object는 자동 삭제하지 않는다. 입력 오류/없는 리소스의 400/404는 신규 경계에서 처리하고 기존 공통 예외·권한 거부 정책은 변경하지 않는다.
- 검증 시나리오: 관리자 실제 HTTP CRUD와 7개 유효 주소 조합, 모두 빈 주소·잘못된 형식의 무변경 거부, KST 생략/null 및 DB 왕복, 기간/placement 공개 조회와 20개 초과 무절단 목록, 익명/비관리자 권한 거부 및 기존 추천 회귀를 확인한다.
- 도구 한계: 현재 host는 `lsp_diagnostics`를 제공하지 않는다. Kotlin 컴파일과 ktlint로 정적 오류를 검사하되 LSP 검증 완료로 표현하지 않는다.
- 자원: 실행 임시 root는 `/var/folders/yh/8xsbvpsj5wg2qnxzxdp11_gm0000gn/T/opencode/subbanner-ulw.kY4Cw5`다. 생성한 test/QA 자원만 종료·정리하며 기존 공유 Gradle 캐시는 삭제하지 않는다.
### `P2-T1` 및 `P2-GATE`, 2026-10-29
- 완료: `SubBanner`·placement·저장소 port·JPA Entity·QueryDSL adapter와 `create-sub-banner.sql`, domain/persistence 테스트를 생성했다.
- RED/GREEN: 두 신규 클래스 26 tests 중 의도한 assertion failure 19개 확인 후 26/26 통과. 기존 배너 baseline 19/19, 직접 영향 배너·creatorname 포함 Gate 50/50 및 전체 `ktlintCheck` 통과.
- 부모 검증: 신규 두 클래스 focused 명령을 다시 실행해 `BUILD SUCCESSFUL in 16s`, XML domain 2 + persistence 24, failures/errors/skipped 모두 0을 확인했다. 전체 변경 소스·테스트·DDL을 읽었고 독립 리뷰도 P2 승인이다.
- 결정: 기간 저장 경계는 repository에서 한 번 초 절삭한 값으로 저장/반환해 DB 반올림을 방지한다. 현재 UTC 시각도 repository query 경계에서 초 절삭한다. 서비스의 UTC 분 입력 정규화와 함께 사용한다.
- 한계: 로컬 MySQL 실행 파일과 실행 중 Docker daemon이 없어 운영 MySQL 적용을 검증하지 않았다. H2 MySQL mode와 DDL/Entity 대조로 확인했으며 운영 DB에는 적용하지 않았다. 전체 suite/HTTP는 후속 Gate에서 수행한다.
- 별도 DDL 직접 검증: task-owned Java/Hibernate driver가 작성한 SQL을 독립 H2에 적용한 뒤 실제 repository CRUD·초 정밀도·기간·placement·잘못된 주소 조합을 검증했다. `MANUAL QA PASS`, `BUILD SUCCESSFUL in 6s`. 초기 driver naming 설정 및 Java fluent setter 오류를 driver에서만 수정했으며 제품 코드는 변경하지 않았다.
### `P3-T1` 및 `P3-GATE`, 2026-10-29
- 완료: 관리자 CRUD·service·request/admin response DTO·실제 random-port JWT HTTP 테스트를 구현했다. 이미지 스트림은 use로 닫고 검증 전에 업로드/저장하지 않는다. 기본 4필드 필수 교체와 날짜 필드의 생략 유지/null 해제를 구분했다.
- 증거: baseline 기존 HTTP/service 5/5; 신규 scaffold RED 39 assertion failures → GREEN 39/39. 기존 배너/creatorname 포함 직접 영향 회귀 161/161, full ktlint 통과. 별도 service unit test 대신 실제 HTTP→service→JPA 경로의 비중복 검증을 유지했다.
- 리뷰 수정: `1988-05-08 02:30`의 Asia/Seoul DST gap 자동 보정을 JDK17과 실제 HTTP로 재현했다. gap 회귀 2 failures → 유효 offset 부재를 거부하도록 수정 → 43/43 및 ktlint 통과. 독립 scoped 재검토는 ADDRESSED/APPROVE다.
- 실제 응답: DELETE HTTP200 `{"success":true,"message":null,"data":null,"errorProperty":null}`. JSON/URI/기간 검증 400과 없는 id 404는 Spring Boot 오류 body. 익명 401, 모든 비관리자와 필수 multipart part 누락은 기존 정책의 HTTP200 `success:false`다.
- 저장 한계: MySQL TIMESTAMP가 표현 가능한 UTC `1970-01-01 00:00:01`~`2038-01-19 03:14:07` 밖 입력은 업로드 전에 400으로 거부한다. 이는 임의의 과거 날짜 제한이 아니다. S3 업로드 후 DB 실패 시 새 object 잔존 가능성은 보존하며 자동 정리를 추가하지 않았다.
- 부모 확인: 실제 소스·테스트를 읽고 최종 XML 43 tests/failures0/errors0/skipped0을 확인했다. P4 후 부모가 전체 HTTP·회귀·빌드를 다시 실행한다.
### `P4-T1` 및 `P4-GATE` 최종 실행, 2026-10-29
- 완료: 공개 controller/6필드 DTO, UTC nowProvider를 한 번 읽는 read-only 서비스 조회, 정확한 `GET /api/v2/sub-banners` 허용 matcher 한 줄과 실제 HTTP 테스트를 구현했다. 기존 추천 API 응답·테이블·공통 예외 정책·dependency는 변경하지 않았다.
- RED/GREEN: 공개 HTTP 23개 중 22개 assertion failures(기대 200/400, 실제 401) → 23/23 통과. JSON fixture의 LongNode/IntNode 차이는 기대 map의 serialize/readTree로 해소하고 전체 JSON tree 비교는 유지했다. 공개·관리자 보안 테스트는 동일 실제 HTTP context로 통합했다.
- 관련 회귀: 신규 92개(domain 2, persistence 24, 관리자 HTTP 43, 공개 HTTP 23) + 기존 홈/오디오 추천 4개 = 96/96, ktlint 통과.
- 부모 최종 명령: `./gradlew test ktlintCheck bootJar --continue --console=plain` → exit 0, `BUILD SUCCESSFUL in 12m 27s`. `build/reports/tests/test/index.html`의 총 2,676 tests, failures 0, ignored 0, 성공률 100%를 확인했다. `build/libs/sodalive-0.0.1-SNAPSHOT.jar` 생성 확인. 현재 코드로 전체 실제 HTTP 테스트도 재실행됐다.
- 실제 HTTP 증거: 관리자 POST/PUT 200의 관리자 8필드, DELETE 200 `{"success":true,"message":null,"data":null,"errorProperty":null}`. 익명 공개 GET은 200의 6필드 배열/빈 배열, 누락·잘못된 placement는 400. 익명 POST/PUT/DELETE/HEAD/PATCH와 인접 GET은 401이며 비관리자 admin은 기존 200 `success:false`다. 원본 응답은 `build/test-results/test/TEST-kr.co.vividnext.sodalive.v2.subbanner.adapter.in.web.*.xml`의 `HTTP EVIDENCE`에 기록돼 있다. Authorization/JWT는 출력하지 않았다.
- 기간 증거: 고정 now의 시작 `:00`, 종료 `:59.999999999` 포함, 다음 초/다음 날 제외, 한쪽·양쪽 null, 두 placement 격리, 25개 무절단 id 순서, iOS 요청에도 Android-only 행을 반환하는 서버 OS 비필터를 실제 DB·HTTP로 확인했다.
- 독립 최종 리뷰: production 11개·대응 테스트 4개·DDL·SecurityConfig diff·호출 경계를 검토한 결과 `APPROVE`, Must fix/Should fix 없음. 기존 P3 DST gap blocker의 RED→GREEN 및 재검토 승인도 확인했다. 부모가 리뷰와 별도로 전체 실행 결과를 확인했다.
- 문서: API 계약과 앱/관리자 프롬프트를 실제 경로·shape·오류·날짜 허용 범위·이미지 정책으로 갱신했다. 문서 동기화 중 잘못 적힌 경로 ID 형식의 400 보장은 제거했으며 프레임워크 바인딩 오류까지 같은 상태라고 가정하지 않는다.
- 남은 한계: 운영 DB DDL 적용·배포·실제 AWS 업로드·native MySQL 실행·앱/관리자 UI는 수행하지 않았다. DDL은 독립 H2에 실제 적용해 검증했다. host에 LSP 도구가 없어 Kotlin 컴파일/ktlint로 대체했으며 LSP clean으로 주장하지 않는다. S3 업로드 후 DB 실패 시 orphan 및 교체/삭제 후 기존 object 보존 가능성은 승인된 범위대로 남긴다.
- 종료 확인: 변경 파일의 `git diff --check` 및 신규 파일 공백 검사 통과, PRD/plan 미완료 체크박스 없음. `pgrep`에서 Gradle Test Executor·embedded Redis·일회성 QA driver 잔존 없음. 검증 요약을 본 문서에 보존한 뒤 이번 소유 run root·child roots·QA driver·note·cache를 제거하고 경로 부재를 확인했다. 기존 공유 캐시·worktree·소스·빌드 결과/테스트 보고서는 보존했으며 커밋·푸시는 하지 않았다.
## Phase 5: is_active 소프트 삭제
앞선 하드 삭제 구현·검증은 당시 완료 이력이다. 이번 사용자 결정은 이를 대체하는 후속 변경이며 기존 완료 체크박스를 되돌리지 않는다.
### Task 5.1 저장·조회·삭제 경계 변경
**Goal `P5-T1`:** DELETE가 행을 보존하며 is_active만 false로 바꾸고, 삭제된 배너가 모든 소비자 조회와 수정 경로에서 제외된다.
- 시작 조건: `DEC-010` 사용자 승인 및 기존 P2~P4 완료.
- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt`, `DefaultSubBannerRepository.kt`; `src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt`, `adapter/in/web/AdminSubBannerControllerIntegrationTest.kt`, `adapter/in/web/SubBannerControllerIntegrationTest.kt`; `docs/20261029_가로형서브배너/create-sub-banner.sql` 및 기존 PRD/API/프롬프트/계획.
- 완료 증거: DB 행과 기존 필드 보존, false 갱신, findById/관리자 목록/공개 목록 제외, 수정·재삭제 404, 기본 true와 DTO 불변을 실제 persistence/HTTP 테스트로 확인한다.
- 동시 경계: 수정 요청이 활성 entity를 로드한 뒤 다른 트랜잭션의 DELETE가 커밋하더라도, 먼저 시작한 수정이 삭제 상태를 true로 되돌리지 않아야 한다. 두 독립 EntityManager의 겹치는 트랜잭션과 새 조회 context로 재현·검증한다.
- 범위 밖: 활성 토글·복원 API·isActive 요청/응답 필드·S3 삭제·기존 완료 이력 삭제·운영 DDL 적용. 아직 미배포인 신규 CREATE DDL을 보완하며 추측한 레거시 migration은 추가하지 않는다.
- [x] **RED:** 행 보존/비활성/목록 제외·기간·placement·수정/재삭제 경계를 먼저 테스트했다. 추가 리뷰에서 발견한 stale managed entity도 두 트랜잭션으로 재현했다.
- [x] **RED 확인:** 기존 하드 삭제/컬럼 부재 assertion 8개 실패, 이후 동시 상태 덮어쓰기 회귀 1개 실패를 각각 확인했다.
- [x] **GREEN:** entity/DDL 기본 true, DELETE false 갱신, 모든 소비자 조회의 활성 필터와 entity-local DynamicUpdate를 구현했다.
- [x] **GREEN 확인:** focused 99/99 및 실제 HTTP에서 행·필드·S3 보존/미노출/404·기존 DTO/권한/기간 규칙과 동시 수정의 비복원을 확인했다.
- [x] **REFACTOR:** 직접 영향만 정리하고 ktlint·DDL 실제 H2 실행·독립 scoped 재검토를 통과했다. 전체 Gate는 별도 진행한다.
### Phase 5 Gate `P5-GATE`
- 시작 조건: `P5-T1` 완료.
- 완료 증거: 실제 HTTP 시나리오·전체 테스트·ktlint·bootJar·독립 리뷰·자원 정리 기록.
- [x] P6를 포함한 최종 전체 테스트 2,682개·lint·bootJar 및 독립 리뷰 통과. DDL의 is_active 기본 true와 entity 일치, 행 보존·조회 제외·동시 비복원을 검증했다. 자원 정리 영수증은 아래 최종 기록을 따른다.
- 도구 한계: LSP 및 native MySQL 실행 환경은 미제공이다. 컴파일/ktlint와 H2 검증으로 대체하되 운영 적용 검증으로 표현하지 않는다.
### `P5-T1` 1차 검증 및 리뷰, 2026-10-29
- baseline: 기존 서브 배너 focused 92/92 통과. 회귀 테스트를 먼저 변경해 기존 하드 삭제와 컬럼 부재로 인한 assertion 8개 실패를 확인했다.
- 1차 GREEN: entity 기본 true/nonnull, DELETE false 갱신, id/관리자/공개 활성 필터와 authored CREATE의 default true를 구현했다. focused 98/98 및 ktlint 통과. 실제 HTTP에서 DELETE 200/null, DB 행·payload·이미지·생성 시각 보존, 비활성 PUT/재DELETE404와 upload0을 확인했다.
- 독립 리뷰: 순차 삭제 경계는 통과했으나, 삭제 전 true entity를 보관한 수정 트랜잭션의 Hibernate 전체 컬럼 UPDATE가 뒤늦게 is_active=true를 덮을 수 있다는 P1 blocker를 확인했다. 기존 flush/clear 후 stale-domain 테스트와 다른 경계이므로 두 트랜잭션 회귀 RED→GREEN을 추가한다. 전체 완료 판정은 수정·재검토 후 수행한다.
### `P5-T1` 동시 경계 수정 및 Gate 진행, 2026-10-29
- 수정: `@DynamicUpdate`를 SubBannerEntity에만 추가해 payload 수정이 변경하지 않은 is_active를 SQL에 포함하지 않도록 했다. 새 잠금·버전 컬럼·외부 API는 추가하지 않았다.
- 재현: T1이 활성 managed entity를 유지한 상태에서 T2 DELETE 커밋 후 T1 payload UPDATE를 커밋했다. 제3 context에서 false를 기대했으나 true가 된 assertion RED 1건을 확인했다. 수정 뒤 persistence29/29와 focused99/99·ktlint가 통과했고 원래 stale-domain 회귀도 유지했다. 독립 재검토는 ADDRESSED/APPROVE다.
- 실제 SQL: 삭제는 `set updated_at=?, is_active=? where id=?`, 일반 payload 변경은 `set updated_at=?, web_url=? where id=?`로 관찰돼 삭제 상태가 덮이지 않았다. 테스트의 독립 EntityManager 3개와 커밋된 fixture는 finally로 정리했다.
- 전체 검증 1차: 부모의 `./gradlew test ktlintCheck bootJar --continue --console=plain`은 `bootJar` 뒤 `:test` 수행 중 실행 도구의 1,200,000ms 제한으로 종료됐다. 테스트 실패나 전체 성공을 판정할 출력은 없었다. 기존 99개 focused HTML을 전체 성공 증거로 재사용하지 않았다.
- 조사·재시도: 종료 후 Gradle Test Executor/embedded Redis 잔존 없음을 확인했다. 코드·테스트·빌드 설정은 바꾸지 않고, 테스트별 진행만 기록하는 임시 init script를 사용해 동일 전체 명령을 2,400,000ms 제한으로 한 번 재실행 중이다. 이 기록은 최종 통과 주장이 아니다.
## Phase 6: 두 추천 API에 서브 배너 통합
### Task 6.1 비로그인 추천 응답 합성 및 기존 앱 GET 제거
**Goal `P6-T1`:** 홈·콘텐츠 추천 GET에서 한 요청으로 기존 추천 데이터와 `subBanners`를 받고, 별도 서브 배너 조회 endpoint는 제공하지 않는다.
- 시작 조건: 사용자 `DEC-011` 승인, P5 focused 99개 및 독립 재리뷰 승인. P5 전체 Gate는 이번 통합 이후 최종 코드로 함께 판정한다.
- Files: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`, `dto/recommendation/HomeRecommendationResponse.kt`; `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt`, `dto/AudioRecommendationsResponse.kt`; 기존 `v2/subbanner/adapter/in/web/SubBannerController.kt` 삭제 및 `configs/SecurityConfig.kt`의 해당 단일 permitAll 제거. 두 facade/DTO 직접 생성 테스트 호출자와 기존 실제 HTTP subbanner 테스트를 추천 통합 시나리오로 갱신한다.
- 인터페이스: 두 facade는 기존 `SubBannerService.findVisibleByPlacement` 및 `SubBannerResponse.from`을 재사용한다. HOME/CONTENT placement는 해당 facade가 고정하며 클라이언트 placement query나 OS 조건을 추가하지 않는다. `data.subBanners: List<SubBannerResponse>`는 항상 존재하며 없으면 `[]`다.
- 범위 밖: 관리자 API/DDL·소프트 삭제 변경, 기존 banners 교체, 새 fallback/cache/endpoint, 앱 UI 코드, 운영 배포. 기존 추천의 익명 GET 허용은 유지한다.
- [x] **RED:** 두 추천 GET의 실제 익명 HTTP에서 subBanners·활성/기간·배열·legacy 보존·폐기 handler 부재를 검사했다.
- [x] **RED 확인:** 29개 중 26 assertion failures를 확인했다. 통합 필드 부재와 폐기 대상 경로가 여전히 200인 것을 재현했다.
- [x] **GREEN:** 두 facade/DTO에 필수 배열을 합성하고 모든 직접 생성 호출자를 수정했다. standalone controller와 해당 permitAll 한 줄을 제거했다.
- [x] **GREEN 확인:** 같은 RED 명령 29/29 및 subbanner/home/audio 전체 관련 회귀 161/161 통과. 두 실제 추천 GET에 기존 소프트 삭제/기간/권한/무절단 시나리오를 이식했다.
- [x] **REFACTOR:** API 계약·앱 프롬프트를 `data.subBanners`로 갱신했다. 기존 기능·보안 및 JSON assertion을 완화하지 않았고 ktlint와 독립 P6 리뷰를 통과했다.
### 통합 최종 Gate `P6-GATE`
- 시작 조건: P6-T1 및 P5 focused/리뷰 완료.
- [x] 소프트 삭제·두 추천 통합 focused/전체 테스트·ktlint·bootJar·독립 리뷰를 통과하고 실제 HTTP 증거를 기록했다.
- [x] 앱 프롬프트/API 문서·소프트 삭제 DDL·행 보존·비로그인 조회·폐기 endpoint 부재를 확인했다. 소유 QA 자원 정리는 최종 종료 기록에 남긴다.
### 전체 검증 범위 변경, 2026-10-29
- 사용자 추가 요청으로 변경 전 코드를 대상으로 실행 중이던 전체 검증 runner를 개별 취소했다. 소프트 삭제 구현은 유지하며 P6 통합 코드로 최종 검증을 새로 수행한다. 이는 전체 테스트 통과/실패 판정이 아니다.
- 취소 당시 Gradle wrapper/test-worker PID를 확인한 뒤 후속 ps/lsof에서 해당 프로세스 부재를 확인했다. 공유 daemon·다른 작업 프로세스는 종료하지 않았다.
### `P6-T1` 구현·focused·독립 리뷰, 2026-10-29
- 구현: HomeRecommendationFacade는 HOME_RECOMMENDATION, AudioRecommendationFacade는 CONTENT_RECOMMENDATION을 고정해 기존 SubBannerService/6필드 DTO를 재사용한다. 기존 추천 DTO의 필수 `subBanners` 배열을 추가했고 모든 기존 필드/기존 banners 매핑을 그대로 유지했다. standalone 앱 controller와 정확한 GET 허용 matcher를 제거했다. 관리자 CRUD·DDL·소프트 삭제 저장소·DynamicUpdate는 변경하지 않았다.
- TDD: 컴파일 후 RED 29개 중 26개 assertion failures → GREEN 29/29. 기존 소켓 테스트를 `RecommendationSubBannerIntegrationTest`로 이식했으며, 폐기된 placement query 검증은 두 화면의 고정 placement 및 handler 부재 검증으로 대체했다. 삭제된 테스트를 통과를 위한 우회로 사용하지 않았다.
- focused: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*' ktlintCheck --console=plain` → 161/161, failures/errors/skipped 0, `BUILD SUCCESSFUL in 2m 25s`.
- 실제 HTTP: 두 GET은 Authorization 없이 200이며 `data.subBanners:[]` 또는 정확한 6필드 목록을 반환했다. HOME의 기존 9개+신규 배열=10필드, AUDIO의 기존 8개+신규 배열=9필드 전체와 비어 있지 않은 legacy banners를 정확히 비교했다. 각 placement 25개 전체·기간 소수초/자정·삭제 행 9개 중 비활성 4개 보존·OS 비필터를 확인했다. 폐기된 경로는 인증 GET 404/익명 401이며 handler mapping에도 없다.
- 독립 P6 리뷰: APPROVE, 차단 사항 없음. 직접 생성 테스트 호출자 6파일과 실제 HTTP 이식 전체를 확인했고 이전 P5 동시 삭제 비복원 회귀도 유지됨을 확인했다.
- 부모 확인: facade/DTO 변경, 기존 테스트 diff 및 새 실제 HTTP 테스트 전체와 앱/API 문서를 읽었다. 통합 이후에는 별도 배너 fetch가 없으므로 앱 프롬프트의 오류 처리를 추천 API 오류와 개별 이미지/링크 오류로 구분했다.
- 배포 조건: 추천 API도 신규 테이블/컬럼을 조회하므로 현재 DDL 준비가 서버 배포보다 선행해야 한다. 운영 DB 적용·배포는 수행하지 않는다.
- 최종 실행: 안정된 P5+P6 코드로 테스트별 진행 로그만 추가한 임시 init script와 함께 전체 `test ktlintCheck bootJar --continue`를 실행 중이다. 이전 취소/timeout 결과나 focused 통과를 전체 통과로 대체하지 않는다.
### `P5-GATE`·`P6-GATE` 최종 전체 검증, 2026-10-29
- 결과: 안정된 최종 코드에서 `./gradlew test ktlintCheck bootJar --continue --console=plain`과 동작을 바꾸지 않는 임시 테스트 진행 로그 init script를 사용해 1회 실행했다. exit 0, `BUILD SUCCESSFUL in 15m 46s`. 전체 2,682 tests, failures/errors/skipped/ignored 모두 0이다. 부모가 fresh HTML 및 `QA_SUITE_RESULT tests=2682 failed=0 skipped=0` 원본 로그를 직접 확인했다.
- 빌드/정적 검사: `bootJar`가 이번 실행에서 생성됐고 `build/libs/sodalive-0.0.1-SNAPSHOT.jar`를 확인했다. `ktlintCheck`는 변경 없는 소스의 선행 성공 결과를 사용한 UP-TO-DATE 상태이며 전체 명령은 성공했다. Kotlin 컴파일과 선행 focused lint도 통과했다. LSP 도구 미제공을 LSP 성공으로 표현하지 않는다.
- 실제 HTTP: 최종 suite에도 `RecommendationSubBannerIntegrationTest` 24개가 실행됐다. 두 추천 GET의 Authorization 없는 200과 `data.subBanners`, 정확한 기존 root 필드/legacy banners 보존, 활성·기간·placement·25개 무절단, 폐기 경로의 handler 부재/인증 404·익명 401, admin 보호 및 삭제 행 보존을 확인했다. 이전 standalone 테스트 클래스는 source/build/report에서 사라졌고 새 클래스의 XML/HTML은 이번 실행에서 갱신됐다.
- 소프트 삭제: 실제 DELETE는 200/data:null을 유지하며 row/is_active=false/기존 payload 보존, 삭제 후 수정·재삭제404, 관리자·추천 조회 제외를 확인했다. 두 EntityManager의 겹치는 트랜잭션에서도 삭제 상태가 true로 돌아가지 않는 회귀를 유지했다. 앱·관리자에 isActive 필드나 복구 API를 추가하지 않았다.
- 리뷰: P5의 유일한 동시성 blocker는 재현 RED→DynamicUpdate GREEN 후 ADDRESSED/APPROVE. P6 독립 리뷰는 추가 차단 없이 APPROVE다. 각각의 검토와 별개로 부모가 전체 실행 결과·변경 파일·문서를 확인했다.
- 증거 위치: `build/reports/tests/test/index.html`, `build/test-results/test/TEST-*.xml`. 실제 응답은 RecommendationSubBannerIntegrationTest/AdminSubBannerControllerIntegrationTest XML의 HTTP EVIDENCE/DB EVIDENCE, 동시 경계는 SubBannerPersistenceTest XML의 CONCURRENCY EVIDENCE에 남는다. 테스트 토큰은 출력하지 않았다.
- 이전 중단 구분: P5 첫 전체 실행은 20분 도구 제한, 두 번째 진행 로그 실행은 사용자 P6 범위 변경 때문에 취소했다. 둘 다 통과 증거로 사용하지 않았으며 최종 P5+P6 전체 실행만 이번 완료 판정에 사용했다.
- 운영 한계: DDL 파일만 생성·보완했으며 native MySQL·운영 DB·실제 AWS·앱/관리자 UI·배포는 수행하지 않았다. 서버 배포 전에 현재 sub_banner/is_active DDL 준비가 필요하다. 기존 테이블을 이미 별도로 생성한 환경은 배포 담당자가 스키마 차이를 확인해야 하며 자동 migration/운영 변경은 하지 않는다.
- 종료 영수증: tracked/new 파일 공백 검사 통과, PRD/plan 미완료 체크박스 없음, production source에 폐기 `/api/v2/sub-banners` 경로 없음. 테스트 wrapper/worker·embedded Redis 잔존 없음을 확인한 뒤 이번 소유 `subbanner-soft-delete.wmREV2` root와 child root·note·진행 로그 스크립트·cache를 제거하고 경로 부재를 확인했다. 공유 Gradle daemon/cache·기존 worktree·소스·JAR·build 테스트 보고서는 보존했다. 커밋·푸시는 하지 않았다.
### 커밋 전 영향 범위 재검증, 2026-10-29
- 무엇을·왜: 커밋할 최종 변경의 서브 배너·홈·콘텐츠 추천 경계를 재검증했다. 제품 코드와 테스트는 수정하지 않았다.
- 명령: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.subbanner.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*' ktlintCheck bootJar --console=plain`.
- 결과: 종료 코드 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 준비가 필요하다.
+152
View File
@@ -0,0 +1,152 @@
# 가로형 서브 배너 백엔드 요구사항
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 소프트 삭제·두 추천 API 통합 및 전체 검증 완료, 미배포 |
| 작성일 | 2026-10-29 |
| 최종 수정일 | 2026-10-29 |
| 대상 기능 | 가로형 서브 배너 백엔드 및 영속성 |
| 관련 API 계약 | [api-contract.md](api-contract.md), 구현·실제 HTTP 검증 기준 |
| 구현 계획 | [plan-task.md](plan-task.md) |
## 요구사항 상태와 현재 승인 범위
- 기존 `USER REQUIREMENTS`는 사용자가 확정한 제품 요구이며, 아래 `USER REQUIREMENTS`와 `DEC-001`, `DEC-005`에 보존한다.
- 초기 API·저장 모델 제안은 `DEC-009`의 사용자 구현 승인에 따라 구현했으며 실제 검증한 계약은 `api-contract.md`에 기록한다. 앱 레이아웃·링크 우선순위 등 UI 추천은 백엔드 구현 완료와 구분한다.
- **현재 승인 범위는 문서대로 백엔드 코드와 DDL 파일을 생성하고 검증하는 것이다.** 운영 DB 적용·배포·앱/관리자 UI 구현은 제외한다. 앞선 문서-only 승인 이력은 Decision Log에 보존한다.
- API 및 저장 모델은 이 문서와 API 계약을 기준으로 구현하되 완료·검증·배포 상태는 구분한다. 소비자 프롬프트는 실제 구현 계약과 다른 설명이 남지 않도록 완료 시 상태를 맞춘다.
## USER REQUIREMENTS
- 가로형 서브 배너 기능이 필요하다.
- 관리자는 배너를 생성, 조회, 수정, 삭제할 수 있어야 한다.
- 웹 URL, Android 딥링크, iOS 딥링크는 각각 선택적으로 등록하며 적어도 하나가 있어야 한다. 정확히 하나만 허용하는 XOR 조건은 아니다. 세 값이 모두 없는 조합은 거부하고 나머지 7개 존재 조합은 허용한다.
- 노출 위치는 메인 홈 추천과 메인 콘텐츠 추천이며, 각 위치에 여러 배너를 조회할 수 있어야 한다.
- 앱은 서버가 반환한 배너 중 웹 URL 또는 현재 대상 OS 링크가 있는 항목을 표시한다.
- 관리자는 기존 콘텐츠 배너 예약 기간과 같은 선택적 시작·종료 기간을 설정할 수 있다.
- 삭제는 `is_active`를 이용한 소프트 삭제로 수행한다. 생성 기본값은 true이며 삭제 시 false로 변경해 행과 기존 데이터를 보존한다.
- 앱 조회는 기존 홈 추천·콘텐츠 추천 API의 `data.subBanners` 배열로 제공하며 두 GET 모두 비로그인 접근을 유지한다. 별도 앱 조회 `/api/v2/sub-banners`는 제거한다. 앱 프롬프트도 이 계약으로 갱신한다.
## 초기 제안과 구현 결정
- 한 레코드에 한 placement를 저장하고 같은 소재를 두 곳에 노출하려면 각각 등록한다. `DEC-003`의 초기 제안을 구현에 적용했으며 최초 사용자 발화로 소급하지 않는다.
- 주소는 trim 후 빈 문자열을 null로 정규화하고 URI 원문을 보존한다. `DEC-002`의 입력 처리 제안을 구현했다.
- 독립 테이블, 서버 생성 이미지 key 및 이미지 검증을 유지한다. 초기 DB row 물리 삭제는 `DEC-010`에 따라 `is_active=false` 소프트 삭제로 변경한다. 이미지의 기존 S3 object 자동 삭제는 제공하지 않는다.
## 목표와 비목표
### 목표
- **G1:** 독립 테이블과 백엔드 도메인으로 두 placement의 배너 레코드를 격리하고, 생성·조회·수정·삭제 후 DB 재조회에서도 데이터가 보존된다. (`SB-001`~`SB-003`, `SB-010`)
- **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`)
### 포함 범위
- Kotlin/Spring 도메인, application service, persistence adapter와 DTO/controller.
- 공개 단일 GET 및 관리자 CRUD endpoint, 보안 matcher, 이미지 업로드 연동.
- 신규 테이블 생성용 SQL artifact와 테스트, 시간 정밀도 및 접근 제어 검증.
### 제외 범위
- 앱 및 관리자 실제 UI 구현, 기존 대형 콘텐츠 배너 변경, 예약 스케줄러/job, 순서 편집, 별도 활성 토글·삭제 복구 UI/API, 분석.
- 새 라이브러리, 공통 예외 처리기 전면 변경, 배포 실행, 운영 DB 직접 변경.
- 기존 콘텐츠 배너 테이블 migration. 새 서브 배너 전용 테이블만 제안한다.
- idempotency key, pagination, 자동 재시도 또는 요청 간 exactly-once 보장.
## 대상 사용자와 권한
| 주체 | 허용 동작 | 거부 조건 |
|---|---|---|
| 익명 앱 클라이언트 | 지정 placement의 공개 GET | 다른 HTTP method 및 인접 경로는 기존 인증 정책 적용 |
| `ADMIN` | 모든 관리자 CRUD | 인증되지 않았거나 ADMIN이 아닌 요청은 변경 전 거부 |
## 기능 요구사항
아래 요구사항은 `DEC-009`의 구현 승인에 따라 완료했으며 각 행을 [plan-task.md](plan-task.md)의 실행 Goal·검증 증거에 연결한다. 상태 열의 초기 제안 표기는 요구사항 출처이며 현재 미구현을 뜻하지 않는다. 최종 wire 계약은 `api-contract.md`를 따른다.
| ID | 상태 | 요구사항 | 수용 기준 | 계약 / Goal |
|---|---|---|---|---|
| `SB-001` | 작성자 영속성 제안 | 서브 배너는 독립적인 테이블에 저장한다. placement는 `HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION` 하나다. | 신규 레코드가 제안 SQL의 독립 테이블에서 저장/조회되고, 기존 콘텐츠 배너 테이블에는 schema/data 변경이 없다. | §1, `P2-T1` |
| `SB-002` | API 형태 제안, 최소 주소 조건 확정 | 응답 객체는 id, placement, imageUrl, 세 nullable 링크를 가진다. 내부 저장 모델은 CDN URL 대신 object key인 imagePath를 보관한다. | 8개 존재 조합 중 모두 null인 경우는 거부하고 나머지 7개 조합은 허용한다. 관리자와 공개 DTO에만 imagePath를 절대 imageUrl로 조립한다. | §1, `P2-T1`, `P3-T1` |
| `SB-003` | CRUD 확정, 응답·정렬 제안 | 관리자는 placement별 활성 목록, 생성, 수정, 삭제를 수행한다. | GET은 삭제되지 않은 행을 기간과 무관하게 id 오름차순으로 반환한다. 생성/수정은 저장된 객체, 삭제는 `data:null`로 응답한다. | §3, `P3-T1`, `P5-T1` |
| `SB-004` | 최소 주소 조건 확정, 정규화·URI 안전 규칙 제안 | 세 링크는 각각 nullable이며 trim 후 최소 하나가 nonblank여야 한다. | 모든 8개 존재 조합을 검사하며 모두 null/blank인 경우만 거부한다. 공백뿐인 값은 null 처리한다. nonblank 값은 trim 외 원문 byte/문자열을 재인코딩하거나 정규화하지 않는다. web URL은 absolute http/https, 딥링크는 유효한 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부한다. 별도의 임의 scheme allowlist는 추가하지 않는다. | §1, `P3-T1` |
| `SB-005` | 작성자 안전 제안 | 이미지가 필수인 생성과 선택적 이미지 교체 수정을 지원한다. | MIME/type 검증 및 모든 request 검증이 S3 업로드와 DB 변경 전에 끝난다. 업로드 실패 시 기존 이미지 값은 바뀌지 않는다. DB rollback이 이미 완료된 S3 업로드를 되돌린다고 가정하지 않는다. | §3, `P3-T1` |
| `SB-006` | 사용자 확정 | 선택적 `startDate`, `endDate`를 독립적으로 설정·조회·수정·해제한다. | KST `yyyy-MM-dd HH:mm`만 받고 응답한다. 생성 누락/null은 unbounded, 수정 누락은 유지, 수정 null은 해당 경계 해제다. 병합 후 날짜 역전은 저장하지 않는다. | §1, §3, `P2-T1`, `P3-T1` |
| `SB-007` | 확정 요구, 상태 매핑은 기존 정책 확인 필요 | 관리자 GET/POST/PUT/DELETE는 ADMIN 권한이어야 한다. | 익명 및 non-ADMIN이 CRUD를 시도해도 데이터가 변하지 않는다. 테스트는 실제 공통 접근 거부 handler의 HTTP/envelope 동작을 확인하고 임의로 403을 가정하지 않는다. | §3, `P3-T1`, `P4-T1` |
| `SB-008` | 사용자 후속 확정 | 두 추천 API가 각 화면 placement의 활성·기간 내 전체 서브 배너를 함께 반환한다. | 비로그인 `GET /api/v2/home/recommendations`는 HOME_RECOMMENDATION, `GET /api/v2/audio/recommendations`는 CONTENT_RECOMMENDATION의 `data.subBanners`를 반환한다. id 오름차순·빈 배열·기간 경계·무절단 규칙은 유지하고 별도 앱 조회 endpoint를 제거한다. | §2, `P6-T1` |
| `SB-009` | 사용자 후속 확정 | 기존 추천 응답에 `subBanners`만 추가하고 다른 필드는 보존한다. | 각 배열 항목은 기존 6필드 `{id, placement, imageUrl, webUrl, androidDeepLink, iosDeepLink}`다. 기존 `banners`와 추천 콘텐츠를 교체하지 않는다. 날짜·isActive는 노출하지 않으며 OS 필터는 앱 책임이다. | §1, §2, `P6-T1` |
| `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<T>` 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` |
## API 및 데이터 계약
- 앱 조회: `GET /api/v2/home/recommendations`, `GET /api/v2/audio/recommendations`의 `data.subBanners` 배열. 비로그인 허용, 화면별 placement 고정, 페이지네이션 없음, id 오름차순. 별도 `/api/v2/sub-banners`는 제거한다.
- 관리자: `GET/POST /admin/sub-banners`, `PUT/DELETE /admin/sub-banners/{id}`, ADMIN 전용. GET은 필수 placement query.
- 성공은 HTTP 200과 공통 `ApiResponse` `{success,message,data,errorProperty}`. POST/PUT multipart에는 `image`와 JSON `request`; POST image 필수, PUT image 선택. PUT의 기존 네 필드는 필수 replacement이고 날짜만 선택적 보존/해제다.
- 두 추천 응답의 `subBanners` 항목은 기본 6필드, 관리자 DTO는 날짜 문자열 두 필드를 더한다. 추천 응답의 기존 `banners` 등 모든 기존 필드는 유지한다.
- 관리자 목록은 삭제되지 않은 활성 행의 모든 기간 상태를 반환한다. 공개 조회는 활성 행에 현재 기간 필터를 추가하며 앱에서 로컬 시간으로 기간 판정하지 않는다.
- 입력 검증과 없는 id는 `ResponseStatusException`을 통해 400/404 Boot 오류 body를 반환한다. 익명은 401, 유효한 비관리자 및 multipart 바인딩 오류는 기존 handler의 200 `success:false`다. 공통 예외 정책을 변경하지 않았다.
## 시간·검증·저장 규칙
- 입력 시각은 엄격한 KST `yyyy-MM-dd HH:mm` 분 정밀도만 허용한다. 초, offset, `Z`, 공백/빈 문자열, raw scalar의 문자열 외 타입, 잘못된 달력 날짜 및 병합 후 start > end는 저장/업로드 전에 거부한다.
- 시작 분은 `:00`, 종료 분은 `:59`까지 포함한다. 동일 분 경계는 유효하다. null 경계는 독립적으로 unbounded다.
- 저장 경계는 KST 입력에서 한 번 정규화한 UTC 초 단위 값이며, 그 값을 영속화와 관리자 응답 변환에 공통 사용한다. DB flush/clear/reload 후 시작 `:00`·종료 `:59`와 관리자 날짜 문자열을 정확히 왕복 비교한다. 공개 조회의 현재 시각은 한 번만 얻어 초 단위로 절삭한다. 이 시각 seam에 나노초 `0`, `123456789`, `999999999` 및 날짜 경계를 넣어 종료 분 마지막 초와 다음 초의 판정을 확인한다. 분 형식 API에 소수초 입력을 허용하는 것은 아니다.
- 링크 입력은 JSON 원문 유형 확인 후 trim 처리한다. nonblank URI는 trim 이후 원문을 보존한다. 기존 Tika 기반 `validateImage(file, gifAllowed = true)`를 호출해 이미지 내용을 검사하고 GIF를 허용한다. 빈 파일은 거부한다. 신규 dependency나 임의 추가 크기 제한은 없다.
- 현재 `application.yml` multipart 제한은 파일 및 요청 각각 1024MB다. 이를 새 제품 상한으로 해석하거나 몰래 변경하지 않는다.
- `S3Uploader.upload(InputStream, bucket, filePath, metadata?)` 반환은 object key다. `imageUrl` 생성 시 기존 CDN 변환 관례를 사용하며 object key를 URL로 오인해 응답하지 않는다. 저장 실패 뒤 이미 업로드된 S3 객체는 자동 삭제/rollback을 보장하지 않으며 cleanup job은 범위 밖이다.
- 삭제는 `is_active=false`만 반영하고 DB 행과 S3 object를 보존한다. 관리자 목록은 활성 행의 모든 기간 상태를, 공개 목록은 활성 행 중 기간 내 항목만 반환한다.
## 근거와 제안 경계
현행 동작 근거:
- 추천 배너 구조: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/common/dto/RecommendationBannerResponse.kt`, `.../v2/api/home/adapter/in/web/HomeRecommendationController.kt`, `.../v2/api/content/recommendation/adapter/in/web/AudioRecommendationController.kt`.
- 기존 관리자 배너: `src/main/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerController.kt`, `AdminContentBannerService.kt`, `GetAdminContentBannerResponse.kt`.
- 예약 기간 규칙: `docs/20261027_콘텐츠배너예약기간/prd.md` §6-8, `src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt`, `docs/20261027_콘텐츠배너예약기간/alter-content-banner-reservation.sql`.
- 경량 헥사고날 패키지와 권한: `docs/agent-guides/코드스타일.md`, `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`.
- 응답/예외 사실: `src/main/kotlin/kr/co/vividnext/sodalive/common/ApiResponse.kt`, `SodaExceptionHandler.kt`.
- 이미지 업로드/검증: `src/main/kotlin/kr/co/vividnext/sodalive/aws/s3/S3Uploader.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageValidation.kt`.
- 테스트 관례: `src/test/kotlin/kr/co/vividnext/sodalive/admin/content/banner/AdminContentBannerControllerIntegrationTest.kt`, `AdminContentBannerServiceTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponseTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`.
신규 `v2.subbanner` 경로와 SQL은 구현되어 있다. 기존 `v2.creatorname` 패턴을 따른 단일 독립 기능 모듈이며 불필요한 API composition 모듈이나 utility wrapper를 추가하지 않았다.
## 수용 기준
- [x] `SB-001`~`SB-012` 각각을 구현 Goal·테스트·Gate 증거로 연결했다.
- [x] 신규 테이블, 기간 및 기본 CRUD는 기존 콘텐츠 배너 schema/data를 변경하지 않는다.
- [x] 관리자 기간 경계를 실제 DB flush/clear/reload와 HTTP 날짜 왕복으로 정확히 검증했다.
- [x] 익명 public GET만 permitAll이며 인접 경로·다른 method·관리자 권한 보호를 실제 HTTP로 검증했다.
- [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건은 초기 구현 이력이다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 |
|---|---|---|---|---|
| 2026-10-29 | DEC-001 | 사용자 확정 | 가로형 서브 배너, 관리자 CRUD, 세 nullable 주소 중 최소 하나, 두 추천 placement, 각 목록 다중 항목, 웹 URL 또는 대상 OS 딥링크가 있는 앱 항목 표시 | 사용자 확인 |
| 2026-10-29 | DEC-002 | 작성자 제안 | trim 후 blank를 null로 만들고 nonblank URI를 변형하지 않음 | 입력 안전성 제안, 승인되지 않음 |
| 2026-10-29 | DEC-003 | 작성자 제안 | API 경로·모델·정렬 순서·단일 placement 레코드 정책 | 일관된 최소 계약 제안, 승인 및 서버 제공 대기 |
| 2026-10-29 | DEC-004 | 작성자 제안 | 대상 OS 딥링크 우선 및 실패 시 웹 URL fallback | 앱 이동 정책 제안, 승인되지 않음 |
| 2026-10-29 | DEC-005 | 사용자 확정 | 관리자에서 콘텐츠 배너와 같은 선택적 노출 기간을 설정한다. KST 분 형식 및 경계 의미는 기존 콘텐츠 배너 예약 동작을 따른다. | 사용자 확인 및 기존 콘텐츠 배너 근거 |
| 2026-10-29 | DEC-006 | 사용자 승인, 범위 제한 | 이번 승인으로 기존 디렉터리의 PRD와 plan-task를 백엔드 요구사항 및 구현 계획으로 완성한다. 이번 변경은 문서 전용이며 제안 API 전체 승인이나 실제 구현 승인이 아니다. | 사용자 요청의 현재 작업 승인 범위 |
| 2026-10-29 | DEC-007 | 작성자 구현 권고, 검토 필요 | 독립 `v2.subbanner` 모듈과 제안된 신규 테이블을 사용한다. 이미 업로드된 S3 객체의 DB rollback 후 자동 정리는 보장하지 않으며 삭제 정책은 첫 구현 Task에서 확인한다. | 현행 코드 패턴과 S3/DB transaction 한계. 사용자 제품 결정으로 간주하지 않음 |
| 2026-10-29 | DEC-008 | 사용자 승인 | 문서 전용 작업의 검증은 문서 대조와 공백 검사로 한정하고 Gradle·테스트·빌드는 실행하지 않는다. 향후 구현용 명령을 문서에 적는 것만으로 실행 검증을 요구하지 않는다. | 사용자 승인 및 `docs/agent-guides/문서유지보수.md`의 검증 지침 수정 |
| 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 통합 및 앱 프롬프트 수정 요청 |
## 후속 사용자 요구사항: 관리자 노출 기간
- 관리자는 가로형 서브 배너의 선택적 `startDate`, `endDate`를 설정·조회·수정·해제할 수 있다.
- 두 경계는 독립적 nullable 값이다. 둘 다 null이면 기간 제한이 없고, 시작만 있으면 시작 시각부터, 종료만 있으면 종료 경계까지 노출한다. 같은 분의 시작/종료도 유효하다.
- 관리자 입력과 응답은 `Asia/Seoul`의 `yyyy-MM-dd HH:mm` 문자열이며 분 정밀도다. 시작 분은 `:00`부터 포함하고 종료 분은 `:59`까지 포함한다. 초, `Z`, offset은 받지 않는다.
- 공개 조회는 현재 기간 안에 있는 항목 전체를 반환한다. 앱은 그 결과에 기존 웹 URL/현재 OS 딥링크 필터를 적용하며 앱 로컬 시계로 기간을 계산하지 않는다.
- 공개 DTO에는 날짜·활성 필드를 추가하지 않는다. 관리자 목록은 삭제되지 않은 활성 행의 미래·현재·만료·무제한 상태를 모두 포함한다.
- 기존 근거는 `docs/20261027_콘텐츠배너예약기간/prd.md` §6-8, `AdminContentBannerService.kt` 및 `GetAdminContentBannerResponse.kt`다. 신규 계약의 승인이나 구현을 뜻하지 않는다.
+3 -1
View File
@@ -37,7 +37,9 @@
- 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다. - 테스트 클래스 추가/이동 시 단일 테스트 실행 예시를 최신 상태로 유지한다.
- `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다. - `.editorconfig` 변경 시 포맷 규칙 섹션을 동기화한다.
- 연속된 하나의 작업에 대해 PRD 또는 구현 계획/TASK 문서가 여러 개 생기지 않도록 기존 문서 재사용 여부를 먼저 확인한다. - 연속된 하나의 작업에 대해 PRD 또는 구현 계획/TASK 문서가 여러 개 생기지 않도록 기존 문서 재사용 여부를 먼저 확인한다.
- 문서 변경 후 최소 한 번 `./gradlew tasks --all`로 명령 유효성을 확인한다. - 문서 전용 변경은 문서 간 정합성 대조와 공백 검사까지만 수행한다. `./gradlew tasks --all`, 테스트, 빌드는 실행하지 않는다.
- 코드·빌드 설정·실행 스크립트가 함께 변경되거나 사용자가 실행 검증을 명시적으로 요청한 경우에만 변경 범위에 필요한 명령을 선택해 검증한다.
문서의 명령 예시나 향후 구현 단계의 검증 명령을 추가한 것만으로는 실행 검증을 요구하지 않는다. 생략한 검증과 사유는 작업 기록에 남긴다.
- 운영 DB 반영용 DDL 문서는 MySQL 기준으로 작성한다. - 운영 DB 반영용 DDL 문서는 MySQL 기준으로 작성한다.
- DDL 작성 시 날짜/시간 표시 컬럼은 `TIMESTAMP` 타입을 사용하고, `created_at`은 `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각'`, `updated_at`은 `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각'` 형식을 기본으로 한다. - DDL 작성 시 날짜/시간 표시 컬럼은 `TIMESTAMP` 타입을 사용하고, `created_at`은 `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각'`, `updated_at`은 `TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각'` 형식을 기본으로 한다.
- DDL의 모든 컬럼에는 MySQL `COMMENT`를 추가하고, 테이블에도 가능한 경우 `COMMENT`를 남긴다. - DDL의 모든 컬럼에는 MySQL `COMMENT`를 추가하고, 테이블에도 가능한 경우 `COMMENT`를 남긴다.
@@ -188,9 +188,11 @@ class SecurityConfig(
.antMatchers("/live/recommend/channel").permitAll() .antMatchers("/live/recommend/channel").permitAll()
.antMatchers(HttpMethod.GET, "/live/room").permitAll() .antMatchers(HttpMethod.GET, "/live/room").permitAll()
.antMatchers(HttpMethod.GET, "/event").permitAll() .antMatchers(HttpMethod.GET, "/event").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/events/*").permitAll()
.antMatchers(HttpMethod.GET, "/live/recommend").permitAll() .antMatchers(HttpMethod.GET, "/live/recommend").permitAll()
.antMatchers("/ad-tracking/app-launch").permitAll() .antMatchers("/ad-tracking/app-launch").permitAll()
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll() .antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
.antMatchers(HttpMethod.GET, "/api/v2/notices/latest").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/character/main").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/room/list").permitAll()
.antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll() .antMatchers(HttpMethod.GET, "/api/chat/original/list").permitAll()
@@ -4,14 +4,22 @@ import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse import kr.co.vividnext.sodalive.v2.api.content.recommendation.dto.AudioRecommendationsResponse
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@Component @Component
class AudioRecommendationFacade( class AudioRecommendationFacade(
private val queryService: AudioRecommendationQueryService private val queryService: AudioRecommendationQueryService,
private val subBannerService: SubBannerService,
@Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String
) { ) {
fun getRecommendations(member: Member?, lang: Lang): AudioRecommendationsResponse { fun getRecommendations(member: Member?, lang: Lang): AudioRecommendationsResponse {
val result = queryService.getRecommendations(member, lang) val result = queryService.getRecommendations(member, lang)
return AudioRecommendationsResponse.from(result) val subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION)
.map { SubBannerResponse.from(it, cloudFrontHost) }
return AudioRecommendationsResponse.from(result, subBanners)
} }
} }
@@ -6,9 +6,11 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse
data class AudioRecommendationsResponse( data class AudioRecommendationsResponse(
val banners: List<RecommendationBannerResponse>, val banners: List<RecommendationBannerResponse>,
val subBanners: List<SubBannerResponse>,
val originalSeries: List<OriginalSeriesResponse>, val originalSeries: List<OriginalSeriesResponse>,
val latestAudios: List<AudioCardResponse>, val latestAudios: List<AudioCardResponse>,
val newAndHotAudios: List<AudioCardResponse>, val newAndHotAudios: List<AudioCardResponse>,
@@ -18,9 +20,10 @@ data class AudioRecommendationsResponse(
val recommendedAudios: List<AudioCardResponse> val recommendedAudios: List<AudioCardResponse>
) { ) {
companion object { companion object {
fun from(recommendations: AudioRecommendations): AudioRecommendationsResponse { fun from(recommendations: AudioRecommendations, subBanners: List<SubBannerResponse>): AudioRecommendationsResponse {
return AudioRecommendationsResponse( return AudioRecommendationsResponse(
banners = recommendations.banners.map(RecommendationBannerResponse::from), banners = recommendations.banners.map(RecommendationBannerResponse::from),
subBanners = subBanners,
originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from), originalSeries = recommendations.originalSeries.map(OriginalSeriesResponse::from),
latestAudios = recommendations.latestAudios.map(AudioCardResponse::from), latestAudios = recommendations.latestAudios.map(AudioCardResponse::from),
newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from), newAndHotAudios = recommendations.newAndHotAudios.map(AudioCardResponse::from),
@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.v2.api.event.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.event.application.EventDetailService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/events")
class EventDetailController(private val service: EventDetailService) {
@GetMapping("/{id}")
fun getEvent(
@PathVariable id: Long,
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
) = ApiResponse.ok(service.getEvent(id, member))
}
@@ -0,0 +1,55 @@
package kr.co.vividnext.sodalive.v2.api.event.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.springframework.beans.factory.annotation.Value
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@Service
class EventDetailService(
private val repository: EventRepository,
private val authRepository: AuthRepository,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
@Transactional(readOnly = true)
fun getEvent(id: Long, member: Member?): EventItem {
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
val event = repository.findByIdOrNull(id)
?: throw SodaException(messageKey = "common.error.invalid_request")
val now = LocalDateTime.now()
val lang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
if (!event.isActive || event.startDate > now || event.endDate < now || event.lang != lang) {
throw SodaException(messageKey = "common.error.invalid_request")
}
if (member?.role != MemberRole.ADMIN) {
val isAdult = member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
if (event.isAdult != null && event.isAdult != isAdult) {
throw SodaException(messageKey = "common.error.invalid_request")
}
}
return EventItem(
id = id,
thumbnailImageUrl = imageUrl(event.thumbnailImage),
detailImageUrl = event.detailImage?.let { imageUrl(it) },
popupImageUrl = event.popupImage?.let { imageUrl(it) },
link = event.link
)
}
private fun imageUrl(image: String): String = if (image.startsWith("https://")) image else "$cloudFrontHost/$image"
}
@@ -30,6 +30,9 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendatio
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomePopularCommunityRecommendationRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
@@ -39,6 +42,7 @@ import java.time.LocalDateTime
class HomeRecommendationFacade( class HomeRecommendationFacade(
private val queryService: HomeRecommendationQueryService, private val queryService: HomeRecommendationQueryService,
private val memberContentPreferenceService: MemberContentPreferenceService, private val memberContentPreferenceService: MemberContentPreferenceService,
private val subBannerService: SubBannerService,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String private val cloudFrontHost: String
) { ) {
@@ -68,6 +72,8 @@ class HomeRecommendationFacade(
lang = bannerLang lang = bannerLang
) )
.map { RecommendationBannerResponse.from(it.toBanner()) }, .map { RecommendationBannerResponse.from(it.toBanner()) },
subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION)
.map { SubBannerResponse.from(it, cloudFrontHost) },
recentlyActiveCreators = queryService.findRecentlyActiveCreators( recentlyActiveCreators = queryService.findRecentlyActiveCreators(
HOME_ACTIVE_CREATOR_LIMIT, HOME_ACTIVE_CREATOR_LIMIT,
member?.id, member?.id,
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.home.dto.recommendation
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse import kr.co.vividnext.sodalive.v2.api.common.dto.RecommendationBannerResponse
import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerResponse
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
@@ -20,6 +21,7 @@ internal fun profileImageUrl(cloudFrontHost: String, path: String?): String {
data class HomeRecommendationResponse( data class HomeRecommendationResponse(
val lives: List<HomeLiveItem>, val lives: List<HomeLiveItem>,
val banners: List<RecommendationBannerResponse>, val banners: List<RecommendationBannerResponse>,
val subBanners: List<SubBannerResponse>,
val recentlyActiveCreators: List<HomeActiveCreatorItem>, val recentlyActiveCreators: List<HomeActiveCreatorItem>,
val recentDebutCreators: List<HomeCreatorItem>, val recentDebutCreators: List<HomeCreatorItem>,
val firstAudioContents: List<HomeFirstAudioContentItem>, val firstAudioContents: List<HomeFirstAudioContentItem>,
@@ -0,0 +1,14 @@
package kr.co.vividnext.sodalive.v2.api.notice.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.api.notice.application.LatestNoticeService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v2/notices")
class LatestNoticeController(private val service: LatestNoticeService) {
@GetMapping("/latest")
fun getLatestNotices() = ApiResponse.ok(service.getLatestNotices())
}
@@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.v2.api.notice.application
import kr.co.vividnext.sodalive.notice.NoticeItem
import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional(readOnly = true)
class LatestNoticeService(private val repository: ServiceServiceNoticeRepository) {
fun getLatestNotices(): List<NoticeItem> = repository.getNoticeList(PageRequest.of(0, 3))
}
@@ -0,0 +1,59 @@
package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.dto.AdminSubBannerResponse
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
@RestController
@RequestMapping("/admin/sub-banners")
@PreAuthorize("hasRole('ADMIN')")
class AdminSubBannerController(
private val service: SubBannerService,
@Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String
) {
@GetMapping
fun list(@RequestParam(required = false) placement: String?): ApiResponse<List<AdminSubBannerResponse>> {
val selected = SubBannerPlacement.values().firstOrNull { it.name == placement }
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner placement")
return ApiResponse.ok(service.findAllByPlacement(selected).map { AdminSubBannerResponse.from(it, cloudFrontHost) })
}
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun create(
@RequestPart("image") image: MultipartFile,
@RequestPart("request") request: String
): ApiResponse<AdminSubBannerResponse> = ApiResponse.ok(
AdminSubBannerResponse.from(service.create(image, request), cloudFrontHost)
)
@PutMapping("/{id}", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
fun update(
@PathVariable id: Long,
@RequestPart("image", required = false) image: MultipartFile?,
@RequestPart("request") request: String
): ApiResponse<AdminSubBannerResponse> = ApiResponse.ok(
AdminSubBannerResponse.from(service.update(id, image, request), cloudFrontHost)
)
@DeleteMapping("/{id}")
fun delete(@PathVariable id: Long): ApiResponse<Any> {
service.delete(id)
return ApiResponse.ok()
}
}
@@ -0,0 +1,73 @@
package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.QSubBannerEntity.subBannerEntity
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import javax.persistence.EntityManager
@Repository
class DefaultSubBannerRepository(
private val entityManager: EntityManager,
private val queryFactory: JPAQueryFactory
) : SubBannerRepository {
override fun findById(id: Long): SubBanner? = entityManager.find(SubBannerEntity::class.java, id)
?.takeIf { it.isActive }?.toDomain()
override fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner> = queryFactory
.selectFrom(subBannerEntity)
.where(subBannerEntity.placement.eq(placement), subBannerEntity.isActive.isTrue)
.orderBy(subBannerEntity.id.asc())
.fetch()
.map { it.toDomain() }
override fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner> {
val visibleAt = now.truncatedTo(ChronoUnit.SECONDS)
return queryFactory
.selectFrom(subBannerEntity)
.where(
subBannerEntity.placement.eq(placement),
subBannerEntity.isActive.isTrue,
subBannerEntity.startDate.isNull.or(subBannerEntity.startDate.loe(visibleAt)),
subBannerEntity.endDate.isNull.or(subBannerEntity.endDate.goe(visibleAt))
)
.orderBy(subBannerEntity.id.asc())
.fetch()
.map { it.toDomain() }
}
override fun save(subBanner: SubBanner): SubBanner {
val startDate = subBanner.startDate?.truncatedTo(ChronoUnit.SECONDS)
val endDate = subBanner.endDate?.truncatedTo(ChronoUnit.SECONDS)
val entity = if (subBanner.id == null) {
SubBannerEntity(
subBanner.placement,
subBanner.imagePath,
subBanner.webUrl,
subBanner.androidDeepLink,
subBanner.iosDeepLink,
startDate,
endDate
).also { entityManager.persist(it) }
} else {
entityManager.find(SubBannerEntity::class.java, subBanner.id).apply {
placement = subBanner.placement
imagePath = subBanner.imagePath
webUrl = subBanner.webUrl
androidDeepLink = subBanner.androidDeepLink
iosDeepLink = subBanner.iosDeepLink
this.startDate = startDate
this.endDate = endDate
}
}
return entity.toDomain()
}
override fun deleteById(id: Long) {
entityManager.find(SubBannerEntity::class.java, id).isActive = false
}
}
@@ -0,0 +1,45 @@
package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
import kr.co.vividnext.sodalive.common.BaseEntity
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import org.hibernate.annotations.DynamicUpdate
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.Entity
import javax.persistence.EnumType
import javax.persistence.Enumerated
import javax.persistence.Index
import javax.persistence.Table
@Entity
@DynamicUpdate
@Table(name = "sub_banner", indexes = [Index(name = "idx_sub_banner_placement_id", columnList = "placement, id")])
class SubBannerEntity(
@Enumerated(EnumType.STRING)
@Column(name = "placement", nullable = false, length = 32)
var placement: SubBannerPlacement,
@Column(name = "image_path", nullable = false, columnDefinition = "LONGTEXT")
var imagePath: String,
@Column(name = "web_url", columnDefinition = "LONGTEXT")
var webUrl: String?,
@Column(name = "android_deep_link", columnDefinition = "LONGTEXT")
var androidDeepLink: String?,
@Column(name = "ios_deep_link", columnDefinition = "LONGTEXT")
var iosDeepLink: String?,
@Column(name = "start_date", columnDefinition = "TIMESTAMP")
var startDate: LocalDateTime?,
@Column(name = "end_date", columnDefinition = "TIMESTAMP")
var endDate: LocalDateTime?,
@Column(name = "is_active", nullable = false)
var isActive: Boolean = true
) : BaseEntity() {
fun toDomain(): SubBanner = SubBanner(id, placement, imagePath, webUrl, androidDeepLink, iosDeepLink, startDate, endDate)
}
@@ -0,0 +1,191 @@
package kr.co.vividnext.sodalive.v2.subbanner.application
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.utils.generateFileName
import kr.co.vividnext.sodalive.utils.validateImage
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.dto.SubBannerRequest
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.multipart.MultipartFile
import org.springframework.web.server.ResponseStatusException
import java.net.URI
import java.net.URISyntaxException
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.format.ResolverStyle
import java.time.temporal.ChronoUnit
@Service
class SubBannerService(
private val repository: SubBannerRepository,
private val s3Uploader: S3Uploader,
private val objectMapper: ObjectMapper,
@Value("\${cloud.aws.s3.bucket}") private val bucket: String,
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) {
@Transactional(readOnly = true)
fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner> = repository.findAllByPlacement(placement)
@Transactional(readOnly = true)
fun findVisibleByPlacement(placement: SubBannerPlacement): List<SubBanner> {
val now = nowProvider().truncatedTo(ChronoUnit.SECONDS)
return repository.findVisibleByPlacement(placement, now)
}
@Transactional
fun create(image: MultipartFile, request: String): SubBanner {
val fields = readFields(request)
val input = readRequest(fields)
val startDate = parseDate(input.startDate, 0)
val endDate = parseDate(input.endDate, 59)
validateRange(startDate, endDate)
val imagePath = uploadImage(image)
return repository.save(
SubBanner(
placement = input.placement,
imagePath = imagePath,
webUrl = input.webUrl,
androidDeepLink = input.androidDeepLink,
iosDeepLink = input.iosDeepLink,
startDate = startDate,
endDate = endDate
)
)
}
@Transactional
fun update(id: Long, image: MultipartFile?, request: String): SubBanner {
val existing = findExisting(id)
val fields = readFields(request)
val input = readRequest(fields)
val startDate = if (fields.has("startDate")) parseDate(input.startDate, 0) else existing.startDate
val endDate = if (fields.has("endDate")) parseDate(input.endDate, 59) else existing.endDate
validateRange(startDate, endDate)
val imagePath = image?.let { uploadImage(it) } ?: existing.imagePath
return repository.save(
existing.copy(
placement = input.placement,
imagePath = imagePath,
webUrl = input.webUrl,
androidDeepLink = input.androidDeepLink,
iosDeepLink = input.iosDeepLink,
startDate = startDate,
endDate = endDate
)
)
}
@Transactional
fun delete(id: Long) {
findExisting(id)
repository.deleteById(id)
}
private fun findExisting(id: Long): SubBanner = repository.findById(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Sub-banner not found")
private fun readFields(request: String): JsonNode {
val fields = try {
objectMapper.readerFor(JsonNode::class.java)
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readValue<JsonNode>(request)
} catch (_: JsonProcessingException) {
invalidRequest()
}
if (!fields.isObject || !fields.path("placement").isTextual ||
SubBannerPlacement.values().none { it.name == fields["placement"].textValue() }
) {
invalidRequest()
}
listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field ->
val value = fields[field]
if (value == null || (!value.isNull && !value.isTextual)) invalidRequest()
}
listOf("startDate", "endDate").forEach { field ->
val value = fields[field]
if (value != null && !value.isNull && !value.isTextual) invalidRequest()
}
return fields
}
private fun readRequest(fields: JsonNode): SubBannerRequest {
val input = objectMapper.treeToValue(fields, SubBannerRequest::class.java)
val normalized = input.copy(
webUrl = input.webUrl?.trim()?.takeIf { it.isNotEmpty() },
androidDeepLink = input.androidDeepLink?.trim()?.takeIf { it.isNotEmpty() },
iosDeepLink = input.iosDeepLink?.trim()?.takeIf { it.isNotEmpty() }
)
if (listOf(normalized.webUrl, normalized.androidDeepLink, normalized.iosDeepLink).all { it == null }) invalidRequest()
validateUri(normalized.webUrl, web = true)
validateUri(normalized.androidDeepLink, web = false)
validateUri(normalized.iosDeepLink, web = false)
return normalized
}
private fun validateUri(value: String?, web: Boolean) {
if (value == null) return
val uri = try {
URI(value)
} catch (_: URISyntaxException) {
invalidRequest()
}
if (!uri.isAbsolute || listOf("javascript", "data", "file", "content").any { it.equals(uri.scheme, ignoreCase = true) }) {
invalidRequest()
}
if (web && ((!uri.scheme.equals("http", true) && !uri.scheme.equals("https", true)) || uri.host.isNullOrBlank())) {
invalidRequest()
}
}
private fun parseDate(value: String?, second: Int): LocalDateTime? {
if (value == null) return null
if (value.length != 16) invalidRequest()
val utc = try {
val local = LocalDateTime.parse(value, DATE_FORMAT).withSecond(second)
val zone = ZoneId.of("Asia/Seoul")
if (zone.rules.getValidOffsets(local).isEmpty()) invalidRequest()
local.atZone(zone)
.withZoneSameInstant(ZoneOffset.UTC)
.toLocalDateTime()
} catch (_: DateTimeParseException) {
invalidRequest()
}
if (utc < MIN_TIMESTAMP || utc > MAX_TIMESTAMP) invalidRequest()
return utc
}
private fun validateRange(startDate: LocalDateTime?, endDate: LocalDateTime?) {
if (startDate != null && endDate != null && startDate > endDate) invalidRequest()
}
private fun uploadImage(image: MultipartFile): String {
if (image.isEmpty) invalidRequest()
try {
validateImage(image, gifAllowed = true)
} catch (_: SodaException) {
invalidRequest()
}
return image.inputStream.use { s3Uploader.upload(it, bucket, "sub_banner/${generateFileName()}") }
}
private fun invalidRequest(): Nothing = throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid sub-banner request")
companion object {
private val DATE_FORMAT = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm").withResolverStyle(ResolverStyle.STRICT)
private val MIN_TIMESTAMP = LocalDateTime.of(1970, 1, 1, 0, 0, 1)
private val MAX_TIMESTAMP = LocalDateTime.of(2038, 1, 19, 3, 14, 7)
}
}
@@ -0,0 +1,20 @@
package kr.co.vividnext.sodalive.v2.subbanner.domain
import java.time.LocalDateTime
data class SubBanner(
val id: Long? = null,
val placement: SubBannerPlacement,
val imagePath: String,
val webUrl: String?,
val androidDeepLink: String?,
val iosDeepLink: String?,
val startDate: LocalDateTime?,
val endDate: LocalDateTime?
) {
init {
require(!webUrl.isNullOrBlank() || !androidDeepLink.isNullOrBlank() || !iosDeepLink.isNullOrBlank()) {
"At least one link must be nonblank"
}
}
}
@@ -0,0 +1,6 @@
package kr.co.vividnext.sodalive.v2.subbanner.domain
enum class SubBannerPlacement {
HOME_RECOMMENDATION,
CONTENT_RECOMMENDATION
}
@@ -0,0 +1,36 @@
package kr.co.vividnext.sodalive.v2.subbanner.dto
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
data class AdminSubBannerResponse(
val id: Long,
val placement: SubBannerPlacement,
val imageUrl: String,
val webUrl: String?,
val androidDeepLink: String?,
val iosDeepLink: String?,
val startDate: String?,
val endDate: String?
) {
companion object {
fun from(banner: SubBanner, cloudFrontHost: String): AdminSubBannerResponse = AdminSubBannerResponse(
id = requireNotNull(banner.id),
placement = banner.placement,
imageUrl = "$cloudFrontHost/${banner.imagePath}",
webUrl = banner.webUrl,
androidDeepLink = banner.androidDeepLink,
iosDeepLink = banner.iosDeepLink,
startDate = formatDate(banner.startDate),
endDate = formatDate(banner.endDate)
)
private fun formatDate(value: LocalDateTime?): String? = value?.atZone(ZoneOffset.UTC)
?.withZoneSameInstant(ZoneId.of("Asia/Seoul"))
?.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}
}
@@ -0,0 +1,12 @@
package kr.co.vividnext.sodalive.v2.subbanner.dto
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
data class SubBannerRequest(
val placement: SubBannerPlacement,
val webUrl: String?,
val androidDeepLink: String?,
val iosDeepLink: String?,
val startDate: String? = null,
val endDate: String? = null
)
@@ -0,0 +1,24 @@
package kr.co.vividnext.sodalive.v2.subbanner.dto
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
data class SubBannerResponse(
val id: Long,
val placement: SubBannerPlacement,
val imageUrl: String,
val webUrl: String?,
val androidDeepLink: String?,
val iosDeepLink: String?
) {
companion object {
fun from(banner: SubBanner, cloudFrontHost: String): SubBannerResponse = SubBannerResponse(
id = requireNotNull(banner.id),
placement = banner.placement,
imageUrl = "$cloudFrontHost/${banner.imagePath}",
webUrl = banner.webUrl,
androidDeepLink = banner.androidDeepLink,
iosDeepLink = banner.iosDeepLink
)
}
}
@@ -0,0 +1,13 @@
package kr.co.vividnext.sodalive.v2.subbanner.port.out
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import java.time.LocalDateTime
interface SubBannerRepository {
fun findById(id: Long): SubBanner?
fun findAllByPlacement(placement: SubBannerPlacement): List<SubBanner>
fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner>
fun save(subBanner: SubBanner): SubBanner
fun deleteById(id: Long)
}
@@ -67,6 +67,7 @@ class AudioRecommendationControllerTest @Autowired constructor(
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.banners").isArray) .andExpect(jsonPath("$.data.banners").isArray)
.andExpect(jsonPath("$.data.subBanners").isEmpty)
.andExpect(jsonPath("$.data.originalSeries").isArray) .andExpect(jsonPath("$.data.originalSeries").isArray)
.andExpect(jsonPath("$.data.latestAudios").isArray) .andExpect(jsonPath("$.data.latestAudios").isArray)
.andExpect(jsonPath("$.data.recommendedAudios").isArray) .andExpect(jsonPath("$.data.recommendedAudios").isArray)
@@ -93,6 +94,7 @@ class AudioRecommendationControllerTest @Autowired constructor(
private fun emptyResponse(): AudioRecommendationsResponse { private fun emptyResponse(): AudioRecommendationsResponse {
return AudioRecommendationsResponse( return AudioRecommendationsResponse(
banners = emptyList(), banners = emptyList(),
subBanners = emptyList(),
originalSeries = emptyList(), originalSeries = emptyList(),
latestAudios = emptyList(), latestAudios = emptyList(),
newAndHotAudios = emptyList(), newAndHotAudios = emptyList(),
@@ -49,6 +49,7 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
mockMvc.perform(get("/api/v2/audio/recommendations")) mockMvc.perform(get("/api/v2/audio/recommendations"))
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.subBanners").isEmpty)
.andExpect(jsonPath("$.data.originalSeries").isArray) .andExpect(jsonPath("$.data.originalSeries").isArray)
.andExpect(jsonPath("$.data.originalSeries[0].seriesId").value(fixture.seriesId)) .andExpect(jsonPath("$.data.originalSeries[0].seriesId").value(fixture.seriesId))
.andExpect(jsonPath("$.data.latestAudios").isArray) .andExpect(jsonPath("$.data.latestAudios").isArray)
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendations
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -16,7 +17,7 @@ import org.mockito.Mockito
class AudioRecommendationFacadeTest { class AudioRecommendationFacadeTest {
private val objectMapper = jacksonObjectMapper() private val objectMapper = jacksonObjectMapper()
private val queryService = Mockito.mock(AudioRecommendationQueryService::class.java) private val queryService = Mockito.mock(AudioRecommendationQueryService::class.java)
private val facade = AudioRecommendationFacade(queryService) private val facade = AudioRecommendationFacade(queryService, Mockito.mock(SubBannerService::class.java), "https://cdn.test")
@Test @Test
@DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다") @DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다")
@@ -39,6 +40,7 @@ class AudioRecommendationFacadeTest {
assertEquals(false, response.recommendedAudios[0].isPointAvailable) assertEquals(false, response.recommendedAudios[0].isPointAvailable)
val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals(objectMapper.readTree("[]"), json["subBanners"])
assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean()) assertEquals(false, json["latestAudios"][0]["isAdult"].asBoolean())
assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean()) assertEquals(false, json["latestAudios"][0]["isPointAvailable"].asBoolean())
assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean()) assertEquals(true, json["latestAudios"][0]["isFirstContent"].asBoolean())
@@ -0,0 +1,350 @@
package kr.co.vividnext.sodalive.v2.api.event
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MvcResult
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import java.time.LocalDateTime
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"cloud.aws.s3.bucket=test-bucket",
"spring.datasource.url=jdbc:h2:mem:event-detail-controller-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class EventDetailControllerIntegrationTest @Autowired constructor(
private val restTemplate: TestRestTemplate,
private val mockMvc: MockMvc,
private val eventRepository: EventRepository,
private val memberRepository: MemberRepository,
private val authRepository: AuthRepository,
private val objectMapper: ObjectMapper
) {
@MockBean
private lateinit var s3Uploader: S3Uploader
@BeforeEach
@AfterEach
fun cleanUp() {
eventRepository.deleteAllInBatch()
authRepository.deleteAllInBatch()
memberRepository.deleteAllInBatch()
}
@Test
@DisplayName("익명 상세는 기존 목록과 같은 5개 필드와 외부 링크를 반환하고 엔티티를 변경하지 않는다")
fun shouldReturnAnonymousEventDetailWithLegacyFieldsWithoutMutatingTheEntity() {
val event = saveEvent(
activeEvent().apply {
detailImage = null
popupImage = null
link = "https://external.test/promotion?from=soda&target=event#detail"
}
)
val detail = httpGet("/api/v2/events/${event.id}")
assertEquals(
objectMapper.readTree(
"""{"success":true,"message":null,"data":{
"id":${event.id},"thumbnailImageUrl":"https://cdn.test/thumbnail.png",
"detailImageUrl":null,"popupImageUrl":null,
"link":"https://external.test/promotion?from=soda&target=event#detail"
},"errorProperty":null}"""
),
detail
)
assertEquals(httpGet("/event")["data"]["eventList"].single(), detail["data"])
val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow()
assertEquals("thumbnail.png", persisted.thumbnailImage)
assertEquals(null, persisted.detailImage)
assertEquals(null, persisted.popupImage)
assertEquals(event.link, persisted.link)
}
@ParameterizedTest
@CsvSource(
"https://images.test/image.png, https://images.test/image.png",
"relative/image.png, https://cdn.test/relative/image.png",
"http://images.test/image.png, https://cdn.test/http://images.test/image.png"
)
@DisplayName("세 이미지 필드는 HTTPS만 보존하고 상대 경로와 HTTP는 기존 CDN 규칙을 따른다")
fun shouldMapAllImageFieldsLikeTheLegacyList(image: String, expectedImage: String) {
val event = saveEvent(
activeEvent().apply {
thumbnailImage = image
detailImage = image
popupImage = image
link = null
}
)
val detail = httpGet("/api/v2/events/${event.id}")["data"]
assertEquals(5, detail.size())
assertEquals(event.id, detail["id"].asLong())
assertEquals(expectedImage, detail["thumbnailImageUrl"].asText())
assertEquals(expectedImage, detail["detailImageUrl"].asText())
assertEquals(expectedImage, detail["popupImageUrl"].asText())
assertEquals(true, detail["link"].isNull)
assertEquals(httpGet("/event")["data"]["eventList"].single(), detail)
val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow()
assertEquals(image, persisted.thumbnailImage)
assertEquals(image, persisted.detailImage)
assertEquals(image, persisted.popupImage)
}
@ParameterizedTest
@CsvSource(
"DEFAULT, KO",
"KR, KO",
"JP, JA",
"US, KO",
nullValues = ["DEFAULT"]
)
@DisplayName("익명 상세의 국가별 언어와 비노출 응답은 기존 목록과 일치한다")
fun shouldApplyTheSameCountryLanguageAsTheLegacyList(country: String?, expectedLang: Lang) {
val events = listOf(Lang.KO, Lang.JA, Lang.EN).map { lang ->
saveEvent(activeEvent().apply { this.lang = lang })
}
val expected = events.single { it.lang == expectedLang }
val legacyItems = httpGet("/event", country)["data"]["eventList"]
assertEquals(listOf(expected.id), legacyItems.map { it["id"].asLong() })
events.forEach { event ->
val detail = httpGet("/api/v2/events/${event.id}", country)
if (event.id == expected.id) {
assertEquals(true, detail["success"].asBoolean())
assertEquals(legacyItems.single(), detail["data"])
} else {
assertInvalidRequest(detail)
}
}
}
@Test
@DisplayName("성인 null false true의 익명 미인증 인증 관리자 노출은 KO와 JA 모두 기존 목록과 같다")
fun shouldMatchTheLegacyAdultMatrixForAnonymousUnverifiedVerifiedAndAdminViewers() {
val unverified = saveMember("unverified")
val verified = saveMember("verified", verified = true)
val admin = saveMember("admin", role = MemberRole.ADMIN)
assertEquals(null, authRepository.getAuthIdByMemberId(requireNotNull(unverified.id)))
assertEquals(1L, authRepository.count())
listOf("KR" to Lang.KO, "JP" to Lang.JA).forEach { (country, lang) ->
val general = saveEvent(activeEvent().apply { this.lang = lang })
val nonAdult = saveEvent(
activeEvent().apply {
this.lang = lang
isAdult = false
}
)
val adult = saveEvent(
activeEvent().apply {
this.lang = lang
isAdult = true
}
)
val events = listOf(general, nonAdult, adult)
val viewers = listOf(
null to setOf(general.id, nonAdult.id),
unverified to setOf(general.id, nonAdult.id),
verified to setOf(general.id, adult.id),
admin to setOf(general.id, nonAdult.id, adult.id)
)
viewers.forEach { (member, expectedIds) ->
val legacyItems = viewerGet("/event", member, country)["data"]["eventList"]
assertEquals(expectedIds, legacyItems.map { it["id"].asLong() }.toSet())
events.forEach { event ->
val detail = viewerGet("/api/v2/events/${event.id}", member, country)
if (event.id in expectedIds) {
assertEquals(true, detail["success"].asBoolean())
assertEquals(legacyItems.single { it["id"].asLong() == event.id }, detail["data"])
} else {
assertInvalidRequest(detail)
}
}
}
}
}
@Test
@DisplayName("관리자도 활성 기간 언어 조건을 우회하지 못하고 비노출 상세는 미존재와 같다")
fun shouldRejectAllHiddenStatesEvenForAdminWhileMatchingTheLegacyList() {
val visible = saveEvent(activeEvent())
val hidden = listOf(
activeEvent().apply { isActive = false },
activeEvent().apply {
startDate = LocalDateTime.now().plusDays(1)
endDate = startDate.plusDays(1)
},
activeEvent().apply {
endDate = LocalDateTime.now().minusDays(1)
startDate = endDate.minusDays(1)
},
activeEvent().apply { lang = Lang.JA },
activeEvent().apply { lang = Lang.EN }
).map { saveEvent(it) }
val unverified = saveMember("hidden-unverified")
val verified = saveMember("hidden-verified", verified = true)
val admin = saveMember("hidden-admin", role = MemberRole.ADMIN)
val missing = httpGet("/api/v2/events/${Long.MAX_VALUE}")
assertInvalidRequest(missing)
listOf(null, unverified, verified, admin).forEach { member ->
val legacyItems = viewerGet("/event", member)["data"]["eventList"]
assertEquals(listOf(visible.id), legacyItems.map { it["id"].asLong() })
hidden.forEach { event ->
assertEquals(missing, viewerGet("/api/v2/events/${event.id}", member))
}
}
}
@ParameterizedTest
@CsvSource("0", "-1", "9223372036854775807")
@DisplayName("양수가 아니거나 미존재인 ID는 HTTP 200 invalid_request 실패 응답을 반환한다")
fun shouldRejectNonpositiveAndMissingIdsWithTheCommonInvalidRequest(id: String) {
assertInvalidRequest(httpGet("/api/v2/events/$id"))
val result = mockGet("/api/v2/events/$id")
assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey)
}
@ParameterizedTest
@CsvSource("not-a-number", "9223372036854775808")
@DisplayName("숫자가 아니거나 Long 범위를 넘는 ID는 기존 바인딩과 공통 예외 응답을 유지한다")
fun shouldKeepTheGlobalBindingErrorResponseForMalformedIds(id: String) {
val response = httpGet("/api/v2/events/$id")
assertEquals(
objectMapper.readTree(
"""{"success":false,"message":"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
"data":null,"errorProperty":null}"""
),
response
)
assertEquals(
MethodArgumentTypeMismatchException::class.java,
mockGet("/api/v2/events/$id").resolvedException?.javaClass
)
}
@ParameterizedTest
@CsvSource(
"POST, /api/v2/events/1", "PUT, /api/v2/events/1", "DELETE, /api/v2/events/1",
"GET, /api/v2/events", "POST, /api/v2/events", "GET, /api/v2/events/1/extra",
"GET, /api/v2/events/1/extra/deeper", "GET, /event/popup", "GET, /admin/event/banner",
"POST, /event", "PUT, /event", "DELETE, /event/1", "GET, /admin/event/banner/1"
)
@DisplayName("익명 쓰기 collection 하위 경로 기존 팝업 관리자 API는 계속 인증이 필요하다")
fun shouldKeepAnonymousRequestsOutsideTheDetailGetProtected(method: HttpMethod, path: String) {
val response = restTemplate.exchange(path, method, HttpEntity.EMPTY, String::class.java)
println("HTTP $method $path -> ${response.statusCodeValue} ${response.body}")
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
}
private fun httpGet(path: String, country: String? = null): JsonNode {
val headers = HttpHeaders().apply {
country?.let { set("CloudFront-Viewer-Country", it) }
}
val response = restTemplate.exchange(path, HttpMethod.GET, HttpEntity<Void>(headers), String::class.java)
println("HTTP GET $path country=$country -> ${response.statusCodeValue} ${response.body}")
assertEquals(HttpStatus.OK, response.statusCode)
return objectMapper.readTree(response.body)
}
private fun viewerGet(path: String, member: Member?, country: String? = null): JsonNode {
if (member == null) return httpGet(path, country)
return objectMapper.readTree(mockGet(path, member, country).response.contentAsByteArray)
}
private fun mockGet(path: String, member: Member? = null, country: String? = null): MvcResult {
val request = get(path).apply {
country?.let { header("CloudFront-Viewer-Country", it) }
member?.let {
val adapter = MemberAdapter(it)
with(authentication(UsernamePasswordAuthenticationToken(adapter, null, adapter.authorities)))
}
}
val result = mockMvc.perform(request).andReturn()
println(
"MockMvc GET $path role=${member?.role} country=$country -> ${result.response.status} " +
result.response.getContentAsString(Charsets.UTF_8)
)
assertEquals(200, result.response.status)
return result
}
private fun assertInvalidRequest(response: JsonNode) {
assertEquals(
objectMapper.readTree(
"""{"success":false,"message":"잘못된 요청입니다.","data":null,"errorProperty":null}"""
),
response
)
}
private fun saveMember(seed: String, role: MemberRole = MemberRole.USER, verified: Boolean = false): Member {
val member = memberRepository.saveAndFlush(Member(password = "test-password", nickname = seed, role = role))
if (verified) {
val auth = authRepository.saveAndFlush(
Auth("test-member", "19900101", "test-ci-$seed", "test-di-$seed", 1).apply { this.member = member }
)
assertEquals(auth.id, authRepository.getAuthIdByMemberId(requireNotNull(member.id)))
}
return member
}
private fun saveEvent(event: Event): Event = eventRepository.saveAndFlush(event)
private fun activeEvent(): Event = Event(
thumbnailImage = "thumbnail.png",
detailImage = "detail.png",
popupImage = "popup.png",
link = "https://external.test/event",
title = "not-returned-title",
isAdult = null,
isPopup = false,
startDate = LocalDateTime.now().minusDays(1),
endDate = LocalDateTime.now().plusDays(1)
)
}
@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.Recomm
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendationSnapshotWindowPolicy
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
@@ -59,7 +60,8 @@ class HomeRecommendationControllerTest @Autowired constructor(
private val memberRepository: MemberRepository, private val memberRepository: MemberRepository,
private val creatorFollowingRepository: CreatorFollowingRepository, private val creatorFollowingRepository: CreatorFollowingRepository,
private val entityManager: EntityManager, private val entityManager: EntityManager,
private val jdbcTemplate: JdbcTemplate private val jdbcTemplate: JdbcTemplate,
private val subBannerService: SubBannerService
) { ) {
@MockBean @MockBean
private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService private lateinit var creatorCommunityTranslationService: CreatorCommunityTranslationService
@@ -372,6 +374,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
val facade = HomeRecommendationFacade( val facade = HomeRecommendationFacade(
failingQueryService, failingQueryService,
preferenceService, preferenceService,
subBannerService,
"https://cdn.test" "https://cdn.test"
) )
Mockito.`when`(failingQueryService.findLiveRecommendations(limit = 20, memberId = null, lang = Lang.KO)) Mockito.`when`(failingQueryService.findLiveRecommendations(limit = 20, memberId = null, lang = Lang.KO))
@@ -414,6 +417,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
val facade = HomeRecommendationFacade( val facade = HomeRecommendationFacade(
failingQueryService, failingQueryService,
preferenceService, preferenceService,
subBannerService,
"https://cdn.test" "https://cdn.test"
) )
Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false) Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false)
@@ -448,6 +452,7 @@ class HomeRecommendationControllerTest @Autowired constructor(
val facade = HomeRecommendationFacade( val facade = HomeRecommendationFacade(
failingQueryService, failingQueryService,
preferenceService, preferenceService,
subBannerService,
"https://cdn.test" "https://cdn.test"
) )
Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false) Mockito.`when`(preferenceService.canViewAdultContent(member)).thenReturn(false)
@@ -21,6 +21,7 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentDebutCreatorRec
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreatorRecord
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
@@ -40,6 +41,7 @@ class HomeRecommendationFacadeTest {
private val facade = HomeRecommendationFacade( private val facade = HomeRecommendationFacade(
queryService, queryService,
preferenceService, preferenceService,
Mockito.mock(SubBannerService::class.java),
"https://cdn.test" "https://cdn.test"
) )
@@ -14,6 +14,7 @@ class HomeRecommendationResponseTest {
fun shouldSerializeNewHomeRecommendationFields() { fun shouldSerializeNewHomeRecommendationFields() {
val response = HomeRecommendationResponse( val response = HomeRecommendationResponse(
lives = emptyList(), lives = emptyList(),
subBanners = emptyList(),
banners = listOf( banners = listOf(
RecommendationBannerResponse( RecommendationBannerResponse(
imageUrl = "https://cdn.test/banner.png", imageUrl = "https://cdn.test/banner.png",
@@ -101,6 +102,7 @@ class HomeRecommendationResponseTest {
val json = objectMapper.readTree(objectMapper.writeValueAsString(response)) val json = objectMapper.readTree(objectMapper.writeValueAsString(response))
assertEquals(objectMapper.readTree("[]"), json["subBanners"])
assertEquals("https://cdn.test/banner.png", json["banners"][0]["imageUrl"].asText()) assertEquals("https://cdn.test/banner.png", json["banners"][0]["imageUrl"].asText())
assertEquals(true, json["banners"][0]["eventItem"].isNull) assertEquals(true, json["banners"][0]["eventItem"].isNull)
assertEquals(11L, json["banners"][0]["creatorId"].asLong()) assertEquals(11L, json["banners"][0]["creatorId"].asLong())
@@ -0,0 +1,205 @@
package kr.co.vividnext.sodalive.v2.api.notice
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.notice.ServiceNotice
import kr.co.vividnext.sodalive.notice.ServiceServiceNoticeRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import java.sql.Timestamp
import java.time.LocalDateTime
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"spring.datasource.url=jdbc:h2:mem:latest-notice-controller-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class LatestNoticeControllerIntegrationTest @Autowired constructor(
private val restTemplate: TestRestTemplate,
private val repository: ServiceServiceNoticeRepository,
private val jdbcTemplate: JdbcTemplate,
private val objectMapper: ObjectMapper
) {
@BeforeEach
fun setUp() {
jdbcTemplate.execute(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
)
jdbcTemplate.execute(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
)
repository.deleteAllInBatch()
}
@AfterEach
fun tearDown() {
repository.deleteAllInBatch()
}
@Test
@DisplayName("익명 요청은 작성 시각과 무관하게 활성 공지 id 역순 최대 3개와 전체 필드를 반환한다")
fun shouldReturnLatestThreeActiveNoticesByIdWithUnchangedFields() {
saveNotice("제외될 활성 공지", "오래된 id", "2026-10-30T12:00:00")
val second = saveNotice("두 번째 공지", "본문 2", "2026-10-29T12:00:00")
val third = saveNotice("세 번째 공지", "본문 3", "2026-10-28T12:00:00")
val fourth = saveNotice(
"최근 서비스 공지",
"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"",
"2026-10-27T15:00:00"
)
saveNotice("더 큰 비활성 id", "공개하면 안 되는 본문", "2026-10-31T12:00:00", false)
assertGet(
"/api/v2/notices/latest",
"""
{"success":true,"message":null,"data":[
{"id":$fourth,"title":"최근 서비스 공지",
"content":"서비스 공지\n두 번째 줄: <b>전체 본문</b> & \"인용\"","date":"2026-10-28"},
{"id":$third,"title":"세 번째 공지","content":"본문 3","date":"2026-10-28"},
{"id":$second,"title":"두 번째 공지","content":"본문 2","date":"2026-10-29"}
],"errorProperty":null}
""".trimIndent()
)
}
@ParameterizedTest
@CsvSource("2026-10-27T14:59:59, 2026-10-27", "2026-10-27T15:00:00, 2026-10-28")
@DisplayName("UTC 시각은 기존 KST 자정 경계에 맞춰 날짜 문자열로 반환한다")
fun shouldPreserveExistingDateAtKstMidnight(createdAt: String, expectedDate: String) {
val noticeId = saveNotice("날짜 경계", "날짜 본문", createdAt)
assertGet(
"/api/v2/notices/latest",
"""
{"success":true,"message":null,"data":[
{"id":$noticeId,"title":"날짜 경계","content":"날짜 본문","date":"$expectedDate"}
],"errorProperty":null}
""".trimIndent()
)
}
@Test
@DisplayName("공지가 없으면 null 대신 빈 배열을 반환한다")
fun shouldReturnEmptyArrayWhenThereAreNoNotices() {
assertGet(
"/api/v2/notices/latest",
"""{"success":true,"message":null,"data":[],"errorProperty":null}"""
)
}
@Test
@DisplayName("활성 공지 1개는 원래 필드 그대로 한 항목 배열로 반환한다")
fun shouldReturnOneNoticeWithoutPadding() {
val noticeId = saveNotice("단일 공지", "단일 본문", "2026-10-27T12:00:00")
assertGet(
"/api/v2/notices/latest",
"""
{"success":true,"message":null,"data":[
{"id":$noticeId,"title":"단일 공지","content":"단일 본문","date":"2026-10-27"}
],"errorProperty":null}
""".trimIndent()
)
}
@Test
@DisplayName("활성 공지 2개는 중복 없이 id 역순으로 반환한다")
fun shouldReturnTwoNoticesWithoutPadding() {
val first = saveNotice("첫 공지", "첫 본문", "2026-10-28T12:00:00")
val second = saveNotice("다음 공지", "다음 본문", "2026-10-27T12:00:00")
assertGet(
"/api/v2/notices/latest",
"""
{"success":true,"message":null,"data":[
{"id":$second,"title":"다음 공지","content":"다음 본문","date":"2026-10-27"},
{"id":$first,"title":"첫 공지","content":"첫 본문","date":"2026-10-28"}
],"errorProperty":null}
""".trimIndent()
)
}
@Test
@DisplayName("비활성 공지만 있으면 빈 배열을 반환한다")
fun shouldReturnEmptyArrayWhenAllNoticesAreInactive() {
saveNotice("비활성 1", "본문 1", "2026-10-27T12:00:00", false)
saveNotice("비활성 2", "본문 2", "2026-10-28T12:00:00", false)
assertGet(
"/api/v2/notices/latest",
"""{"success":true,"message":null,"data":[],"errorProperty":null}"""
)
}
@Test
@DisplayName("기존 v1 최근 공지는 배열이 아닌 최신 활성 공지 단일 객체를 유지한다")
fun shouldKeepLegacyLatestNoticeAsAnObject() {
saveNotice("이전 공지", "이전 본문", "2026-10-29T12:00:00")
val latest = saveNotice("기존 최근 공지", "기존 전체 본문", "2026-10-27T15:00:00")
saveNotice("비활성", "미공개 본문", "2026-10-30T12:00:00", false)
assertGet(
"/notice/latest",
"""
{"success":true,"message":null,"data":
{"id":$latest,"title":"기존 최근 공지","content":"기존 전체 본문","date":"2026-10-28"},
"errorProperty":null}
""".trimIndent()
)
}
@Test
@DisplayName("기존 v1 최근 공지는 데이터가 없으면 null을 유지한다")
fun shouldKeepLegacyLatestNoticeNullWhenEmpty() {
assertGet("/notice/latest", """{"success":true,"message":null,"data":null,"errorProperty":null}""")
}
@ParameterizedTest
@CsvSource("POST, /api/v2/notices/latest", "GET, /api/v2/notices/private")
@DisplayName("익명 POST 및 인접 보호 경로는 공개하지 않는다")
fun shouldRejectAnonymousRequestsOutsideTheExactGetRoute(method: HttpMethod, path: String) {
val response = restTemplate.exchange(path, method, HttpEntity.EMPTY, String::class.java)
println("HTTP $method $path -> ${response.statusCodeValue} ${response.body}")
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
}
private fun assertGet(path: String, expectedJson: String) {
val response = restTemplate.getForEntity(path, String::class.java)
println("HTTP GET $path -> ${response.statusCodeValue} ${response.body}")
assertEquals(HttpStatus.OK, response.statusCode)
assertEquals(objectMapper.readTree(expectedJson), objectMapper.readTree(response.body))
}
private fun saveNotice(title: String, content: String, createdAt: String, isActive: Boolean = true): Long {
val noticeId = requireNotNull(repository.saveAndFlush(ServiceNotice(title, content, isActive)).id)
assertEquals(
1,
jdbcTemplate.update(
"UPDATE service_notice SET created_at = ? WHERE id = ?",
Timestamp.valueOf(LocalDateTime.parse(createdAt)),
noticeId
)
)
return noticeId
}
}
@@ -0,0 +1,596 @@
package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.ValueSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.core.io.ByteArrayResource
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.util.LinkedMultiValueMap
import java.io.InputStream
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Base64
import java.util.UUID
import javax.persistence.EntityManager
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"cloud.aws.s3.bucket=test-bucket",
"spring.datasource.url=jdbc:h2:mem:admin-sub-banner-http;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AdminSubBannerControllerIntegrationTest @Autowired constructor(
private val restTemplate: TestRestTemplate,
private val objectMapper: ObjectMapper,
private val repository: SubBannerRepository,
private val jdbcTemplate: JdbcTemplate,
private val transactionTemplate: TransactionTemplate,
private val entityManager: EntityManager,
private val memberRepository: MemberRepository,
private val tokenProvider: TokenProvider,
private val tokenRepository: MemberTokenRepository
) {
@LocalServerPort
private var port: Int = 0
@MockBean
private lateinit var s3Uploader: S3Uploader
private lateinit var adminToken: String
private val memberIds = mutableListOf<Long>()
@BeforeEach
fun setUp() {
jdbcTemplate.update("delete from sub_banner")
jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN start_date TIMESTAMP(0)")
jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN end_date TIMESTAMP(0)")
adminToken = tokenFor(MemberRole.ADMIN)
Mockito.`when`(
s3Uploader.upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull())
).thenAnswer { invocation ->
assertEquals("test-bucket", invocation.getArgument<String>(1))
assertTrue(invocation.getArgument<String>(2).matches(Regex("sub_banner/[A-Za-z0-9_=-]+")))
assertEquals(PNG.toList(), invocation.getArgument<InputStream>(0).readBytes().toList())
invocation.getArgument<String>(2)
}
}
@AfterEach
fun cleanUp() {
jdbcTemplate.update("delete from sub_banner")
tokenRepository.deleteAll()
memberRepository.deleteAllById(memberIds)
memberIds.clear()
}
@Test
@DisplayName("실제 JWT HTTP CRUD는 관리자 8필드·삭제 null을 유지하며 소프트 삭제 후 수정·재삭제를 거부한다")
fun shouldCreateListUpdateAndSoftDeleteWithExactAdminResponse() {
val createdResponse = send(HttpMethod.POST)
evidence("POST", createdResponse)
val created = success(createdResponse)
assertAdminFields(created)
val id = created["id"].asLong()
val stored = reload(id)
assertEquals("https://cdn.test/${stored.imagePath}", created["imageUrl"].asText())
assertEquals(WEB, stored.webUrl)
assertNull(stored.startDate)
assertNull(stored.endDate)
assertEquals(listOf(created), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList())
val updatedResponse = send(
HttpMethod.PUT,
"/$id",
baseFields() + mapOf("placement" to "CONTENT_RECOMMENDATION", "webUrl" to null, "iosDeepLink" to "custom:promo") +
dates("2026-10-28 00:00", "2026-10-29 23:59")
)
evidence("PUT", updatedResponse)
val updated = success(updatedResponse)
assertAdminFields(updated)
assertEquals(id, updated["id"].asLong())
assertTrue(updated["webUrl"].isNull)
assertEquals("custom:promo", updated["iosDeepLink"].asText())
assertNotEquals(stored.imagePath, reload(id).imagePath)
assertEquals(emptyList<JsonNode>(), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList())
assertEquals(listOf(updated), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList())
val beforeDelete = storedRows().single()
Mockito.clearInvocations(s3Uploader)
val deleted = send(HttpMethod.DELETE, "/$id")
evidence("DELETE", deleted)
assertTrue(success(deleted, allowNullData = true).isNull)
assertNull(transactionTemplate.execute { repository.findById(id) })
assertEquals(1L, rowCount(), "Soft delete must retain the original row")
val retained = storedRows().single()
assertEquals(true, beforeDelete["is_active"])
assertEquals(false, retained["is_active"])
assertEquals(beforeDelete - setOf("is_active", "updated_at"), retained - setOf("is_active", "updated_at"))
assertNotEquals(beforeDelete["updated_at"], retained["updated_at"])
println("DB EVIDENCE soft-delete before=$beforeDelete after=$retained")
assertEquals(emptyList<JsonNode>(), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList())
Mockito.verifyNoInteractions(s3Uploader)
listOf(
send(HttpMethod.DELETE, "/$id"),
send(HttpMethod.PUT, "/$id", baseFields() + ("isActive" to true)),
send(HttpMethod.PUT, "/$id", rawRequest = "{")
).forEach { response ->
evidence("inactive mutation", response)
assertStatus(response, HttpStatus.NOT_FOUND)
assertEquals(listOf(retained), storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
}
@ParameterizedTest
@ValueSource(ints = [0, 1, 2, 3, 4, 5, 6, 7])
@DisplayName("주소 8개 조합 중 모두 빈 조합만 거부하고 나머지는 생성·수정한다")
fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations(mask: Int) {
val fields = baseFields() + mapOf(
"webUrl" to if (mask and 1 != 0) " $WEB " else " \t ",
"androidDeepLink" to if (mask and 2 != 0) "sampleapp://promo?id=a%2Fb" else null,
"iosDeepLink" to if (mask and 4 != 0) "other-app:promo" else ""
)
if (mask == 0) {
assertRejectedWithoutMutation(fields)
return
}
val created = success(send(HttpMethod.POST, fields = fields))
val id = created["id"].asLong()
val stored = reload(id)
assertEquals(if (mask and 1 != 0) WEB else null, stored.webUrl)
assertEquals(if (mask and 2 != 0) "sampleapp://promo?id=a%2Fb" else null, stored.androidDeepLink)
assertEquals(if (mask and 4 != 0) "other-app:promo" else null, stored.iosDeepLink)
assertEquals(created, success(send(HttpMethod.PUT, "/$id", fields, image = null)))
}
@Test
@DisplayName("raw JSON의 필수 기본 필드·실제 타입·객체·정확한 placement를 업로드 전에 검증한다")
fun shouldRejectMissingBaseFieldsAndNonStringLinksBeforeUpload() {
baseFields().keys.forEach { missing -> assertRejectedWithoutMutation(baseFields() - missing) }
listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field ->
listOf(1, true, emptyList<String>(), emptyMap<String, String>()).forEach { value ->
assertRejectedWithoutMutation(baseFields() + (field to value))
}
}
listOf(null, 0, true, emptyList<String>(), "", "home_recommendation", " HOME_RECOMMENDATION", "OTHER").forEach {
assertRejectedWithoutMutation(baseFields() + ("placement" to it))
}
listOf("{", "[]", "null", "42", "\"string\"", "{}", "{} {}").forEach { raw ->
assertRejectedWithoutMutation(rawRequest = raw)
}
assertRejectedWithoutMutation(baseFields() + mapOf("webUrl" to null, "androidDeepLink" to null, "iosDeepLink" to null))
}
@Test
@DisplayName("위험 scheme·상대 URI·실제 host 없는 웹 주소와 문법 오류를 생성·수정 전에 거부한다")
fun shouldRejectUnsafeOrMalformedUrisBeforeUploadOrPersistence() {
evidence("invalid URI", send(HttpMethod.POST, fields = baseFields() + ("webUrl" to "javascript:alert(1)")))
val unsafe = listOf("javascript:alert(1)", "JaVaScRiPt:alert(1)", "DATA:text/plain,x", "file:///tmp/a", "CoNtEnT://a")
listOf("webUrl", "androidDeepLink", "iosDeepLink").forEach { field ->
(unsafe + listOf("relative/path", "//example.test/path", "app://bad path", "app://bad%zz")).forEach { value ->
assertRejectedWithoutMutation(baseFields() + (field to value))
}
}
listOf("ftp://example.test", "https:/missing-host", "http:///path", "https://", "https://bad_host/a").forEach {
assertRejectedWithoutMutation(baseFields() + ("webUrl" to it))
}
}
@Test
@DisplayName("주소는 trim 외 대소문자·인코딩·순서·fragment·긴 문자열을 변경하지 않는다")
fun shouldPreserveLinkUriAfterTrimOnly() {
val links = mapOf(
"webUrl" to "HTTPS://Example.TEST/a%2fb/../x?b=2&a=%2F+%20#Frag",
"androidDeepLink" to "Intent://promo/Item?id=a%2Fb#Intent;scheme=sample;end",
"iosDeepLink" to "new-custom:Item?value=${"a".repeat(512)}%2f"
)
val created = success(send(HttpMethod.POST, fields = baseFields() + links.mapValues { " \t${it.value}\n " }))
val stored = reload(created["id"].asLong())
links.forEach { (field, value) -> assertEquals(value, created[field].asText()) }
assertEquals(links["webUrl"], stored.webUrl)
assertEquals(links["androidDeepLink"], stored.androidDeepLink)
assertEquals(links["iosDeepLink"], stored.iosDeepLink)
}
@ParameterizedTest
@CsvSource(
"OMITTED, OMITTED, , ",
"NULL, NULL, , ",
"2026-10-27 00:00, OMITTED, 2026-10-26T15:00:00, ",
"OMITTED, 2026-10-28 23:59, , 2026-10-28T14:59:59",
"2026-10-27 00:00, 2026-10-28 23:59, 2026-10-26T15:00:00, 2026-10-28T14:59:59",
"2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27T14:59:00, 2026-10-27T14:59:59",
"1970-01-01 09:01, 2038-01-19 12:13, 1970-01-01T00:01:00, 2038-01-19T03:13:59",
"OMITTED, 1970-01-01 09:00, , 1970-01-01T00:00:59",
"2038-01-19 12:14, OMITTED, 2038-01-19T03:14:00, ",
"1988-05-08 01:30, 1988-05-08 03:30, 1988-05-07T16:30:00, 1988-05-07T17:30:59",
"1988-10-09 02:30, 1988-10-09 02:30, 1988-10-08T16:30:00, 1988-10-08T16:30:59"
)
@DisplayName("H2 TIMESTAMP(0) 재조회 후 UTC 초 경계와 HTTP KST 분 문자열이 정확히 왕복한다")
fun shouldRoundTripAdminDateStringsExactlyAfterDatabaseReload(
start: String,
end: String,
utcStart: String?,
utcEnd: String?
) {
val response = send(HttpMethod.POST, fields = baseFields() + dates(start, end))
if (start == "2026-10-27 23:59") evidence("POST dates", response)
val created = success(response)
val stored = reload(created["id"].asLong())
assertEquals(utcStart?.let(LocalDateTime::parse), stored.startDate)
assertEquals(utcEnd?.let(LocalDateTime::parse), stored.endDate)
assertDate(created, "startDate", start)
assertDate(created, "endDate", end)
assertEquals(listOf(created), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList())
}
@ParameterizedTest
@ValueSource(strings = ["startDate", "endDate"])
@DisplayName("존재하지 않는 KST DST gap 분은 POST·PUT 모두 업로드와 DB 변경 전에 거부한다")
fun shouldRejectKstDstGapBeforeUploadOrPersistence(field: String) {
assertRejectedWithoutMutation(
baseFields() + mapOf("startDate" to null, "endDate" to null) + (field to "1988-05-08 02:30")
)
}
@Test
@DisplayName("날짜의 실제 JSON 타입·엄격한 형식·달력·DB TIMESTAMP 범위·역전을 업로드 전에 거부한다")
fun shouldRejectInvalidDateJsonTypesFormatsAndRangesBeforeUpload() {
val invalid = listOf(
2026, true, emptyList<String>(), emptyMap<String, String>(), "", " ", "not-a-date",
"2026-02-29 10:00", "2026-04-31 10:00", "2026-13-01 10:00", "2026-10-27 24:00",
"2026-10-27T10:00", "2026-10-27 10:00:00", "2026-10-27 10:00Z", "2026-10-27 10:00+09:00",
"2026-1-01 10:00", "+02026-10-27 10:00", " 2026-10-27 10:00", "2026-10-27 10:00 ",
"0000-01-01 00:00", "9999-12-31 23:59", "1969-12-31 23:59", "2039-01-01 00:00"
)
listOf("startDate", "endDate").forEach { field ->
invalid.forEach { value -> assertRejectedWithoutMutation(baseFields() + (field to value)) }
}
assertRejectedWithoutMutation(baseFields() + dates("1970-01-01 09:00", "OMITTED"))
assertRejectedWithoutMutation(baseFields() + dates("OMITTED", "2038-01-19 12:14"))
assertRejectedWithoutMutation(baseFields() + dates("2026-10-29 00:00", "2026-10-28 23:59"))
}
@ParameterizedTest
@CsvSource(
"OMITTED, OMITTED, 2026-10-28 00:00, 2026-10-29 23:59",
"NULL, OMITTED, NULL, 2026-10-29 23:59",
"OMITTED, NULL, 2026-10-28 00:00, NULL",
"NULL, NULL, NULL, NULL",
"2026-10-27 00:00, OMITTED, 2026-10-27 00:00, 2026-10-29 23:59",
"OMITTED, 2026-10-30 23:59, 2026-10-28 00:00, 2026-10-30 23:59",
"2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59, 2026-10-27 23:59",
"NULL, 2026-10-26 00:00, NULL, 2026-10-26 00:00",
"2026-10-30 00:00, NULL, 2026-10-30 00:00, NULL"
)
@DisplayName("수정 생략은 경계를 보존하고 null은 개별 해제하며 이미지·주소는 유지한다")
fun shouldUpdateDatesWithOmittedPreservedAndNullCleared(start: String, end: String, wantStart: String, wantEnd: String) {
val id = createScheduled()
val before = reload(id)
Mockito.clearInvocations(s3Uploader)
val updated = success(send(HttpMethod.PUT, "/$id", baseFields() + dates(start, end), image = null))
assertDate(updated, "startDate", wantStart)
assertDate(updated, "endDate", wantEnd)
val stored = reload(id)
assertEquals(expectedUtc(wantStart, 0), stored.startDate)
assertEquals(expectedUtc(wantEnd, 59), stored.endDate)
assertEquals(before.imagePath, stored.imagePath)
assertEquals(before.webUrl, stored.webUrl)
Mockito.verifyNoInteractions(s3Uploader)
}
@ParameterizedTest
@CsvSource("2026-10-30 00:00, OMITTED", "OMITTED, 2026-10-27 23:59", "2026-10-30 00:00, 2026-10-29 23:59")
@DisplayName("수정은 유지한 날짜와 새 날짜를 병합한 역전 범위를 업로드 전에 거부한다")
fun shouldRejectReversedEffectiveUpdateRange(start: String, end: String) {
val id = createScheduled()
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
assertStatus(send(HttpMethod.PUT, "/$id", baseFields() + dates(start, end)), HttpStatus.BAD_REQUEST)
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("관리자 목록은 빈 배열·placement 격리·미래·만료·무기한과 20개 초과 id 순서를 보장한다")
fun shouldListAllPeriodsInIdOrderWithoutTruncation() {
assertEquals(emptyList<JsonNode>(), success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).toList())
val ids = (0 until 23).map { index ->
val period = when (index % 3) {
0 -> dates("2030-01-01 00:00", "2030-01-02 23:59")
1 -> dates("2020-01-01 00:00", "2020-01-02 23:59")
else -> emptyMap()
}
success(send(HttpMethod.POST, fields = baseFields() + period))["id"].asLong()
}
val other = success(send(HttpMethod.POST, fields = baseFields() + ("placement" to "CONTENT_RECOMMENDATION")))
assertEquals(ids, success(send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION")).map { it["id"].asLong() })
assertEquals(listOf(other), success(send(HttpMethod.GET, "?placement=CONTENT_RECOMMENDATION")).toList())
listOf("", "?placement=", "?placement=OTHER", "?placement=home_recommendation").forEach {
assertStatus(send(HttpMethod.GET, it), HttpStatus.BAD_REQUEST)
}
}
@Test
@DisplayName("빈 파일·위장 이미지 교체를 거부하고 업로드 실패 시 기존 행과 이미지 경로를 보존한다")
fun shouldKeepExistingImageWhenReplacementUploadFails() {
val id = createScheduled()
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
listOf(byteArrayOf(), "not an image".toByteArray()).forEach { bytes ->
assertStatus(send(HttpMethod.POST, image = bytes), HttpStatus.BAD_REQUEST)
assertStatus(send(HttpMethod.PUT, "/$id", image = bytes), HttpStatus.BAD_REQUEST)
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
Mockito.doThrow(IllegalStateException("Upload failed")).`when`(s3Uploader)
.upload(anyInputStream(), Mockito.anyString(), Mockito.anyString(), Mockito.isNull())
val response = send(HttpMethod.PUT, "/$id", baseFields() + ("webUrl" to "https://changed.test"))
assertEquals(HttpStatus.OK, response.statusCode)
assertFalse(objectMapper.readTree(response.body)["success"].asBoolean())
assertEquals(before, storedRows())
assertFalse(requireNotNull(response.body).contains("Upload failed"))
}
@Test
@DisplayName("없는 id의 수정·삭제는 404이며 이미지 업로드나 행 변경이 없다")
fun shouldRejectMissingIdWithoutMutation() {
createScheduled()
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
listOf(HttpMethod.PUT, HttpMethod.DELETE).forEach { method ->
val response = send(method, "/${Long.MAX_VALUE}")
evidence("missing $method", response)
assertStatus(response, HttpStatus.NOT_FOUND)
}
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("익명 401과 모든 비관리자 역할의 CRUD 거부는 업로드·DB 변경 없이 기존 정책을 따른다")
fun shouldRejectAnonymousAndNonAdminBeforeUpload() {
val id = createScheduled()
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
(listOf<String?>(null) + MemberRole.values().filter { it != MemberRole.ADMIN }.map { tokenFor(it) }).forEach { token ->
listOf(
HttpMethod.GET to "?placement=HOME_RECOMMENDATION",
HttpMethod.POST to "",
HttpMethod.PUT to "/$id",
HttpMethod.DELETE to "/$id"
).forEach { (method, suffix) ->
val response = send(method, suffix, token = token)
if (token == null) {
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
} else {
assertEquals(HttpStatus.OK, response.statusCode)
val body = objectMapper.readTree(response.body)
assertFalse(body["success"].asBoolean())
assertEquals("You do not have permission.", body["message"].asText())
}
if (method == HttpMethod.DELETE) evidence(if (token == null) "anonymous" else "non-admin", response)
}
}
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("필수 multipart part 누락·잘못된 multipart는 기존 framework 오류 envelope를 반환하며 변경하지 않는다")
fun shouldCharacterizeMissingImageAndMalformedMultipart() {
val responses = listOf(
send(HttpMethod.POST, image = null),
send(HttpMethod.POST, includeRequest = false),
restTemplate.exchange(
url(),
HttpMethod.POST,
HttpEntity("not a multipart body", headers(adminToken).apply { contentType = MediaType.MULTIPART_FORM_DATA }),
String::class.java
)
)
responses.forEach { response ->
evidence("framework multipart", response)
assertEquals(HttpStatus.OK, response.statusCode)
val body = objectMapper.readTree(response.body)
assertFalse(body["success"].asBoolean())
assertTrue(body["data"].isNull)
}
assertEquals(0L, rowCount())
Mockito.verifyNoInteractions(s3Uploader)
}
private fun assertRejectedWithoutMutation(fields: Map<String, Any?> = baseFields(), rawRequest: String? = null) {
if (rowCount() == 0L) createScheduled()
val id = jdbcTemplate.queryForObject("select min(id) from sub_banner", Long::class.java)
val before = storedRows()
Mockito.clearInvocations(s3Uploader)
listOf(HttpMethod.POST to "", HttpMethod.PUT to "/$id").forEach { (method, suffix) ->
val response = send(method, suffix, fields, rawRequest = rawRequest)
assertStatus(response, HttpStatus.BAD_REQUEST)
assertFalse(requireNotNull(response.body).contains("javascript", ignoreCase = true))
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
}
private fun createScheduled(): Long = success(
send(HttpMethod.POST, fields = baseFields() + dates("2026-10-28 00:00", "2026-10-29 23:59"))
)["id"].asLong()
private fun send(
method: HttpMethod,
suffix: String = "",
fields: Map<String, Any?> = baseFields(),
image: ByteArray? = PNG,
token: String? = adminToken,
rawRequest: String? = null,
includeRequest: Boolean = true
): ResponseEntity<String> {
val headers = headers(token)
val body = if (method == HttpMethod.POST || method == HttpMethod.PUT) {
headers.contentType = MediaType.MULTIPART_FORM_DATA
LinkedMultiValueMap<String, Any>().apply {
if (includeRequest) {
add(
"request",
HttpEntity(
rawRequest ?: objectMapper.writeValueAsString(fields),
HttpHeaders().apply { contentType = MediaType.APPLICATION_JSON }
)
)
}
if (image != null) {
add(
"image",
HttpEntity(
object : ByteArrayResource(image) {
override fun getFilename(): String = "client-name.png"
},
HttpHeaders().apply { contentType = MediaType.IMAGE_PNG }
)
)
}
}
} else {
null
}
return restTemplate.exchange(url() + suffix, method, HttpEntity(body, headers), String::class.java)
}
private fun headers(token: String?): HttpHeaders = HttpHeaders().apply {
token?.let { setBearerAuth(it) }
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
accept = listOf(MediaType.APPLICATION_JSON)
}
private fun success(response: ResponseEntity<String>, allowNullData: Boolean = false): JsonNode {
assertEquals(HttpStatus.OK, response.statusCode, response.body)
val body = objectMapper.readTree(response.body)
assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet())
assertTrue(body["success"].asBoolean(), response.body)
assertTrue(body["message"].isNull)
assertTrue(body["errorProperty"].isNull)
if (!allowNullData) assertFalse(body["data"].isNull, "Successful create/update/list must return data")
return body["data"]
}
private fun assertAdminFields(data: JsonNode) {
assertEquals(
setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"),
data.fieldNames().asSequence().toSet()
)
}
private fun assertStatus(response: ResponseEntity<String>, status: HttpStatus) {
assertEquals(status, response.statusCode, response.body)
val body = objectMapper.readTree(response.body)
assertEquals(status.value(), body["status"].asInt())
assertFalse(body.has("trace"))
assertFalse(body.has("exception"))
}
private fun reload(id: Long): SubBanner = requireNotNull(
transactionTemplate.execute {
entityManager.flush()
entityManager.clear()
repository.findById(id)
}
)
private fun storedRows(): List<Map<String, Any>> = jdbcTemplate.queryForList("select * from sub_banner order by id")
private fun rowCount(): Long = requireNotNull(
jdbcTemplate.queryForObject("select count(*) from sub_banner", Long::class.java)
)
private fun tokenFor(role: MemberRole): String {
val member = memberRepository.saveAndFlush(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role)
)
val id = requireNotNull(member.id)
memberIds.add(id)
return tokenProvider.createToken(
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))),
id
)
}
private fun assertDate(data: JsonNode, field: String, value: String) {
if (value == "NULL" || value == "OMITTED") assertTrue(data[field].isNull) else assertEquals(value, data[field].asText())
}
private fun dates(start: String, end: String): Map<String, Any?> = mapOf("startDate" to start, "endDate" to end)
.filterValues { it != "OMITTED" }
.mapValues { if (it.value == "NULL") null else it.value }
private fun expectedUtc(value: String, second: Int): LocalDateTime? = if (value == "NULL") {
null
} else {
LocalDateTime.parse(value, DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm")).minusHours(9).withSecond(second)
}
private fun evidence(label: String, response: ResponseEntity<String>) {
println("HTTP EVIDENCE $label ${response.statusCodeValue}: ${response.body}")
}
private fun url(): String = "http://127.0.0.1:$port/admin/sub-banners"
private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream()
private fun baseFields(): Map<String, Any?> = mapOf(
"placement" to "HOME_RECOMMENDATION",
"webUrl" to WEB,
"androidDeepLink" to null,
"iosDeepLink" to null
)
companion object {
private const val WEB = "https://example.test/promo?x=a%2Fb"
private val PNG = Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a0foAAAAASUVORK5CYII="
)
}
}
@@ -0,0 +1,525 @@
package kr.co.vividnext.sodalive.v2.subbanner.adapter.`in`.web
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.jwt.TokenProvider
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.token.MemberTokenRepository
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.EnumSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.boot.test.web.server.LocalServerPort
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.support.TransactionTemplate
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
import java.time.LocalDateTime
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
import javax.persistence.EntityManager
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"cloud.aws.s3.bucket=test-bucket",
"spring.datasource.url=jdbc:h2:mem:public-sub-banner-http;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class RecommendationSubBannerIntegrationTest @Autowired constructor(
private val restTemplate: TestRestTemplate,
private val objectMapper: ObjectMapper,
private val repository: SubBannerRepository,
private val jdbcTemplate: JdbcTemplate,
private val transactionTemplate: TransactionTemplate,
private val entityManager: EntityManager,
private val memberRepository: MemberRepository,
private val tokenProvider: TokenProvider,
private val tokenRepository: MemberTokenRepository,
private val time: TestTime,
private val requestMappingHandlerMapping: RequestMappingHandlerMapping
) {
@LocalServerPort
private var port: Int = 0
@MockBean
private lateinit var s3Uploader: S3Uploader
private val memberIds = mutableListOf<Long>()
@BeforeEach
fun setUp() {
jdbcTemplate.update("delete from sub_banner")
jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN start_date TIMESTAMP(0)")
jdbcTemplate.execute("ALTER TABLE sub_banner ALTER COLUMN end_date TIMESTAMP(0)")
jdbcTemplate.update("insert into audio_content_main_tab (id, title, is_active) values (2, 'recommendation', true)")
transactionTemplate.execute {
entityManager.persist(legacyBanner())
entityManager.persist(legacyBanner().apply { tab = entityManager.getReference(AudioContentMainTab::class.java, 2L) })
entityManager.flush()
entityManager.clear()
}
time.set(LocalDateTime.parse("2026-10-29T23:59:59.999999999"))
}
@AfterEach
fun cleanUp() {
jdbcTemplate.update("delete from sub_banner")
jdbcTemplate.update("delete from content_banner")
jdbcTemplate.update("delete from audio_content_main_tab")
tokenRepository.deleteAll()
memberRepository.deleteAllById(memberIds)
memberIds.clear()
}
@ParameterizedTest
@EnumSource(SubBannerPlacement::class)
@DisplayName("익명 추천 GET은 기존 응답과 배너를 보존하며 두 placement의 빈 subBanners 배열을 반환한다")
fun shouldReturnEmptyArrayForEitherPlacement(placement: SubBannerPlacement) {
val response = send(recommendationPath(placement))
evidence("empty $placement", response)
assertEquals(emptyList<JsonNode>(), subBanners(response, placement).toList())
assertEquals(1, time.calls.get())
}
@ParameterizedTest
@EnumSource(SubBannerPlacement::class)
@DisplayName("공개 목록만 현재 기간을 필터링하고 관리자는 미래·만료·무기한을 모두 유지한다")
fun shouldReturnOnlyVisiblePlacementRowsInIdOrderWithoutDates(placement: SubBannerPlacement) {
val now = LocalDateTime.parse("2026-10-29T23:59:59")
val unbounded = save(banner(placement))
val future = save(banner(placement, start = now.plusSeconds(1)))
val untilNow = save(banner(placement, end = now))
val expired = save(banner(placement, end = now.minusSeconds(1)))
val fromNow = save(banner(placement, start = now))
val instant = save(banner(placement, start = now, end = now))
val other = SubBannerPlacement.values().first { it != placement }
save(banner(other))
val response = send(recommendationPath(placement))
evidence("visible $placement", response)
assertEquals(
listOf(unbounded, untilNow, fromNow, instant).map(::publicTree),
subBanners(response, placement).toList()
)
assertEquals(1, time.calls.get())
val admin = success(send("$ADMIN_PATH?placement=$placement", token = tokenFor(MemberRole.ADMIN)))
assertEquals(listOf(unbounded, future, untilNow, expired, fromNow, instant).map { it.id }, ids(admin))
assertTrue(admin.all { it.has("startDate") && it.has("endDate") })
Mockito.verifyNoInteractions(s3Uploader)
}
@ParameterizedTest
@EnumSource(SubBannerPlacement::class)
@DisplayName("실제 관리자 DELETE는 모든 기간의 행을 보존하며 익명·관리자 목록에서 비활성 행을 제외한다")
fun shouldHideSoftDeletedRowsFromPublicAndAdminWhileRetainingDatabaseRows(placement: SubBannerPlacement) {
val now = LocalDateTime.parse("2026-10-29T23:59:59")
val periods = listOf(null to null, now.minusMinutes(1) to now, now.plusSeconds(1) to null, null to now.minusSeconds(1))
val deleted = periods.map { (start, end) -> save(banner(placement, start, end)) }
val active = periods.map { (start, end) -> save(banner(placement, start, end)) }
val other = save(banner(SubBannerPlacement.values().first { it != placement }))
val before = storedRows()
val token = tokenFor(MemberRole.ADMIN)
deleted.forEach { banner ->
val response = send("$ADMIN_PATH/${banner.id}", HttpMethod.DELETE, token)
evidence("soft-delete $placement", response)
assertEquals(HttpStatus.OK, response.statusCode, response.body)
assertEquals(
objectMapper.readTree("""{"success":true,"message":null,"data":null,"errorProperty":null}"""),
objectMapper.readTree(response.body)
)
}
val retained = storedRows()
assertEquals(before.size, retained.size, "Soft delete must retain every original row")
before.zip(retained).forEach { (original, stored) ->
if (deleted.any { it.id == (stored["id"] as Number).toLong() }) {
assertEquals(false, stored["is_active"])
assertEquals(original - setOf("is_active", "updated_at"), stored - setOf("is_active", "updated_at"))
} else {
assertEquals(true, stored["is_active"])
assertEquals(original, stored)
}
}
println("DB EVIDENCE $placement retained=${retained.size} inactive=${retained.count { it["is_active"] == false }}")
val publicResponse = send(recommendationPath(placement))
evidence("after soft-delete $placement", publicResponse)
assertEquals(active.take(2).map(::publicTree), subBanners(publicResponse, placement).toList())
assertEquals(1, time.calls.get())
val admin = success(send("$ADMIN_PATH?placement=$placement", token = token))
assertEquals(active.map { it.id }, ids(admin))
admin.forEach { data ->
assertEquals(
setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"),
data.fieldNames().asSequence().toSet()
)
}
assertEquals(
listOf(publicTree(other)),
subBanners(send(recommendationPath(other.placement)), other.placement).toList()
)
assertEquals(listOf(other.id), ids(success(send("$ADMIN_PATH?placement=${other.placement}", token = token))))
assertEquals(retained, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@ParameterizedTest
@CsvSource(
"2026-10-29T23:58:59.999999999, false, false",
"2026-10-29T23:59:00, true, false",
"2026-10-29T23:59:00.123456789, true, false",
"2026-10-29T23:59:00.999999999, true, false",
"2026-10-29T23:59:59, true, true",
"2026-10-29T23:59:59.123456789, true, true",
"2026-10-29T23:59:59.999999999, true, true",
"2026-10-30T00:00:00, false, false",
"2026-10-30T00:00:00.123456789, false, false"
)
@DisplayName("고정 UTC now는 한 번만 읽고 초 절삭하여 시작·종료·동일 초·날짜 경계를 포함 판정한다")
fun shouldUseOneSecondPrecisionNowAcrossInclusiveBoundaries(now: String, inMinute: Boolean, atInstant: Boolean) {
val start = LocalDateTime.parse("2026-10-29T23:59:00")
val end = LocalDateTime.parse("2026-10-29T23:59:59")
SubBannerPlacement.values().forEach { placement ->
val minute = save(banner(placement, start = start, end = end))
val instant = save(banner(placement, start = end, end = end))
time.set(LocalDateTime.parse(now))
val response = send(recommendationPath(placement))
evidence("UTC $now $placement", response)
val expected = listOfNotNull(minute.takeIf { inMinute }, instant.takeIf { atInstant })
assertEquals(expected.map(::publicTree), subBanners(response, placement).toList())
assertEquals(1, time.calls.get())
assertEquals(start, reload(requireNotNull(minute.id)).startDate)
assertEquals(end, reload(requireNotNull(minute.id)).endDate)
}
}
@ParameterizedTest
@EnumSource(SubBannerPlacement::class)
@DisplayName("공개 목록은 pagination이나 20개 제한 없이 모든 항목을 id 오름차순으로 반환한다")
fun shouldReturnAllVisibleRowsWithoutLimit(placement: SubBannerPlacement) {
val saved = (0 until 25).map { index -> save(banner(placement).copy(imagePath = "sub_banner/$index.png")) }
save(banner(SubBannerPlacement.values().first { it != placement }))
val response = send(recommendationPath(placement))
evidence("25 untruncated $placement", response)
val data = subBanners(response, placement)
assertEquals(saved.map(::publicTree), data.toList())
assertEquals(saved.map { requireNotNull(it.id) }.sorted(), ids(data))
assertEquals(25, data.size())
assertEquals(1, time.calls.get())
}
@ParameterizedTest
@EnumSource(SubBannerPlacement::class)
@DisplayName("iOS 요청도 Android 전용 행과 모든 저장 링크 원문을 공개 DTO 6필드 그대로 받는다")
fun shouldPreserveAllStoredLinksWithoutServerSideOsFiltering(placement: SubBannerPlacement) {
val androidOnly = save(banner(placement).copy(webUrl = null, androidDeepLink = "sampleapp://Item?id=a%2Fb"))
val allLinks = save(
banner(placement).copy(
webUrl = "HTTPS://Example.TEST/a%2fb/../x?b=2&a=%2F+%20#Frag",
androidDeepLink = "Intent://promo/Item?id=a%2Fb#Intent;scheme=sample;end",
iosDeepLink = "new-custom:Item?value=${"a".repeat(512)}%2f"
)
)
val response = send(recommendationPath(placement), userAgent = "iPhone iOS")
evidence("all OS links $placement", response)
assertEquals(listOf(androidOnly, allLinks).map(::publicTree), subBanners(response, placement).toList())
assertEquals(1, time.calls.get())
}
@ParameterizedTest
@EnumSource(SubBannerPlacement::class)
@DisplayName("추천 경로가 placement를 고정하며 다른 placement query로 바꿀 수 없다")
fun shouldKeepPlacementFixedByRecommendationRoute(placement: SubBannerPlacement) {
val saved = save(banner(placement))
val other = SubBannerPlacement.values().first { it != placement }
save(banner(other))
val response = send("${recommendationPath(placement)}?placement=$other")
assertEquals(listOf(publicTree(saved)), subBanners(response, placement).toList())
assertEquals(1, time.calls.get())
}
@Test
@DisplayName("폐기한 앱 GET은 handler가 없고 인증 요청도 404이며 익명 요청은 기존 인증 정책으로 거부한다")
fun shouldRemoveStandaloneHandlerAndReturnNotFoundForAuthenticatedRequest() {
save(banner())
val before = storedRows()
val response = send("$RETIRED_PATH?placement=HOME_RECOMMENDATION", token = tokenFor(MemberRole.USER))
evidence("retired authenticated GET", response)
assertEquals(HttpStatus.NOT_FOUND, response.statusCode, response.body)
val body = objectMapper.readTree(response.body)
assertEquals(404, body["status"].asInt())
assertFalse(body.has("trace"))
assertFalse(body.has("exception"))
assertFalse(requestMappingHandlerMapping.handlerMethods.keys.any { RETIRED_PATH in it.patternValues })
val anonymous = send("$RETIRED_PATH?placement=HOME_RECOMMENDATION")
evidence("retired anonymous GET", anonymous)
assertEquals(HttpStatus.UNAUTHORIZED, anonymous.statusCode, anonymous.body)
assertEquals(0, time.calls.get())
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("익명은 정확한 GET만 허용하고 HEAD·변경 method·하위·접두사 인접 경로는 401이다")
fun shouldPermitOnlyExactPublicGetAndKeepAdminProtected() {
val saved = save(banner())
val before = storedRows()
SubBannerPlacement.values().forEach { placement ->
val path = recommendationPath(placement)
val expected = if (placement == saved.placement) listOf(publicTree(saved)) else emptyList()
assertEquals(expected, subBanners(send(path), placement).toList())
listOf(HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE, HttpMethod.HEAD, HttpMethod.PATCH).forEach { method ->
val response = send(path, method)
evidence("anonymous $method $path", response)
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body)
}
listOf("$path/", "$path/${saved.id}", "$path/extra", "$path-extra").forEach { neighbor ->
val response = send(neighbor)
evidence("anonymous GET $neighbor", response)
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body)
}
}
listOf("lives", "debut-creators", "ai-characters").forEach { section ->
val response = send("/api/v2/home/recommendations/$section")
evidence("anonymous home page $section", response)
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body)
}
listOf(HttpMethod.GET to "$ADMIN_PATH?placement=HOME_RECOMMENDATION", HttpMethod.DELETE to "$ADMIN_PATH/${saved.id}")
.forEach { (method, path) ->
val response = send(path, method)
evidence("anonymous admin $method", response)
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode, response.body)
}
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("실제 비관리자 JWT의 관리자 조회·삭제 거부는 기존 HTTP 200 success false를 유지한다")
fun shouldKeepNonAdminReadAndMutationDeniedWithoutSideEffects() {
val saved = save(banner())
val before = storedRows()
val token = tokenFor(MemberRole.USER)
listOf(HttpMethod.GET to "$ADMIN_PATH?placement=HOME_RECOMMENDATION", HttpMethod.DELETE to "$ADMIN_PATH/${saved.id}")
.forEach { (method, path) ->
val response = send(path, method, token)
evidence("non-admin $method", response)
assertEquals(HttpStatus.OK, response.statusCode, response.body)
val body = objectMapper.readTree(response.body)
assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet())
assertFalse(body["success"].asBoolean())
assertEquals("You do not have permission.", body["message"].asText())
assertTrue(body["data"].isNull)
}
assertEquals(before, storedRows())
Mockito.verifyNoInteractions(s3Uploader)
}
private fun send(
path: String,
method: HttpMethod = HttpMethod.GET,
token: String? = null,
userAgent: String = "sub-banner-http-test"
): ResponseEntity<String> = restTemplate.exchange(
"http://127.0.0.1:$port$path",
method,
HttpEntity<Any>(
HttpHeaders().apply {
token?.let { setBearerAuth(it) }
set(HttpHeaders.ACCEPT_LANGUAGE, "en")
set(HttpHeaders.USER_AGENT, userAgent)
accept = listOf(MediaType.APPLICATION_JSON)
}
),
String::class.java
)
private fun success(response: ResponseEntity<String>): JsonNode {
val data = successData(response)
assertTrue(data.isArray)
return data
}
private fun successData(response: ResponseEntity<String>): JsonNode {
assertEquals(HttpStatus.OK, response.statusCode, response.body)
val body = objectMapper.readTree(response.body)
assertEquals(setOf("success", "message", "data", "errorProperty"), body.fieldNames().asSequence().toSet())
assertTrue(body["success"].asBoolean(), response.body)
assertTrue(body["message"].isNull)
assertTrue(body["errorProperty"].isNull)
return body["data"]
}
private fun subBanners(response: ResponseEntity<String>, placement: SubBannerPlacement): JsonNode {
val data = successData(response)
assertTrue(data.isObject, response.body)
assertTrue(data.has("subBanners"), "Recommendation response must include subBanners: ${response.body}")
val legacyBanner = """"banners":[{"imageUrl":"https://cdn.test/legacy.png","eventItem":null,
"creatorId":null,"seriesId":null,"link":"https://example.test/legacy"}]"""
val expected = objectMapper.readTree(
when (placement) {
SubBannerPlacement.HOME_RECOMMENDATION ->
"""{
$legacyBanner,"lives":[],"recentlyActiveCreators":[],"recentDebutCreators":[],
"firstAudioContents":[],"aiCharacters":[],"genreCreators":[],"cheerCreators":[],"popularCommunityPosts":[]
}"""
SubBannerPlacement.CONTENT_RECOMMENDATION ->
"""{
$legacyBanner,"originalSeries":[],"latestAudios":[],"newAndHotAudios":[],"freeAudios":[],
"pointAudios":[],"mostCommentedAudios":[],"recommendedAudios":[]
}"""
}
)
assertEquals(expected.fieldNames().asSequence().toSet() + "subBanners", data.fieldNames().asSequence().toSet())
expected.fields().forEach { (field, value) -> assertEquals(value, data[field], "Existing $field must be preserved") }
assertTrue(data["subBanners"].isArray, response.body)
return data["subBanners"]
}
private fun recommendationPath(placement: SubBannerPlacement): String = when (placement) {
SubBannerPlacement.HOME_RECOMMENDATION -> "/api/v2/home/recommendations"
SubBannerPlacement.CONTENT_RECOMMENDATION -> "/api/v2/audio/recommendations"
}
private fun legacyBanner(): AudioContentBanner = AudioContentBanner(
thumbnailImage = "legacy.png",
type = AudioContentBannerType.LINK
).apply { link = "https://example.test/legacy" }
private fun publicTree(banner: SubBanner): JsonNode = objectMapper.readTree(
objectMapper.writeValueAsString(
mapOf(
"id" to banner.id,
"placement" to banner.placement.name,
"imageUrl" to "https://cdn.test/${banner.imagePath}",
"webUrl" to banner.webUrl,
"androidDeepLink" to banner.androidDeepLink,
"iosDeepLink" to banner.iosDeepLink
)
)
)
private fun ids(data: JsonNode): List<Long> = data.map { it["id"].asLong() }
private fun banner(
placement: SubBannerPlacement = SubBannerPlacement.HOME_RECOMMENDATION,
start: LocalDateTime? = null,
end: LocalDateTime? = null
): SubBanner = SubBanner(
placement = placement,
imagePath = "sub_banner/test.png",
webUrl = "https://example.test/promo?x=a%2Fb",
androidDeepLink = null,
iosDeepLink = null,
startDate = start,
endDate = end
)
private fun save(banner: SubBanner): SubBanner = requireNotNull(
transactionTemplate.execute {
val saved = repository.save(banner)
entityManager.flush()
entityManager.clear()
repository.findById(requireNotNull(saved.id))
}
)
private fun reload(id: Long): SubBanner = requireNotNull(
transactionTemplate.execute {
entityManager.clear()
repository.findById(id)
}
)
private fun storedRows(): List<Map<String, Any>> = jdbcTemplate.queryForList("select * from sub_banner order by id")
private fun tokenFor(role: MemberRole): String {
val member = memberRepository.saveAndFlush(
Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role)
)
val id = requireNotNull(member.id)
memberIds.add(id)
return tokenProvider.createToken(
UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))),
id
)
}
private fun evidence(label: String, response: ResponseEntity<String>) {
println("HTTP EVIDENCE $label ${response.statusCodeValue}: ${response.body}")
}
class TestTime {
private val value = AtomicReference<LocalDateTime>()
val calls = AtomicInteger()
fun set(now: LocalDateTime) {
value.set(now)
calls.set(0)
}
fun now(): LocalDateTime {
calls.incrementAndGet()
return value.get()
}
}
@TestConfiguration
class TimeConfiguration {
@Bean
fun subBannerTestTime(): TestTime = TestTime()
@Bean
@Primary
fun fixedTimeSubBannerService(
repository: SubBannerRepository,
s3Uploader: S3Uploader,
objectMapper: ObjectMapper,
time: TestTime
): SubBannerService = SubBannerService(repository, s3Uploader, objectMapper, "test-bucket", time::now)
}
companion object {
private const val RETIRED_PATH = "/api/v2/sub-banners"
private const val ADMIN_PATH = "/admin/sub-banners"
}
}
@@ -0,0 +1,400 @@
package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner
import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement
import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.EnumSource
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.nio.file.Files
import java.nio.file.Path
import java.sql.DriverManager
import java.sql.SQLException
import java.time.LocalDateTime
import java.util.stream.Stream
import javax.persistence.EntityManager
import javax.persistence.EntityManagerFactory
@DataJpaTest(properties = ["spring.cache.type=none"])
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(QueryDslConfig::class, DefaultSubBannerRepository::class)
class SubBannerPersistenceTest @Autowired constructor(
private val entityManager: EntityManager,
private val repository: SubBannerRepository,
private val entityManagerFactory: EntityManagerFactory,
private val jdbcTemplate: JdbcTemplate
) {
@BeforeEach
fun setSecondPrecisionColumns() {
jdbcTemplate.execute("alter table sub_banner alter column start_date timestamp(0)")
jdbcTemplate.execute("alter table sub_banner alter column end_date timestamp(0)")
}
@ParameterizedTest
@ValueSource(ints = [0, 123456789, 999999999])
@DisplayName("nullable UTC 기간은 저장 결과와 DB 재조회 모두 초 정밀도를 정확히 보존한다")
fun shouldRoundTripNullableDatesAtSecondPrecision(nano: Int) {
val start = LocalDateTime.of(2026, 10, 29, 23, 59, 0)
val end = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
val midnight = LocalDateTime.of(2026, 10, 30, 0, 0, 0)
for ((startDate, endDate) in listOf(null to null, start to null, null to end, start to end, midnight to midnight)) {
val input = banner().copy(startDate = startDate?.withNano(nano), endDate = endDate?.withNano(nano))
val saved = repository.save(input)
flushAndClear()
val stored = entityManager.find(SubBannerEntity::class.java, saved.id)
assertNotNull(stored)
assertEquals(startDate, stored.startDate)
assertEquals(endDate, stored.endDate)
val expected = input.copy(id = saved.id, startDate = startDate, endDate = endDate)
assertEquals(expected, repository.findById(requireNotNull(saved.id)))
assertEquals(expected, saved)
}
}
@Test
@DisplayName("이미지 key와 긴 원문 URI 및 7개 링크 조합을 DB 왕복에서도 자르거나 변형하지 않는다")
fun shouldRoundTripLongImageKeysAndSevenLinkCombinations() {
val suffix = "a".repeat(300)
for (mask in 1..7) {
val input = banner().copy(
imagePath = "sub-banner/$suffix.png",
webUrl = if (mask and 1 != 0) "https://example.com/$suffix?next=%2Fhome#detail" else null,
androidDeepLink = if (mask and 2 != 0) "sodalive://content/$suffix?next=%2Fhome" else null,
iosDeepLink = if (mask and 4 != 0) "sodalive-ios://content/$suffix#detail" else null
)
val saved = repository.save(input)
flushAndClear()
assertNotNull(saved.id)
assertEquals(input.copy(id = saved.id), repository.findById(requireNotNull(saved.id)))
assertEquals(
"HOME_RECOMMENDATION",
entityManager.createNativeQuery("select placement from sub_banner where id = :id")
.setParameter("id", saved.id).singleResult
)
}
}
@ParameterizedTest
@EnumSource(SubBannerPlacement::class)
@DisplayName("관리자 목록은 다른 placement를 제외하고 모든 기간의 20개 초과 행을 id 오름차순으로 반환한다")
fun shouldReturnAllPlacementRowsInIdOrderWithoutLimit(placement: SubBannerPlacement) {
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(placement))
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(placement, now))
val otherPlacement = SubBannerPlacement.values().first { it != placement }
val saved = (1..25).map { index ->
repository.save(banner(otherPlacement))
repository.save(banner(placement).copy(imagePath = "sub-banner/$index.png"))
}
val future = repository.save(banner(placement).copy(startDate = now.plusSeconds(1)))
val expired = repository.save(banner(placement).copy(endDate = now.minusSeconds(1)))
flushAndClear()
assertEquals(saved + future + expired, repository.findAllByPlacement(placement))
assertEquals(saved, repository.findVisibleByPlacement(placement, now))
}
@ParameterizedTest(name = "{0}")
@MethodSource("visibilityCases")
@DisplayName("공개 목록은 nullable 기간의 양끝과 소수초 now 및 날짜 경계를 초 단위로 판정한다")
fun shouldApplyInclusiveNullablePeriodAtSecondPrecision(
name: String,
now: LocalDateTime,
startDate: LocalDateTime?,
endDate: LocalDateTime?,
visible: Boolean
) {
val candidate = repository.save(banner().copy(startDate = startDate, endDate = endDate))
repository.save(banner(SubBannerPlacement.CONTENT_RECOMMENDATION))
flushAndClear()
assertEquals(
if (visible) listOf(candidate) else emptyList(),
repository.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, now),
name
)
}
@Test
@DisplayName("수정은 같은 id와 생성 시각을 보존하며 모든 필드 교체와 nullable 기간 해제를 저장한다")
fun shouldUpdateSameRowAndClearNullableFields() {
val saved = repository.save(banner().copy(startDate = LocalDateTime.of(2026, 10, 29, 23, 59, 0)))
flushAndClear()
val id = requireNotNull(saved.id)
val createdAt = entityManager.find(SubBannerEntity::class.java, id).createdAt
assertNotNull(createdAt)
val replacement = saved.copy(
placement = SubBannerPlacement.CONTENT_RECOMMENDATION,
imagePath = "sub-banner/replacement.png",
webUrl = null,
androidDeepLink = "sodalive://replacement",
iosDeepLink = "sodalive-ios://replacement",
startDate = null,
endDate = LocalDateTime.of(2026, 10, 30, 0, 0, 59, 999999999)
)
val updated = repository.save(replacement)
flushAndClear()
val expected = replacement.copy(endDate = LocalDateTime.of(2026, 10, 30, 0, 0, 59))
assertEquals(expected, updated)
assertEquals(expected, repository.findById(id))
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(SubBannerPlacement.HOME_RECOMMENDATION))
assertEquals(listOf(expected), repository.findAllByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION))
val stored = entityManager.find(SubBannerEntity::class.java, id)
assertEquals(createdAt, stored.createdAt)
assertNotNull(stored.updatedAt)
val cleared = expected.copy(
webUrl = "https://example.com/replacement",
androidDeepLink = null,
iosDeepLink = null,
endDate = null
)
assertEquals(cleared, repository.save(cleared))
flushAndClear()
assertEquals(cleared, repository.findById(id))
}
@ParameterizedTest
@EnumSource(SubBannerPlacement::class)
@DisplayName("소프트 삭제는 모든 기간의 행과 생성 정보를 보존하고 모든 조회에서 제외한다")
fun shouldRetainSoftDeletedRowsAndExcludeThemFromEveryRead(placement: SubBannerPlacement) {
assertNull(repository.findById(Long.MAX_VALUE))
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
val periods = listOf(null to null, now.minusMinutes(1) to now, now.plusSeconds(1) to null, null to now.minusSeconds(1))
val deleted = periods.map { (start, end) ->
repository.save(
banner(placement).copy(
androidDeepLink = "sodalive://content/123?from=banner",
iosDeepLink = "sodalive-ios://content/123#detail",
startDate = start,
endDate = end
)
)
}
val active = periods.map { (start, end) -> repository.save(banner(placement).copy(startDate = start, endDate = end)) }
val other = repository.save(banner(SubBannerPlacement.values().first { it != placement }))
flushAndClear()
val createdAt = deleted.associate { it.id to entityManager.find(SubBannerEntity::class.java, it.id).createdAt }
deleted.forEach { repository.deleteById(requireNotNull(it.id)) }
flushAndClear()
deleted.forEach { original ->
val stored = entityManager.find(SubBannerEntity::class.java, original.id)
assertNotNull(stored, "Soft delete must retain the original row")
assertEquals(original, stored.toDomain())
assertEquals(createdAt[original.id], stored.createdAt)
assertNotNull(stored.updatedAt)
assertEquals(
false,
entityManager.createNativeQuery("select is_active from sub_banner where id = :id")
.setParameter("id", original.id).singleResult
)
assertNull(repository.findById(requireNotNull(original.id)))
}
active.forEach { assertEquals(it, repository.findById(requireNotNull(it.id))) }
assertEquals(active, repository.findAllByPlacement(placement))
assertEquals(active.take(2), repository.findVisibleByPlacement(placement, now))
assertEquals(other, repository.findById(requireNotNull(other.id)))
assertEquals(listOf(other), repository.findAllByPlacement(other.placement))
assertEquals(listOf(other), repository.findVisibleByPlacement(other.placement, now))
}
@Test
@DisplayName("삭제 전 도메인 스냅샷 저장도 비활성 행을 다시 활성화하지 않는다")
fun shouldNotReactivateDeletedRowWhenSavingStaleDomain() {
val saved = repository.save(banner())
flushAndClear()
val id = requireNotNull(saved.id)
repository.deleteById(id)
flushAndClear()
assertNotNull(entityManager.find(SubBannerEntity::class.java, id), "Soft delete must retain the original row")
repository.save(saved.copy(webUrl = "https://example.com/changed"))
flushAndClear()
assertEquals(
false,
entityManager.createNativeQuery("select is_active from sub_banner where id = :id")
.setParameter("id", id).singleResult
)
assertNull(repository.findById(id))
assertEquals(emptyList<SubBanner>(), repository.findAllByPlacement(saved.placement))
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(saved.placement, LocalDateTime.now()))
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("삭제 전 활성 managed entity를 유지한 수정 트랜잭션도 먼저 커밋된 소프트 삭제를 되돌리지 않는다")
fun shouldNotReactivateDeletedRowWhenStaleManagedEntityCommits() {
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 0)
val updateContext = entityManagerFactory.createEntityManager()
val deleteContext = entityManagerFactory.createEntityManager()
val readContext = entityManagerFactory.createEntityManager()
var seededId: Long? = null
try {
val deleteRepository = DefaultSubBannerRepository(deleteContext, JPAQueryFactory(deleteContext))
deleteContext.transaction.begin()
val saved = deleteRepository.save(banner().copy(startDate = now, endDate = now.plusSeconds(59)))
val id = requireNotNull(saved.id)
seededId = id
deleteContext.transaction.commit()
updateContext.transaction.begin()
val updateRepository = DefaultSubBannerRepository(updateContext, JPAQueryFactory(updateContext))
val existing = requireNotNull(updateRepository.findById(id))
val staleEntity = updateContext.find(SubBannerEntity::class.java, id)
val createdAt = staleEntity.createdAt
assertTrue(staleEntity.isActive)
deleteContext.transaction.begin()
deleteRepository.deleteById(id)
println("CONCURRENCY EVIDENCE delete commit")
deleteContext.transaction.commit()
assertTrue(staleEntity.isActive, "The update context must retain its stale active entity")
assertSame(staleEntity, updateContext.find(SubBannerEntity::class.java, id))
val replacement = existing.copy(webUrl = "https://example.com/concurrent-update")
assertEquals(replacement, updateRepository.save(replacement))
println("CONCURRENCY EVIDENCE stale payload commit")
updateContext.transaction.commit()
readContext.transaction.begin()
val stored = readContext.find(SubBannerEntity::class.java, id)
assertNotNull(stored, "Concurrent update must preserve the soft-deleted row")
val active = readContext.createNativeQuery("select is_active from sub_banner where id = :id")
.setParameter("id", id).singleResult
println(
"CONCURRENCY DB EVIDENCE id=$id is_active=$active " +
"payload=${stored.toDomain()} created_at=${stored.createdAt}"
)
assertEquals(false, active, "Stale managed payload update must not reactivate a committed soft delete")
assertEquals(replacement, stored.toDomain())
assertEquals(createdAt, stored.createdAt)
val readRepository = DefaultSubBannerRepository(readContext, JPAQueryFactory(readContext))
assertNull(readRepository.findById(id))
assertEquals(emptyList<SubBanner>(), readRepository.findAllByPlacement(saved.placement))
assertEquals(emptyList<SubBanner>(), readRepository.findVisibleByPlacement(saved.placement, now))
readContext.transaction.commit()
} finally {
listOf(updateContext, deleteContext, readContext).forEach { context ->
if (context.transaction.isActive) context.transaction.rollback()
context.close()
}
seededId?.let { id ->
val removed = jdbcTemplate.update("delete from sub_banner where id = ?", id)
println("CONCURRENCY EVIDENCE fixture cleanup rows=$removed")
}
}
}
@Test
@DisplayName("ORM 생성 기본값은 true이며 활성 컬럼은 null을 허용하지 않는다")
fun shouldCreateActiveRowsWithNonNullableFlag() {
val saved = repository.save(banner())
flushAndClear()
val columns = entityManager.createNativeQuery(
"select count(*) from INFORMATION_SCHEMA.COLUMNS " +
"where TABLE_NAME = 'sub_banner' and COLUMN_NAME = 'is_active' and IS_NULLABLE = 'NO'"
).singleResult as Number
assertEquals(1, columns.toInt(), "ORM schema must contain a non-nullable is_active column")
assertEquals(
true,
entityManager.createNativeQuery("select is_active from sub_banner where id = :id")
.setParameter("id", saved.id).singleResult
)
}
@Test
@DisplayName("작성한 CREATE DDL은 실제 실행 시 활성 기본값 true와 NOT NULL을 보장한다")
fun shouldApplyActiveDefaultAndNotNullFromAuthoredCreateDdl() {
DriverManager.getConnection(
"jdbc:h2:mem:sub-banner-soft-delete-ddl;MODE=MySQL;DATABASE_TO_UPPER=false"
).use { connection ->
connection.createStatement().use { statement ->
statement.execute(Files.readString(Path.of("docs/20261029_가로형서브배너/create-sub-banner.sql")))
connection.metaData.getColumns(null, null, "sub_banner", "is_active").use { columns ->
assertTrue(columns.next(), "CREATE DDL must contain is_active")
assertEquals("NO", columns.getString("IS_NULLABLE"))
}
statement.executeUpdate(
"insert into sub_banner (placement, image_path, web_url) " +
"values ('HOME_RECOMMENDATION', 'sub-banner/image.png', 'https://example.com/banner')"
)
statement.executeQuery("select is_active from sub_banner").use { rows ->
assertTrue(rows.next())
assertTrue(rows.getBoolean("is_active"))
}
assertThrows(SQLException::class.java) {
statement.executeUpdate("update sub_banner set is_active = null")
}
}
}
}
private fun banner(placement: SubBannerPlacement = SubBannerPlacement.HOME_RECOMMENDATION): SubBanner = SubBanner(
placement = placement,
imagePath = "sub-banner/image.png",
webUrl = "https://example.com/banner",
androidDeepLink = null,
iosDeepLink = null,
startDate = null,
endDate = null
)
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
companion object {
@JvmStatic
fun visibilityCases(): Stream<Arguments> {
val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59)
val before = LocalDateTime.of(2026, 10, 29, 23, 59, 58)
val midnight = LocalDateTime.of(2026, 10, 30, 0, 0, 0)
return Stream.of(
Arguments.of("unbounded", now, null, null, true),
Arguments.of("start-before", now, before, null, true),
Arguments.of("start-exact", now, now, null, true),
Arguments.of("start-after", now, midnight, null, false),
Arguments.of("end-before", now, null, before, false),
Arguments.of("end-exact", now, null, now, true),
Arguments.of("end-after", now, null, midnight, true),
Arguments.of("inside", now, before, midnight, true),
Arguments.of("before-range", now, midnight, midnight.plusSeconds(1), false),
Arguments.of("after-range", now, before.minusSeconds(1), before, false),
Arguments.of("both-exact", now, now, now, true),
Arguments.of("end-second-123456789", now.withNano(123456789), before, now, true),
Arguments.of("end-second-999999999", now.withNano(999999999), before, now, true),
Arguments.of("next-second-not-started", now.withNano(999999999), midnight, null, false),
Arguments.of("midnight-start-exact", midnight, midnight, null, true),
Arguments.of("midnight-expired", midnight, null, now, false)
)
}
}
}
@@ -0,0 +1,53 @@
package kr.co.vividnext.sodalive.v2.subbanner.domain
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class SubBannerTest {
@Test
@DisplayName("세 링크 중 하나 이상이 있는 7개 조합은 허용하고 모두 null이면 거부한다")
fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations() {
for (mask in 1..7) {
val webUrl = if (mask and 1 != 0) "https://example.com/banner?next=%2Fhome" else null
val androidDeepLink = if (mask and 2 != 0) "sodalive://content/123?from=banner" else null
val iosDeepLink = if (mask and 4 != 0) "sodalive-ios://content/123#detail" else null
val banner = banner(webUrl, androidDeepLink, iosDeepLink)
assertEquals(webUrl, banner.webUrl)
assertEquals(androidDeepLink, banner.androidDeepLink)
assertEquals(iosDeepLink, banner.iosDeepLink)
}
assertThrows(IllegalArgumentException::class.java) { banner(null, null, null) }
}
@Test
@DisplayName("null과 빈 문자열 및 공백만으로 된 모든 조합을 거부한다")
fun shouldRejectAllBlankLinksAndAllowAnyNonblankLink() {
val emptyLinks = listOf(null, "", " ", "\t\n")
for (webUrl in emptyLinks) {
for (androidDeepLink in emptyLinks) {
for (iosDeepLink in emptyLinks) {
assertThrows(IllegalArgumentException::class.java) { banner(webUrl, androidDeepLink, iosDeepLink) }
}
}
}
assertEquals("https://example.com", banner("https://example.com", " ", "").webUrl)
assertEquals("sodalive://home", banner(" ", "sodalive://home", "").androidDeepLink)
assertEquals("sodalive-ios://home", banner("", " ", "sodalive-ios://home").iosDeepLink)
}
private fun banner(webUrl: String?, androidDeepLink: String?, iosDeepLink: String?): SubBanner = SubBanner(
placement = SubBannerPlacement.HOME_RECOMMENDATION,
imagePath = "sub-banner/image.png",
webUrl = webUrl,
androidDeepLink = androidDeepLink,
iosDeepLink = iosDeepLink,
startDate = null,
endDate = null
)
}