docs(banner): 언어 필터 계약과 검증 결과를 기록한다
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
> **상태: 소프트 삭제·두 추천 API 통합 구현 및 검증 완료, 미배포.** 최종 전체 테스트 2,682개(실패·오류·무시 0), lint·bootJar 및 독립 리뷰를 통과했다. 운영 DB 적용과 배포는 수행하지 않았다.
|
||||
|
||||
2026-11-03 언어 후속 Phase 7 검증도 완료했다. 전체 2,928개 통과, 테스트 공백 수정 후 영향 HTTP 26개·전체 lint·bootJar 재확인 및 독립 리뷰 APPROVE다. 위 2,682개는 기존 Phase 2~6 이력이며 후속 증거는 `plan-task.md` Phase 7에 기록한다. 운영 DDL·배포는 미실행이다.
|
||||
|
||||
## 1. 공통 형식
|
||||
|
||||
애플리케이션 성공 응답은 네 필드를 가진 `ApiResponse<T>` 형식이다. 아래는 관리자 빈 목록 예시이며, 두 추천 GET의 data는 §2의 응답 객체다.
|
||||
@@ -37,9 +39,11 @@
|
||||
| `androidDeepLink` | string 또는 null | Android 대상 링크 원문 |
|
||||
| `iosDeepLink` | string 또는 null | iOS 대상 링크 원문 |
|
||||
|
||||
공개 `SubBanner`에는 날짜 필드를 추가하지 않는다. 앱 응답은 아래와 같이 기간 필터 적용 후의 기본 필드만 반환한다. 세 주소 중 하나 이상은 공백이 아닌 값이어야 한다. 입력 주소는 trim 후 공백이면 `null`로 보내고, nonblank URI는 trim 외 재인코딩·정규화·손상을 가하지 않는다. `webUrl`은 host를 가진 절대 http(s) URL이어야 한다. OS 딥링크는 유효한 절대 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 임의 scheme allowlist는 없다.
|
||||
공개 `SubBanner`는 정확히 위 6필드이며 `lang`·날짜·활성 필드를 추가하지 않는다. 앱 응답은 국가별 언어·기간 필터 적용 후의 기본 필드만 반환한다. 세 주소 중 하나 이상은 공백이 아닌 값이어야 한다. 입력 주소는 trim 후 공백이면 `null`로 보내고, nonblank URI는 trim 외 재인코딩·정규화·손상을 가하지 않는다. `webUrl`은 host를 가진 절대 http(s) URL이어야 한다. OS 딥링크는 유효한 절대 URI이며 `javascript`, `data`, `file`, `content` scheme은 대소문자와 무관하게 거부된다. 임의 scheme allowlist는 없다.
|
||||
|
||||
관리자 전용 `AdminSubBanner`는 `SubBanner`의 기본 필드에 nullable `startDate`, `endDate`를 더한 형태다. 관리자 GET 및 POST/PUT 응답은 모두 `AdminSubBanner`를 반환한다. 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이다.
|
||||
관리자 전용 `AdminSubBanner`는 `SubBanner`의 기본 필드에 nullable `startDate`, `endDate`와 필수 `lang`을 더한 정확히 9필드다. 관리자 GET 및 POST/PUT 응답은 모두 `AdminSubBanner`를 반환한다. 날짜는 `Asia/Seoul` 기준 `yyyy-MM-dd HH:mm` 문자열 또는 null이고, `lang` 응답은 `KO`, `EN`, `JA` 중 하나다.
|
||||
|
||||
요청의 `lang`은 선택적 문자열이며 기존 `Lang.fromCode` 규칙을 사용한다. `KO`/`EN`/`JA` 및 `ko`/`en`/`ja`를 대소문자 구분 없이, 앞뒤 공백을 제거해 허용한다. 생성 생략은 KO, 수정 생략은 저장된 언어 유지다. 명시적 null, 숫자·boolean·배열·객체, blank 문자열, 알 수 없는 코드는 HTTP 400이며 이미지 업로드·DB 쓰기 전에 거부한다. 날짜의 null 해제와 달리 언어 null은 허용하지 않는다.
|
||||
|
||||
관리자 기간 의미는 기존 콘텐츠 배너 예약과 동일하다. null/null은 무제한, 시작만 있으면 시작부터, 종료만 있으면 해당 종료 경계까지 포함하며 시작과 종료가 같은 분인 것도 유효하다. 입력·응답은 분 단위지만 실제 노출 판정은 초 단위로 양 끝을 포함한다. 시작 경계는 입력 분의 `:00`, 종료 경계는 입력 분의 `:59`다. 예를 들어 종료 입력이 `2026-11-01 12:00`이면 `12:00:59`까지 포함하고 `12:01:00`부터 제외한다. API 문자열에는 초, `Z`, offset을 표현하거나 받지 않는다.
|
||||
|
||||
@@ -56,7 +60,7 @@ UTC로 변환된 경계 instant는 `1970-01-01 00:00:01`부터 `2038-01-19 03:14
|
||||
| 접근 | 익명 조회 가능. 로그인 사용자도 기존 클라이언트 헤더를 유지해 조회한다. |
|
||||
| 성공 | HTTP 200, `ApiResponse<HomeRecommendationResponse>` |
|
||||
| `data` | 기존 홈 추천 응답 객체의 모든 필드와 `subBanners: SubBanner[]`를 포함한다. 기존 `banners` 및 추천 섹션은 유지한다. |
|
||||
| `data.subBanners` | 해당 placement의 활성 배너 중 현재 노출 기간 안에 있는 전체 항목, id 오름차순. 페이지네이션·묵시적 절단 없음. 결과가 없으면 `[]`. 날짜·활성 필드는 응답하지 않는다. |
|
||||
| `data.subBanners` | 해당 placement의 활성 배너 중 회원 국가별 언어와 현재 노출 기간에 맞는 전체 항목, id 오름차순. 페이지네이션·묵시적 절단 없음. 결과가 없으면 `[]`. 언어·날짜·활성 필드는 응답하지 않는다. |
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -84,15 +88,17 @@ UTC로 변환된 경계 instant는 `1970-01-01 00:00:01`부터 `2038-01-19 03:14
|
||||
|
||||
두 추천 GET은 Authorization 헤더 없이 200으로 조회할 수 있다. 아래 인증 오류 설명은 관리자 등 인증이 필요한 경로에 적용되며, 앱 추천 조회에 새 로그인 조건을 추가하는 것이 아니다. 서브 배너 전용 HTTP 요청·서버 fallback은 없다.
|
||||
|
||||
두 endpoint의 서브 배너 언어는 기존 `MemberContentPreferenceService.resolveCountryCode(member)`로 판정한다. 강제 회원 매핑이 접속 국가 헤더보다 우선하며, 일반 회원·익명은 `CloudFront-Viewer-Country`를 사용하고 누락 시 KR이다. JP는 JA, KR·US 등 그 외는 KO만 조회한다. `Accept-Language`나 `LangContext`로 선택하지 않고 별도 언어 query도 받지 않는다. EN은 관리자에서 저장·조회할 수 있지만 현재 공개 국가 매핑으로는 노출되지 않는다. 이벤트 연결을 파싱하거나 외부 링크를 번역하지 않으며 기존 링크 원문을 유지한다.
|
||||
|
||||
## 3. 관리자 목록과 변경
|
||||
|
||||
관리자 경로는 `Authorization: Bearer <token>` 인증과 `ADMIN` 역할이 필요하다. 유효한 비관리자 요청이 반드시 403을 반환하는 것은 아니며 현재 정책 응답은 HTTP 200 `success:false`다.
|
||||
|
||||
| Method / Path | 목적 | 성공 data |
|
||||
|---|---|---|
|
||||
| `GET /admin/sub-banners?placement={placement}` | placement별 삭제되지 않은 활성 레코드, 기간 무관. 필수 query, id 오름차순 | `AdminSubBanner[]` |
|
||||
| `GET /admin/sub-banners?placement={placement}` | placement별 삭제되지 않은 활성 레코드, 기간·언어 무관. 필수 query, id 오름차순 | `AdminSubBanner[]` |
|
||||
| `POST /admin/sub-banners` | 신규 생성 | 생성된 `AdminSubBanner` |
|
||||
| `PUT /admin/sub-banners/{id}` | 원래 네 JSON 필드는 교체, 날짜 필드는 선택적 생략 보존 | 수정된 `AdminSubBanner` |
|
||||
| `PUT /admin/sub-banners/{id}` | 원래 네 JSON 필드는 교체, 날짜와 언어 필드는 선택적 생략 보존 | 수정된 `AdminSubBanner` |
|
||||
| `DELETE /admin/sub-banners/{id}` | 소프트 삭제 | `null` |
|
||||
|
||||
삭제는 내부 `is_active` 값을 false로 변경하며 행·이미지 key·주소·기간·생성 시각과 S3 이미지는 보존한다. 생성 기본값은 true다. 삭제된 id는 관리자/공개 목록에서 제외하고, 수정·재삭제는 없는 대상과 동일하게 404다. 성공 응답은 기존 HTTP 200 및 `data:null`을 유지한다. `isActive` 요청/응답 필드나 복구·활성 토글 API는 추가하지 않는다.
|
||||
@@ -106,12 +112,14 @@ POST와 PUT은 `multipart/form-data`다. `image` 파트는 파일이며, `reques
|
||||
"androidDeepLink": "sampleapp://promo",
|
||||
"iosDeepLink": null,
|
||||
"startDate": "2026-11-01 09:30",
|
||||
"endDate": null
|
||||
"endDate": null,
|
||||
"lang": "JA"
|
||||
}
|
||||
```
|
||||
|
||||
- POST: `image` 필수.
|
||||
- PUT은 기존 네 필드 `placement`, `webUrl`, `androidDeepLink`, `iosDeepLink`를 모두 보낸다. 이 네 필드와 placement는 기존처럼 필수 replacement 값이다. 주소 `null`은 해당 주소를 지운다. 이는 전체 JSON replacement라고 무조건 부르는 대신, 아래 두 날짜 필드만 예외적으로 생략 보존한다고 명시한다.
|
||||
- PUT은 기존 네 필드 `placement`, `webUrl`, `androidDeepLink`, `iosDeepLink`를 모두 보낸다. 이 네 필드와 placement는 기존처럼 필수 replacement 값이다. 주소 `null`은 해당 주소를 지운다. 이는 전체 JSON replacement가 아니며 아래 날짜와 언어 필드는 생략 보존한다.
|
||||
- `lang` 생성 생략은 KO, 수정 생략은 기존 언어 유지다. 언어를 바꾸려면 유효한 문자열을 보내며 명시적 null은 지우기가 아니라 HTTP 400이다.
|
||||
- `startDate`, `endDate`는 요청에서 각각 선택적이다. POST에서 생략 또는 null이면 무제한 경계다. PUT에서 생략하면 기존 경계를 보존하고, null이면 그 경계만 해제한다. 둘 다 보낼 필요는 없다. PUT은 날짜 두 필드를 모두 보내도 된다.
|
||||
- 기간만 수정하더라도 `placement`와 세 주소는 현재 저장된 값을 그대로 보낸다. 예를 들어 현재 주소가 아래 예시와 같을 때 `{"placement":"HOME_RECOMMENDATION","webUrl":"https://example.test/promo","androidDeepLink":"sampleapp://promo","iosDeepLink":null,"startDate":"2026-11-01 09:30"}`는 생략한 `endDate`를 유지한다. 같은 주소를 유지하며 `"startDate":null,"endDate":null`을 보내면 양쪽 경계만 해제한다. 기간 변경을 위해 기존 주소를 임의로 null로 바꾸지 않는다.
|
||||
- PUT에서 `image` 생략 시 기존 이미지를 유지하고, 포함 시 교체한다.
|
||||
@@ -138,4 +146,4 @@ POST와 PUT은 `multipart/form-data`다. `image` 파트는 파일이며, `reques
|
||||
|
||||
관리자 CRUD·소프트 삭제·두 추천 API 통합을 구현하고 최종 전체 2,682개 테스트와 lint·bootJar·독립 리뷰를 통과했다. 실제 HTTP/DB 및 단계별 실행 증거는 [plan-task.md](plan-task.md)에 있다. DDL은 독립 H2에 적용해 검증했지만 native MySQL 및 운영 DB에는 적용하지 않았다. 구현·검증 완료를 운영 배포 완료로 간주하지 않는다.
|
||||
|
||||
배포 시 신규 `sub_banner` 테이블과 `is_active` 컬럼 등 현재 DDL을 먼저 준비해야 한다. 통합된 추천 조회도 해당 테이블을 사용하므로 DDL 준비 전 서버 코드를 배포하지 않는다. 실제 운영 DDL 적용과 배포는 별도 승인 대상으로 이번 작업에서는 수행하지 않는다.
|
||||
서버 배포 전에 신규 환경은 [create-sub-banner.sql](create-sub-banner.sql), 기존 테이블은 [20261102_add_sub_banner_lang.sql](20261102_add_sub_banner_lang.sql)을 적용해 `lang VARCHAR(10) NOT NULL DEFAULT 'KO'`를 준비해야 한다. 기존 `is_active` 등 선행 스키마도 필요하다. 언어 migration은 컬럼 존재 확인 → nullable 추가 → NULL의 KO backfill → NOT NULL/default 순서다. KO는 기존 행의 언어를 추론한 결과가 아니므로 운영 담당자가 JA 소재를 수동 분류하고 연결 이벤트 언어와 일치하는지 검토해야 한다. 외부 링크는 원문 그대로이며 자동 언어 분류·링크 파서는 없다. 통합 추천 조회도 새 컬럼을 사용하므로 migration 전에 서버 코드를 배포하지 않는다. SQL의 운영 적용·native MySQL 검증·배포는 이번 작업에서 수행하지 않는다.
|
||||
|
||||
Reference in New Issue
Block a user