docs(subbanner): 요구사항과 앱·관리자 연동 계약을 정의한다
This commit is contained in:
@@ -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 적용과 배포는 별도 승인 대상으로 이번 작업에서는 수행하지 않는다.
|
||||
Reference in New Issue
Block a user