# 다르만 출판사 API 가이드

외부 시스템과 AI 에이전트가 소식지 서비스를 그대로 다룰 수 있도록 만든 연동 안내서입니다.
글을 올리고, 재작성 지시를 받아 고쳐 올리고, 컨펌해서 게시·발송하는 전 과정을 API로 처리할 수 있습니다.

- 기계가 읽는 명세(OpenAPI 3.1): `https://darman-pub.azurewebsites.net/openapi.json`
- 이 문서 내려받기: `https://darman-pub.azurewebsites.net/api-guide.md`
- 기준 주소: `https://darman-pub.azurewebsites.net`

## 1. 빠른 시작

### 1) 키 발급

관리자 화면 → **설정 → 외부 연동 API 키**에서 이름과 권한을 고르고 발급합니다.
키는 **발급 순간 한 번만** 표시되니 바로 안전한 곳에 보관하세요. 형식은 `dmk_` 로 시작합니다.

### 2) 첫 호출

```
curl https://darman-pub.azurewebsites.net/api/v1/categories \
  -H "Authorization: Bearer $DARMAN_API_KEY"
```

```
{
  "ok": true,
  "items": [
    { "id": 1, "slug": "buddhist-tales", "name": "불교 야화", "next_series_no": 1001 },
    { "id": 2, "slug": "healing", "name": "힐링 메시지", "next_series_no": 1 }
  ]
}
```

### 3) 글 올리기

```
curl -X POST https://darman-pub.azurewebsites.net/api/v1/posts \
  -H "Authorization: Bearer $DARMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"category":"healing","title":"오늘의 한 문장","body_md":"## 한 줄 요약\n오늘은 이만큼만.","tags":["위로"]}'
```

응답에 **검토 링크와 웹훅 주소**가 함께 옵니다. 사람이 컨펌하면 게시됩니다.

## 2. 인증

모든 `/api/v1/*` 요청에 키를 넣습니다. 세션이나 CSRF 토큰은 필요 없습니다.

```
Authorization: Bearer dmk_xxxxxxxxxxxxxxxxxxxx
```

`X-API-Key: dmk_...` 헤더도 같은 뜻으로 받습니다. 키가 없거나 폐기됐으면 `401`, 권한이 모자라면 `403` 이 돌아옵니다.

### 권한(scope)

| scope | 할 수 있는 일 |
|---|---|
| `content:read` | 주제·글 목록·상세 조회, 검토 큐 읽기 |
| `content:write` | 글 올리기·수정 (재작성 결과 재업로드 포함) |
| `review:write` | 컨펌·재작성 지시·즉시 게시·검토 요청 메일 발송 |
| `admin:read` | 통계·발송 내역·설정 조회 |
| `admin:write` | 설정 변경·글 삭제·재발송·발송 작업 실행 |

집필 에이전트에는 보통 `content:read`, `content:write` 만 주고, 컨펌은 사람이 하도록 두는 편이 안전합니다.

## 3. 글의 상태

| 상태 | 뜻 | 공개 | 메일 |
|---|---|---|---|
| `pending` | 컨펌(검토 승인) 대기 | 안 됨 | 안 나감 |
| `rewrite` | 재작성 요청됨 (사유가 `review_note` 에) | 안 됨 | 안 나감 |
| `scheduled` | 컨펌됐고 예약 시각 대기 | 안 됨 | 그 시각에 |
| `published` | 게시됨 | 공개 | 발송됨 |
| `draft` | 초안 | 안 됨 | 안 나감 |
| `archived` | 보관 | 안 됨 | 안 나감 |

컨펌 게이트(설정 `review_required`, 기본 켜짐)가 켜져 있으면 **새로 올라온 글은 무조건 `pending`** 으로 들어옵니다.

### 컨펌하면 벌어지는 일

- 예약 시각(`publish_at`)이 **미래**면 → `scheduled` 로 두고 그 시각에 게시·발송합니다.
- 예약 시각이 없거나 지났으면 → **즉시 게시**하고 구독자 발송 큐를 적재합니다.
- 전에 게시된 적 있는 글이면 → **원래 게시 시각을 유지**하고 **메일을 다시 보내지 않습니다**.

## 4. AI 에이전트 연동

집필 에이전트가 글을 올리고, 사람이 재작성을 지시하면 고쳐서 다시 올리는 순환입니다.

### 1단계 — 글 올리기

`POST /api/v1/posts` · `content:write`

```
{
  "category": "healing",
  "slug": "healing-today-one-line",
  "title": "오늘의 한 문장",
  "summary": "비우면 본문에서 자동 생성됩니다",
  "body_md": "## 한 줄 요약\n오늘은 이만큼만 하면 됩니다.",
  "tags": ["위로", "아침"]
}
```

응답:

```
{
  "ok": true,
  "created": true,
  "queued": 0,
  "review_required": true,
  "post": {
    "id": 4123,
    "slug": "healing-today-one-line",
    "status": "pending",
    "review_url": "https://darman-pub.azurewebsites.net/review/AbCd...",
    "confirm_webhook": "https://darman-pub.azurewebsites.net/api/review/AbCd.../confirm",
    "rewrite_webhook": "https://darman-pub.azurewebsites.net/api/review/AbCd.../rewrite"
  }
}
```

`slug` 를 직접 정해 두면 나중에 같은 글을 덮어쓰기 쉽습니다. 비우면 제목에서 자동 생성됩니다(한글 제목은 `주제-날짜` 형태).

### 2단계 — 재작성 지시 받기

`GET /api/v1/review/queue?status=rewrite` · `content:read`

```
curl "https://darman-pub.azurewebsites.net/api/v1/review/queue?status=rewrite&body=1" \
  -H "Authorization: Bearer $DARMAN_API_KEY"
```

```
{
  "ok": true,
  "status": "rewrite",
  "total": 2,
  "items": [
    {
      "id": 4123,
      "slug": "healing-today-one-line",
      "title": "오늘의 한 문장",
      "review_note": "도입부를 더 짧게, 예시를 하나 추가",
      "body_md": "## 한 줄 요약\n..."
    }
  ]
}
```

`review_note` 가 사람이 남긴 재작성 사유입니다. `body=1` 을 붙이면 본문까지 같이 받습니다.

### 3단계 — 고쳐서 다시 올리기

**같은 `slug`** 로 `POST /api/v1/posts` 를 호출하면 덮어쓰고 상태가 다시 `pending` 이 됩니다.

```
{
  "slug": "healing-today-one-line",
  "category": "healing",
  "title": "오늘의 한 문장",
  "body_md": "## 한 줄 요약\n짧게 고쳤습니다.\n\n예시를 하나 더했습니다."
}
```

응답의 `created` 가 `false` 면 기존 글을 덮어쓴 것입니다.

### 4단계 — 컨펌

사람이 검토 링크나 메일에서 눌러도 되고, 권한이 있으면 API로도 가능합니다.

```
curl -X POST https://darman-pub.azurewebsites.net/api/v1/posts/4123/confirm \
  -H "Authorization: Bearer $DARMAN_API_KEY"
```

```
{ "ok": true, "result": "published", "queued": 12, "post": { "status": "published" } }
```

`result` 는 `published` · `scheduled` · `restored` · `already_published` 중 하나이고, `queued` 는 적재된 메일 발송 건수입니다.
메일을 보내지 않고 게시만 하려면 `{"notify": false}` 를 함께 보냅니다.

### 에이전트 의사코드

```
매일:
  주제마다:
    글 = 생성한다()
    POST /api/v1/posts { slug, category, title, body_md }

주기적으로:
  큐 = GET /api/v1/review/queue?status=rewrite&body=1
  큐의 각 글에 대해:
    새본문 = 다시쓴다(글.body_md, 글.review_note)
    POST /api/v1/posts { slug: 글.slug, category: 글.category, title: 글.title, body_md: 새본문 }
```

## 5. 이미지 넣기

글에 사진을 쓰려면 먼저 이미지를 올리고, 돌려받은 `url` 을 본문이나 대표 이미지에 씁니다.

### 올리기 — 두 가지 방법

**바이트를 그대로** (가장 간단합니다)

```
curl -X POST "https://darman-pub.azurewebsites.net/api/v1/images?alt=%EA%B0%80%EC%9D%84%20%EB%93%A4%ED%8C%90" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```

**JSON 으로 base64**

```
POST https://darman-pub.azurewebsites.net/api/v1/images
Authorization: Bearer <키>
Content-Type: application/json

{ "data_base64": "/9j/4AAQSkZJRg...", "alt": "가을 들판" }
```

`data:image/jpeg;base64,` 접두사가 붙어 있어도 알아서 떼어냅니다.

### 응답

```
{
  "ok": true,
  "image": {
    "id": "9f2c…",
    "url": "/uploads/9f2c….jpg",
    "absolute_url": "https://darman-pub.azurewebsites.net/uploads/9f2c….jpg",
    "mime": "image/jpeg", "bytes": 214880, "width": 1600, "height": 1067,
    "alt": "가을 들판", "deduped": false
  }
}
```

`deduped: true` 는 똑같은 사진이 이미 있어서 그것을 그대로 돌려줬다는 뜻입니다. 같은 파일을 여러 번 올려도 저장 공간이 늘지 않습니다.

### 글에 쓰기

본문 안에 넣을 때는 마크다운 이미지 문법에 `url` 을 그대로 씁니다.

```
{
  "category": "healing-art",
  "title": "가을 들판을 그린 사람들",
  "cover_image": "/uploads/9f2c….jpg",
  "cover_alt": "가을 들판",
  "body_md": "## 첫 장면\n\n![가을 들판](/uploads/9f2c….jpg)\n\n그림은 이렇게 시작합니다…"
}
```

- `cover_image` — 대표 이미지. 목록 카드, 메일, 카카오톡·슬랙 공유 미리보기(`og:image`), RSS 에 쓰입니다. 빈 문자열 `""` 을 보내면 해제됩니다.
- 본문 이미지 — 업로드한 `/uploads/…` 주소이거나 `https://` 로 시작하는 외부 주소만 그림으로 그려집니다. 그 밖의 주소는 무시합니다.

### 제한과 거절 사유

| 항목 | 값 |
|---|---|
| 형식 | JPEG · PNG · GIF · WebP |
| 한 장 용량 | 6MB |
| 가로·세로 | 각 12,000px, 합쳐서 4천만 화소 |
| 받지 않는 것 | SVG (스크립트를 담을 수 있어 제외), 그 밖의 모든 형식 |

형식은 **파일 앞부분 바이트로 판별**합니다. 확장자나 `Content-Type` 을 바꿔 보내도 통하지 않습니다.

| 코드 | 뜻 |
|---|---|
| `413` | 용량·해상도 초과 |
| `415` | 허용하지 않는 형식 (SVG 포함) |
| `409` | 삭제하려는 이미지를 쓰는 글이 있음 (`?force=1` 로 강행) |

### 목록·삭제

```
GET    https://darman-pub.azurewebsites.net/api/v1/images?page=1      content:read
GET    https://darman-pub.azurewebsites.net/api/v1/images/{id}        content:read
DELETE https://darman-pub.azurewebsites.net/api/v1/images/{id}        admin:write
```

## 6. 사이트 고치기 — 문구 · 메뉴 · 페이지 · 배너

화면에 나가는 문구, 메뉴, 고정 페이지, 배너(광고)를 배포 없이 바꿀 수 있습니다. 관리자 화면에서 하는 일을 그대로 API 로도 할 수 있습니다.

### 화면 문구

```
GET   https://darman-pub.azurewebsites.net/api/v1/site      admin:read
PATCH https://darman-pub.azurewebsites.net/api/v1/site      admin:write
```

`GET` 은 현재 값과 함께 각 항목의 기본값·최대 길이·형식을 알려줍니다.

```
{
  "ok": true,
  "values": { "hero_title": "매일 한 편,\n마음과 일을 위한 짧은 읽을거리", "footer_legal": "…" },
  "fields": { "hero_title": { "default": "…", "maxLength": 160, "kind": "lines" } }
}
```

| `kind` | 뜻 |
|---|---|
| `plain` | 글자 그대로 |
| `rich` | `**굵게**` 와 `[링크](/주소)` 를 쓸 수 있음 |
| `lines` | 줄바꿈이 화면에서도 줄바꿈으로 |
| `email` | 이메일 형식 검사 |
| `adsense` | `ca-pub-` 로 시작하는 게시자 ID |

`PATCH` 는 보낸 키만 바꿉니다. **빈 문자열을 보내면 기본 문구로 되돌아갑니다.**

```
PATCH https://darman-pub.azurewebsites.net/api/v1/site
{ "hero_title": "매일 한 편,\n오늘의 그림과 이야기", "footer_legal": "정보 제공 목적이며 투자 권유가 아닙니다." }
```

어떤 값을 보내도 HTML 로 해석되지 않습니다. 태그를 넣으면 글자 그대로 보입니다.

### 메뉴

```
GET    https://darman-pub.azurewebsites.net/api/v1/menu        admin:read
POST   https://darman-pub.azurewebsites.net/api/v1/menu        admin:write
PATCH  https://darman-pub.azurewebsites.net/api/v1/menu/{id}   admin:write
DELETE https://darman-pub.azurewebsites.net/api/v1/menu/{id}   admin:write
```

`area` 는 `header`(상단), `footer_browse`·`footer_subscribe`·`footer_about`(푸터 세 칸) 중 하나입니다. 주소는 `/` 로 시작하는 사이트 내 경로이거나 `http(s)`·`mailto` 만 받습니다.

### 고정 페이지

```
GET    https://darman-pub.azurewebsites.net/api/v1/pages        content:read
POST   https://darman-pub.azurewebsites.net/api/v1/pages        admin:write
PATCH  https://darman-pub.azurewebsites.net/api/v1/pages/{id}   admin:write
DELETE https://darman-pub.azurewebsites.net/api/v1/pages/{id}   admin:write
```

`slug` 를 `about` · `privacy` · `terms` 로 만들면 **기본 화면을 대신합니다.** 지우면 기본 화면이 다시 나갑니다. 그 밖의 주소는 `/page/{slug}` 로 열리고, `in_menu: true` 면 푸터에 링크가 붙습니다. 본문은 마크다운입니다.

### 배너 · 광고

```
GET    https://darman-pub.azurewebsites.net/api/v1/banners        admin:read   (노출·클릭 수 포함)
POST   https://darman-pub.azurewebsites.net/api/v1/banners        admin:write
PATCH  https://darman-pub.azurewebsites.net/api/v1/banners/{id}   admin:write
DELETE https://darman-pub.azurewebsites.net/api/v1/banners/{id}   admin:write
```

**노출 위치(`slot`)**

| 값 | 자리 |
|---|---|
| `topbar` | 헤더 바로 아래 (공지 띠) |
| `home_hero` | 홈 · 히어로 아래 |
| `home_mid` | 홈 · 주제별 글과 최신 글 사이 |
| `list_top` | 목록·주제 화면 상단 |
| `post_top` | 글 · 본문 시작 전 |
| `post_bottom` | 글 · 본문 끝 |
| `footer_top` | 푸터 바로 위 |

**종류(`kind`)** — `text`(제목·설명·버튼 카드), `image`(업로드한 이미지), `notice`(한 줄 공지, `topbar` 전용), `adsense`

```
POST https://darman-pub.azurewebsites.net/api/v1/banners
{
  "slot": "home_mid", "kind": "text", "name": "가을 이벤트",
  "title": "**가을 미술 기획전** 초대",
  "body": "미술 이야기 구독자에게 드리는 무료 관람권.",
  "cta_label": "신청하기", "url": "https://example.com/event",
  "category": "healing-art", "audience": "member",
  "starts_at": "2026-10-01T00:00:00+09:00", "ends_at": "2026-10-31T23:59:59+09:00",
  "weight": 10
}
```

- `audience` — `all` · `guest`(비회원만) · `member`(회원만)
- `category` — 그 주제의 글·목록 화면에서만 노출
- `starts_at`·`ends_at` — 비우면 무기한. 같은 자리에 여럿이면 `weight` 가 큰 쪽이 먼저
- 링크는 `/b/{id}` 로 감싸 클릭이 집계되고, 바깥 주소에는 `rel="sponsored nofollow noopener"` 가 붙습니다
- 이미지는 `POST /api/v1/images` 로 올린 `/uploads/…` 만 씁니다

### 애드센스

`/api/v1/site` 의 `adsense_client` 에 게시자 ID(`ca-pub-…`)를 넣으면 그때부터:

- 광고 스크립트가 페이지에 실리고
- 보안 정책(CSP)에 구글 광고 도메인이 열리며
- `https://darman-pub.azurewebsites.net/ads.txt` 가 자동으로 만들어집니다

**비워두면 이 중 아무것도 일어나지 않습니다.** 광고를 켜도 인라인 스크립트는 쓰지 않으므로 `script-src` 에 `unsafe-inline` 이 열리지는 않습니다.

광고 단위는 `kind: "adsense"` 배너로 만들고, 애드센스에서 발급한 광고 단위의 `data-ad-slot` 값을 `ad_slot` 에 넣습니다.

## 7. 언어별 사이트

한 서버에서 언어마다 독립된 사이트가 섭니다. 주소는 언어별 경로로 나뉩니다.

| 언어 | 주소 | 시간대 |
|---|---|---|
| 한국어 (기본) | `https://darman-pub.azurewebsites.net/` | Asia/Seoul |
| 일본어 | `https://darman-pub.azurewebsites.net/ja/` | Asia/Tokyo |
| 영어 | `https://darman-pub.azurewebsites.net/en/` | America/New_York |
| 독일어 | `https://darman-pub.azurewebsites.net/de/` | Europe/Berlin |

실제로 열려 있는 언어는 `GET /api/v1/categories` 응답의 `locales` 로 확인할 수 있습니다.

### 글의 언어는 주제가 정합니다

글을 올릴 때 언어를 따로 지정하지 않습니다. **주제(category)가 어느 언어에 속하는지가 곧 글의 언어**입니다.

```
POST https://darman-pub.azurewebsites.net/api/v1/posts
{ "category": "zen", "title": "古い寺の朝", "body_md": "…" }
```

`zen` 이 일본어 주제라면 이 글은 일본어 사이트에 올라가고, 주소는 `https://darman-pub.azurewebsites.net/ja/p/…` 가 됩니다. 응답의 `locale` 과 `url` 로 확인하세요.

### 목록을 언어로 거르기

```
GET https://darman-pub.azurewebsites.net/api/v1/categories?locale=ja
GET https://darman-pub.azurewebsites.net/api/v1/posts?locale=ja&status=pending
GET https://darman-pub.azurewebsites.net/api/v1/review/queue?locale=ja
```

`locale` 을 빼면 **모든 언어가 섞여** 나옵니다. 에이전트가 한 언어만 맡는다면 항상 붙이는 편이 안전합니다.

### 문구·메뉴·페이지·배너

사이트 구성도 언어별입니다.

```
GET   https://darman-pub.azurewebsites.net/api/v1/site?locale=ja
PATCH https://darman-pub.azurewebsites.net/api/v1/site?locale=ja      { "hero_title": "毎日一篇、…" }
GET   https://darman-pub.azurewebsites.net/api/v1/menu?locale=ja
POST  https://darman-pub.azurewebsites.net/api/v1/pages               { "locale": "ja", "slug": "about", … }
POST  https://darman-pub.azurewebsites.net/api/v1/banners             { "locale": "ja", "slot": "home_mid", … }
```

배너의 `locale` 에 `"*"` 를 주면 모든 언어에 노출됩니다(기본값).

### 메일

구독자에게 나가는 메일은 **그 회원의 언어**로 만들어집니다. 제목·본문·버튼·수신거부 안내와 본문 링크(`/ja/p/…`)까지 그 언어를 따릅니다. 발송 시각도 회원이 사는 시간대를 기준으로 판단합니다.

## 8. 사람이 검토하는 방법

### 검토 링크

글마다 발급되는 주소로, **로그인 없이** 열립니다. 메일·슬랙·사내 도구에 그대로 붙이면 됩니다.

```
https://darman-pub.azurewebsites.net/review/{token}
```

내용을 읽고 **컨펌하고 게시** 또는 **재작성 요청**을 누르면 끝입니다. 토큰이 곧 열쇠이므로 외부에 공유하지 마세요.

### 검토 요청 메일

대기 중인 글을 묶어 관리자에게 보냅니다. 메일 안의 버튼을 누르면 해당 동작이 선택된 검토 화면이 열립니다.

```
curl -X POST https://darman-pub.azurewebsites.net/api/v1/review/notify \
  -H "Authorization: Bearer $DARMAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"limit":10}'
```

특정 주소로만 보내려면 `{"to":"editor@example.com"}` 을 함께 보냅니다.
설정에서 **검토 대기 글이 생기면 관리자에게 메일**을 켜 두면 글이 올라올 때 자동으로 나갑니다.

> 메일의 버튼은 클릭 즉시 게시하지 않고 확인 화면을 한 번 거칩니다. 메일 보안 스캐너가 링크를 자동으로 열어 의도치 않게 게시되는 사고를 막기 위한 설계입니다.

## 9. 검토 웹훅 (인증 불필요)

토큰 자체가 인증 수단이라 외부 시스템에서 바로 호출할 수 있습니다.

| 메서드 | 주소 | 하는 일 |
|---|---|---|
| `POST` | `/api/review/{token}/confirm` | 컨펌 → 게시 |
| `POST` | `/api/review/{token}/rewrite` | 재작성 지시 (`{"note":"사유"}`) |
| `GET` | `/api/review/{token}` | 상태 조회 |

```
curl -X POST https://darman-pub.azurewebsites.net/api/review/AbCd.../rewrite \
  -H "Content-Type: application/json" \
  -d '{"note":"표의 숫자 출처를 밝혀 주세요","by":"편집자 김"}'
```

`by` 를 넣으면 누가 처리했는지 감사기록에 남습니다.

## 10. 발송 동작

구독자는 주제마다 **즉시 수신** 또는 **하루 한 번 모아 보기(다이제스트)** 를 고를 수 있습니다.

- 컨펌으로 게시되는 순간, 그 주제를 **즉시 수신**으로 둔 구독자에게 발송 큐가 적재됩니다.
- 다이제스트 구독자는 각자 정한 시각에 그날 글을 묶어 받습니다.
- 예약(`publish_at`)된 글은 그 시각에 게시되며 같은 규칙으로 발송됩니다.
- 발송 작업을 지금 돌리려면 `POST /api/v1/newsletter/tick` (`admin:write`).

## 11. 엔드포인트 요약

| 메서드 | 경로 | 권한 | 설명 |
|---|---|---|---|
| `GET` | `/api/v1/categories` | content:read | 주제 목록 (다음 연재 번호 포함) |
| `GET` | `/api/v1/posts` | content:read | 글 목록 (`status`·`category`·`q`·`page`·`limit`) |
| `POST` | `/api/v1/posts` | content:write | 글 올리기 (같은 `slug` 면 덮어쓰기) |
| `GET` | `/api/v1/posts/{id}` | content:read | 글 상세 (본문 포함) |
| `PATCH` | `/api/v1/posts/{id}` | content:write | 글 수정 |
| `DELETE` | `/api/v1/posts/{id}` | admin:write | 글 삭제 |
| `POST` | `/api/v1/posts/preview` | content:read | 마크다운 → HTML 미리보기 |
| `POST` | `/api/v1/images` | content:write | 이미지 올리기 (바이트 또는 base64) |
| `GET` | `/api/v1/images` | content:read | 올린 이미지 목록 |
| `GET` | `/api/v1/images/{id}` | content:read | 이미지 한 장 |
| `DELETE` | `/api/v1/images/{id}` | admin:write | 이미지 삭제 (`?force=1`) |
| `GET` | `/api/v1/site` | admin:read | 화면 문구 조회 |
| `PATCH` | `/api/v1/site` | admin:write | 화면 문구 수정 |
| `GET` | `/api/v1/menu` | admin:read | 메뉴 목록 |
| `POST` | `/api/v1/menu` | admin:write | 메뉴 추가 |
| `PATCH` | `/api/v1/menu/{id}` | admin:write | 메뉴 수정 |
| `DELETE` | `/api/v1/menu/{id}` | admin:write | 메뉴 삭제 |
| `GET` | `/api/v1/pages` | content:read | 고정 페이지 목록 |
| `POST` | `/api/v1/pages` | admin:write | 고정 페이지 만들기 |
| `PATCH` | `/api/v1/pages/{id}` | admin:write | 고정 페이지 수정 |
| `DELETE` | `/api/v1/pages/{id}` | admin:write | 고정 페이지 삭제 |
| `GET` | `/api/v1/banners` | admin:read | 배너 목록 (노출·클릭) |
| `POST` | `/api/v1/banners` | admin:write | 배너 만들기 |
| `PATCH` | `/api/v1/banners/{id}` | admin:write | 배너 수정 |
| `DELETE` | `/api/v1/banners/{id}` | admin:write | 배너 삭제 |
| `GET` | `/api/v1/review/queue` | content:read | 검토 대기·재작성 목록 |
| `POST` | `/api/v1/posts/{id}/confirm` | review:write | 컨펌 → 게시 |
| `POST` | `/api/v1/posts/{id}/rewrite` | review:write | 재작성 지시 |
| `POST` | `/api/v1/posts/{id}/publish` | review:write | 즉시 게시 |
| `POST` | `/api/v1/posts/{id}/resend` | admin:write | 재발송 큐 적재 |
| `POST` | `/api/v1/review/notify` | review:write | 검토 요청 메일 발송 |
| `GET` | `/api/v1/stats` | admin:read | 운영 통계 |
| `GET` | `/api/v1/deliveries` | admin:read | 발송 내역 (수신자 마스킹) |
| `POST` | `/api/v1/newsletter/tick` | admin:write | 발송 작업 실행 |
| `GET` | `/api/v1/settings` | admin:read | 설정 조회 |
| `PATCH` | `/api/v1/settings` | admin:write | 설정 변경 |

## 12. 입력 제한과 오류

### 제한

| 항목 | 제한 |
|---|---|
| 제목 | 120자 |
| 요약 | 300자 (비우면 본문에서 자동 생성) |
| 본문 | 60,000자 |
| 태그 | 10개, 각 20자 |
| 슬러그 | 영문 소문자·숫자·하이픈 |
| 요청 본문 | 256KB (이미지 업로드는 6MB, base64 JSON 은 9MB) |
| 이미지 | 한 장 6MB · JPEG/PNG/GIF/WebP · 12,000px · 4천만 화소 |
| 호출 한도 | `/api/v1/*` 분당 600회, 검토 웹훅 분당 60회 |

### 오류 형식

```
{ "error": "이 작업에는 review:write 권한이 필요합니다. (보유: content:read, content:write)" }
```

| 코드 | 뜻 |
|---|---|
| `400` | 입력이 올바르지 않음 (주제·상태·형식) |
| `401` | 키 없음·잘못됨·폐기됨 |
| `403` | 권한(scope) 부족 |
| `404` | 대상 없음 (글·토큰) |
| `429` | 호출 한도 초과 — `Retry-After` 초 뒤 재시도 |
| `500` | 서버 오류 |

## 13. 자주 묻는 것

**같은 글을 여러 번 올리면 중복되나요?**
`slug` 가 같으면 덮어씁니다. 다르면 새 글이 됩니다. 재작성 결과는 반드시 같은 `slug` 로 올리세요.

**컨펌 없이 바로 게시하고 싶습니다.**
설정에서 `review_required` 를 `0` 으로 두면 예전처럼 올리는 즉시 게시됩니다. `PATCH /api/v1/settings` 로도 바꿀 수 있습니다.

**이미 게시된 글을 고치면 메일이 다시 나가나요?**
아니요. 이미 게시된 글의 수정은 재발송하지 않습니다. 다시 보내려면 `POST /api/v1/posts/{id}/resend` 를 쓰세요.

**연재 번호는 어떻게 매기나요?**
`series_no` 에 숫자를 직접 넣거나 `"auto"` 로 보내면 해당 주제의 다음 번호가 붙습니다. 현재 다음 번호는 `GET /api/v1/categories` 의 `next_series_no` 로 확인합니다.

**키를 잃어버렸습니다.**
다시 볼 수 없습니다. 관리자 화면에서 기존 키를 폐기하고 새로 발급하세요.

**예약 발행은 어떻게 하나요?**
글을 올릴 때 `publish_at` 에 미래 시각(ISO 8601)을 넣고 컨펌하면 `scheduled` 가 되어 그 시각에 게시·발송됩니다.
