Appearance
实战:博客系统 API 设计
本章导读:把前 5 篇的知识收敛成一份可评审的 API 设计文档。目标系统是一个中型博客平台:文章、评论、标签、用户、认证、搜索与导出。按"需求 → 资源建模 → 端点清单 → 契约细节 → 治理策略"五步走,这也是团队落地的真实工作流。
1. 需求清单(设计输入)
| # | 需求 | 涉及规范章节 |
|---|---|---|
| R1 | 作者可创建/编辑/发布/撤回/删除文章,编辑不能覆盖别人的修改 | 方法语义、幂等、条件请求 |
| R2 | 读者可对文章发表评论(带分页),作者可删除旗下文章评论 | 资源层次 |
| R3 | 文章可打标签;按标签/作者/状态/时间筛选,按时间/热度排序 | URI 规范、分页过滤排序 |
| R4 | 注册/登录/登出,第三方"用 GitHub 账号登录" | 认证与授权 |
| R5 | 全文搜索 | URI 规范、分页 |
| R6 | 导出全部文章为 CSV(万级数据,分钟级耗时) | 异步作业 |
| R7 | 文章封面图上传 | 媒体类型、作业 |
| R8 | 对外开放 API,需限流与版本治理 | 限流、版本 |
2. 资源建模
识别资源(四问法):
articles(集合) /v1/articles
└ article(单资源) /v1/articles/{articleId}
├ comments(子集合) /v1/articles/{articleId}/comments ← 从属建模(权限沿文章继承)
│ └ comment /v1/comments/{commentId} ← 单资源扁平化(后台治理需要)
├ draft(单例子资源) /v1/articles/{articleId}/draft ← 独立权限+独立编辑生命周期 → 拆
├ thumbnail /v1/articles/{articleId}/thumbnail ← 独立高频读 → 拆
└ publish(状态迁移) PUT /v1/articles/{id}/status ← "发布"名词化为状态资源
tags(集合) /v1/tags
users / sessions /v1/users, /v1/sessions ← 登录态资源化
searches(一次搜索=资源) /v1/searches ← 复杂查询 noun 化
exports / jobs(作业) /v1/jobs/{jobId} ← 异步执行统一出口裁决记录(评审要写的"为什么"):
- 评论用从属集合 + 扁平单资源混合:挂在文章下便于权限与分页,后台按 ID 直接治理;
- "发布"不设计
POST /articles/{id}/publish:发布=状态迁移,可逆(撤回),用PUT /status幂等且天然支持"撤回"(写回draft); - 搜索独立资源:查询串可超长、结果可翻页复用、便于按"一次搜索"计量与缓存。
3. 端点清单(API Surface)
3.1 认证(R4)
POST /v1/sessions 登录 → 201 + Location: /v1/sessions/cur,返回 token 对
DELETE /v1/sessions/cur 登出 → 204
POST /v1/sessions/refresh 用 refreshToken 换新 access → 200
GET /v1/oauth/authorize → 302 到 GitHub(OAuth 授权码+PKCE)
GET /v1/sessions 我的会话列表(设备管理)
DELETE /v1/sessions/{id} 吊销指定会话 → 2043.2 文章(R1)
GET /v1/articles 列表(分页/过滤/排序)→ 200
POST /v1/articles 创建草稿 → 201 + Location [Idempotency-Key 必需]
GET /v1/articles/{id} 详情 → 200 | 404 | 410
PUT /v1/articles/{id} 替换可写属性 → 200 + 新 ETag [If-Match 必需 → 412]
DELETE /v1/articles/{id} 删除(软删,保留 30 天)→ 204;此后 GET → 410
GET /v1/articles/{id}/draft 草稿表示 → 200 | 404
PUT /v1/articles/{id}/draft 覆盖草稿 → 200 [If-Match 必需]
PUT /v1/articles/{id}/status 发布/撤回:{"status":"published"} → 200 | 409(非法迁移)
POST /v1/articles/{id}/thumbnail 上传封面 multipart → 201 (R7)
GET /v1/articles/{id}/thumbnail → 200 image/webp | 301 对象存储签名 URL3.3 评论与标签(R2/R3)
GET /v1/articles/{id}/comments 分页(游标)→ 200
POST /v1/articles/{id}/comments 发表 → 201 [Idempotency-Key]
PATCH /v1/comments/{cid} 本人编辑 → 200 | 403
DELETE /v1/comments/{cid} 作者删自己评论 / 文章作者删旗下评论 → 204 | 403
GET /v1/tags?prefix= 标签词表 → 200
GET /v1/articles?tag=rest 按标签筛文章(标签是过滤参数不是路径)3.4 搜索与导出(R5/R6)
POST /v1/searches {"query":"...","filters":{…}} → 201 + Location: /v1/searches/{sid}
GET /v1/searches/{sid}?cursor= 翻页取结果 → 200 ← 结果缓存 60s
POST /v1/exports {"type":"articles.csv"} → 202 + Location: /v1/jobs/{jid}
GET /v1/jobs/{jid} 进度 → 200(含 Link: rel="result" 指向下载资源)
GET /v1/files/{fid} 下载产物 → 200 text/csv(签名 URL 301)4. 契约细节(选摘)
4.1 文章列表
http
GET /v1/articles?status=published&tag=rest&sort=-viewCount,createdAt&page=2&pageSize=20 HTTP/1.1
Accept: application/json
Authorization: Bearer …
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=30, stale-while-revalidate=60
Vary: Accept
{
"items": [
{
"id": "42",
"title": "RESTful API 设计规范",
"excerpt": "……",
"status": "published",
"tagIds": ["t1", "t9"],
"authorId": "7",
"viewCount": 1024,
"createdAt": "2026-09-18T08:30:00Z",
"updatedAt": "2026-09-18T10:12:31Z",
"links": { "self": "/v1/articles/42", "author": "/v1/users/7", "comments": "/v1/articles/42/comments" }
}
],
"page": 2, "pageSize": 20, "total": 214,
"links": { "next": "/v1/articles?page=3&…", "prev": "/v1/articles?page=1&…" }
}约定:列表不含 content(正文走 ?fields=content 或详情端点——防 over-fetching)。
4.2 发布(状态机 + 冲突)
状态机:draft → inReview → published → archived;published ⇄ draft(撤回)
PUT /v1/articles/42/status
{ "status": "published" }
200 → { "id":"42","status":"published","publishedAt":"…","version":8 }
409 → article-in-review:审核中不可直接发布(业务规则冲突,problem+json)
412 → 状态已变化(若启用 If-Match)4.3 错误目录(节选)
| type(错误码) | HTTP | 触发端点 |
|---|---|---|
validation-failed | 422 | 全部写端点 |
article-title-too-long | 422 | POST/PUT articles |
invalid-status-transition | 409 | PUT status |
version-conflict | 412 | PUT 带旧 ETag |
invalid-cursor | 400 | 列表翻页 |
permission-denied | 403 | 评论/草稿越权 |
rate-limit-exceeded | 429 | 全局(Retry-After) |
5. 治理策略(写进文档的"宪法")
| 主题 | 决策 |
|---|---|
| 命名 | camelCase;集合复数;URI 全小写 kebab |
| 时间 | RFC 3339 UTC |
| ID | 文章/评论/用户对外 UUIDv7(字符串);禁自增 |
| 版本 | /v1;新增字段不升版本;未知枚举容忍 |
| 分页默认 | page=1, pageSize=20, max=100;评论用游标 |
| 认证 | Bearer JWT(15min)+ Refresh(30d 旋转);/articles 的 GET 匿名可访公开文章 |
| 幂等 | 全部 POST 需 Idempotency-Key(24h 重放窗口) |
| 并发 | 文章/草稿写强制 If-Match;其余后写覆盖 |
| 缓存 | 写响应/错误 no-store;公开 GET 短公共缓存 + 强 ETag 全资源启用 |
| 弃用 | Deprecation/Sunset 头 + 12 个月窗口 |
| 限流 | 默认 1000 req/h/用户,写端点 200 req/h;Retry-After + 配额头部 |
6. 评审自查(走完这 8 问才算设计完成)
- 遮住 body,方法+URI 能否读懂 90% 意图?
- 每个 POST 是否想过"能否名词化/幂等化"?
- 集合接口是否全部强制分页?
- 写接口是否定义并发策略(If-Match / last-write / 409)?
- 错误是否落到 problem+json + type 目录,且无 5xx 细节泄露?
- 私有数据缓存指令是否全部
private/no-store? - 枚举、字段、错误码的演进规则是否成文?
- 是否给出 OpenAPI 文件(下一章生成)?
7. 下一步
设计稿完成,用 Spring Boot 把它变成可运行代码 → 实战:Spring Boot 实现