From 103f8c4396c030d4d44b03edcdb5855e3322ed76 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 29 Oct 2026 23:24:20 +0900 Subject: [PATCH] =?UTF-8?q?docs(subbanner):=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=EA=B3=BC=20=EC=95=B1=C2=B7=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EA=B3=84=EC=95=BD=EC=9D=84=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20261029_가로형서브배너/admin-prompt.md | 90 +++++++++++ docs/20261029_가로형서브배너/api-contract.md | 141 +++++++++++++++++ docs/20261029_가로형서브배너/app-prompt.md | 102 +++++++++++++ docs/20261029_가로형서브배너/prd.md | 152 +++++++++++++++++++ 4 files changed, 485 insertions(+) create mode 100644 docs/20261029_가로형서브배너/admin-prompt.md create mode 100644 docs/20261029_가로형서브배너/api-contract.md create mode 100644 docs/20261029_가로형서브배너/app-prompt.md create mode 100644 docs/20261029_가로형서브배너/prd.md diff --git a/docs/20261029_가로형서브배너/admin-prompt.md b/docs/20261029_가로형서브배너/admin-prompt.md new file mode 100644 index 00000000..a7882b2d --- /dev/null +++ b/docs/20261029_가로형서브배너/admin-prompt.md @@ -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 `을 전송하고 `ADMIN` 역할이 필요하다. 익명 요청은 HTTP 401이다. 유효한 비관리자 요청은 현재 정책상 HTTP 200 `success:false`이며 403을 보장하지 않는다. +- 성공은 HTTP 200 및 네 필드 `ApiResponse`: `success`, `message`, `data`, `errorProperty`. GET `data`는 전체 객체 배열, POST/PUT은 저장된 전체 객체, DELETE는 `null`. 빈 목록 `[]`. +- 관리자 응답 형식 `AdminSubBanner`: `id` integer JSON number(정밀도를 보존하는 불투명 식별자), `placement` enum(`HOME_RECOMMENDATION` 또는 `CONTENT_RECOMMENDATION`), `imageUrl` 절대 http/https URL 문자열, `webUrl`·`androidDeepLink`·`iosDeepLink` 각각 string|null, `startDate`·`endDate` 각각 string|null. 모든 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이다. 목록, 생성, 수정 모두 이 관리자 shape를 쓴다. +- 앱은 두 추천 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는 변경하지 않는다. diff --git a/docs/20261029_가로형서브배너/api-contract.md b/docs/20261029_가로형서브배너/api-contract.md new file mode 100644 index 00000000..64b3aba8 --- /dev/null +++ b/docs/20261029_가로형서브배너/api-contract.md @@ -0,0 +1,141 @@ +# 가로형 서브 배너 API 계약 + +> **상태: 소프트 삭제·두 추천 API 통합 구현 및 검증 완료, 미배포.** 최종 전체 테스트 2,682개(실패·오류·무시 0), lint·bootJar 및 독립 리뷰를 통과했다. 운영 DB 적용과 배포는 수행하지 않았다. + +## 1. 공통 형식 + +애플리케이션 성공 응답은 네 필드를 가진 `ApiResponse` 형식이다. 아래는 관리자 빈 목록 예시이며, 두 추천 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` | +| `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 ` 인증과 `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 적용과 배포는 별도 승인 대상으로 이번 작업에서는 수행하지 않는다. diff --git a/docs/20261029_가로형서브배너/app-prompt.md b/docs/20261029_가로형서브배너/app-prompt.md new file mode 100644 index 00000000..c006d2ff --- /dev/null +++ b/docs/20261029_가로형서브배너/app-prompt.md @@ -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를 구현하지 말고, 서버 준비를 완료했다고 보고하지 않는다. diff --git a/docs/20261029_가로형서브배너/prd.md b/docs/20261029_가로형서브배너/prd.md new file mode 100644 index 00000000..5d90028f --- /dev/null +++ b/docs/20261029_가로형서브배너/prd.md @@ -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` 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`다. 신규 계약의 승인이나 구현을 뜻하지 않는다.