主题
界面
主题
此前讲的是"怎么把代码写好",本节讲"怎么把能力暴露给别人用"——也就是接口设计。
接口是前后端之间、服务与服务之间的契约。它一旦发布就难以更改(调用方依赖它),所以设计阶段的准确性比编码阶段更重要。
完成本节后,你应该能够:
接口设计错了,代价很高:
| 错误 | 代价 |
|---|---|
URL 命名混乱(/getOrderList vs /orders) | 每个调用方都要查文档,团队内无法形成直觉 |
| 状态码全用 200 | 调用方必须解析 body 才知道成败;监控与网关无法基于状态码做告警与重试 |
| 接口没有幂等设计 | 网络重试导致重复下单 |
分页参数各自为政(page / pageNum / pageIndex) | 每个接口都要重新看文档 |
而这些问题在项目初期几乎无成本——只是没人告诉过你"应该怎么设计"。
REST 把一切都看作资源,用URL 定位资源、用方法表达操作、用表示(JSON)传递状态。
POST /orders 创建订单
GET /orders/123 查询订单 123
GET /orders?status=PAID&page=1 查询订单列表(带筛选与分页)
PUT /orders/123 整体替换订单 123
PATCH /orders/123 局部修改订单 123
DELETE /orders/123 删除订单 123三个要点:
| 要点 | 说明 |
|---|---|
| URL 是名词(资源) | /orders 而不是 /getOrders |
| 方法是动词(操作) | GET / POST / PUT / DELETE |
| 状态码表达结果 | 201 Created 而不是 200 OK + {"success": true} |
| 规范 | 好 | 差 |
|---|---|---|
| 用名词,不用动词 | GET /orders | GET /getOrderList |
| 用复数表示集合 | /orders/123 | /order/123(单复数混用) |
| 层级用嵌套表示从属关系 | GET /orders/123/items | GET /orderItems?orderId=123 |
| 筛选条件用查询参数 | /orders?status=PAID | /orders/status/PAID |
| 小写 + 连字符 | /order-items | /orderItems、/order_items |
| 不要带文件扩展名 | /orders/123 | /orders/123.json |
关于"动作类操作"怎么表达(REST 的经典争议点):
| 需求 | 不推荐 | 推荐 |
|---|---|---|
| 取消订单 | POST /cancelOrder | POST /orders/123/cancel(子资源 + 动作,务实做法) |
| 支付订单 | POST /payOrder | POST /orders/123/payments(支付作为子资源) |
| 用户登录 | POST /login | POST /sessions(会话作为资源) |
说明:纯粹的 REST 不接受"动作 URL",但工程实践中"子资源 + 动作"是被广泛接受的折中——因为它清晰、无歧义。
判断标准:如果一个操作很难用资源表达,说明它可能是"业务动作"——用一个明确的动词路径比硬套 REST 更不容易出错。
| 方法 | 语义 | 安全(不改变数据) | 幂等(执行多次 = 一次) | 典型用途 |
|---|---|---|---|---|
GET | 读取 | ✅ | ✅ | 查询 |
POST | 创建 | ❌ | ❌ | 新建资源 |
PUT | 整体替换 | ❌ | ✅ | 用已知 id 覆盖 |
PATCH | 局部修改 | ❌ | ⚠️ 取决于实现 | 改一个字段 |
DELETE | 删除 | ❌ | ✅ | 删除 |
三个必须记住的推论:
① GET 绝不能有副作用
✗ GET /orders/123/cancel 用 GET 做取消操作为什么危险:浏览器预取、爬虫、监控探活都会"顺手"发 GET——一个链接被访问就会取消订单。
② PUT 必须幂等,POST 不必
PUT /orders/123 { "status": "PAID" } ← 执行 10 次,结果一样(幂等)
POST /orders { ... } ← 执行 10 次,创建 10 个订单这就是"为什么创建用 POST、更新用 PUT"。
③ POST 的非幂等性必须靠"幂等键"补救
场景:用户点击"支付",网络超时重试——会不会扣两次钱?
幂等键方案(业界标准做法):
POST /payments
Idempotency-Key: 7f3a9c2e-...
Content-Type: application/json
{ "orderNo": "ORD-001", "amountInCents": 19900 }| 要点 | 说明 |
|---|---|
| 客户端生成唯一键 | 通常是一次"业务操作"的 UUID(重试时复用同一个键) |
| 服务端先查后写(同一个事务/唯一约束) | 键已存在则直接返回上次的结果 |
| 键要存下来并设过期时间 | 一般 24 小时 |
| 实现基础 | 数据库的唯一约束(《表设计与约束》) |
顺带:这也是"为什么支付接口都有 out_trade_no(商户订单号)"——它就是幂等键。
先看一个反模式:
HTTP/1.1 200 OK
{ "code": 404, "message": "订单不存在", "data": null }"HTTP 200 + body 里的 code" 是很多国内项目的做法,但它有四个问题:
| 问题 | 说明 |
|---|---|
| 网关看不到真实状态 | Nginx、API 网关、负载均衡都按 HTTP 状态码做统计与策略 |
| 监控无法告警 | 404 率、5xx 率是标准监控指标——全 200 时它们全是 0 |
| 客户端库误判 | HTTP 客户端(如 RestTemplate)默认对 4xx/5xx 抛异常——全 200 时它以为都成功了 |
| 重试策略失效 | "5xx 才重试"是通用策略;全 200 时重试逻辑无从下手 |
正确用法:
| 状态码 | 含义 | 什么时候用 |
|---|---|---|
200 OK | 成功 | GET / PUT / PATCH 成功 |
201 Created | 已创建 | POST 成功,并在 Location 头里给出新资源的 URL |
204 No Content | 成功但无返回体 | DELETE 成功 |
400 Bad Request | 请求格式错误 | JSON 语法错误、缺少必填字段 |
401 Unauthorized | 未认证 | 没登录、token 无效 |
403 Forbidden | 已认证但无权限 | 登录了但没这个权限 |
404 Not Found | 资源不存在 | 查不到、或无权知道它是否存在 |
409 Conflict | 冲突 | 唯一约束冲突(用户名已存在) |
422 Unprocessable Entity | 参数校验失败 | 格式对但值不合法(余额不足) |
429 Too Many Requests | 限流 | 请求过于频繁 |
500 Internal Server Error | 服务端错误 | 未预期异常 |
503 Service Unavailable | 服务不可用 | 依赖挂掉、正在发布 |
401 与 403 的区别(高频混淆):
401 = 你是谁?我不知道 → 客户端应该去登录(或刷新 token)
403 = 我知道你是谁,但你不能 → 客户端不该重试,重试也没用400 与 422 的取舍:
| 做法 | 说明 |
|---|---|
只用 400 | 简单,但"格式错"与"值不合法"混在一起 |
400 + 422 分开 | 更精确:400 是"请求本身有问题",422 是"参数校验没过" |
实践建议:团队内统一即可,但不要全用 200。
统一响应格式(成功):
{
"data": {
"orderNo": "ORD-001",
"amountInCents": 19900,
"status": "PAID"
}
}列表 + 分页:
{
"data": [
{ "orderNo": "ORD-001" },
{ "orderNo": "ORD-002" }
],
"page": {
"page": 1,
"size": 20,
"total": 137,
"totalPages": 7
}
}错误响应(要有机器可读的错误码):
{
"code": "ORDER_NOT_FOUND",
"message": "订单不存在",
"traceId": "7f3a9c2e-...",
"timestamp": "2026-09-21T10:00:00Z",
"details": [
{ "field": "amountInCents", "message": "金额必须大于 0" }
]
}四个要素的作用:
| 字段 | 作用 |
|---|---|
code | 机器可读——客户端根据它做分支(不要用 message 做判断) |
message | 给人看——可以直接展示给用户(也可以让客户端自己映射文案) |
traceId | 排查用——用户报错时把它给客服,能直接定位日志 |
details | 参数校验的逐字段错误(表单场景必需) |
一个关键设计原则:
错误码是契约,消息是文案。
- 错误码:一旦发布就是契约,不能随便改(客户端依赖它);
- 消息:可以随时改(甚至可以让客户端自己维护多语言文案)。
不要做的事:
{ "code": 1, "message": "系统繁忙,请稍后重试" } // ✗ 所有错误都是 code=1
{ "error": "Exception in thread main java.lang.NullPointerException ..." } // ✗ 泄露堆栈第二行尤其危险:泄露堆栈 = 给攻击者提供内部结构信息。
统一约定(团队内一致比"哪种更好"更重要):
| 需求 | 参数 | 示例 |
|---|---|---|
| 分页 | page + size(页码从 1 开始) | /orders?page=2&size=20 |
| 排序 | sort=字段,方向 | /orders?sort=createdAt,desc |
| 多字段排序 | 重复 sort | /orders?sort=status,asc&sort=createdAt,desc |
| 过滤 | 直接用字段名 | /orders?status=PAID&userId=1 |
| 范围 | 字段_gte / 字段_lte | /orders?createdAt_gte=2026-01-01 |
| 全文搜索 | q 或 keyword | /orders?keyword=键盘 |
两种分页方式的取舍:
| 方式 | 优点 | 缺点 | 适用 |
|---|---|---|---|
页码分页(page / size) | 能跳页、能显示总页数 | 深分页慢(LIMIT 100000, 20 要扫描前 10 万行);数据变动时会重复/漏 | 后台管理、数据量小 |
游标分页(cursor) | 稳定、高效 | 不能跳页 | 信息流、无限滚动、大数据量 |
顺带一个实践细节:size 必须有上限(如最大 100)——否则 size=1000000 会打垮数据库 ✓
| 需求 | ✗ 不好的设计 | ✓ 好的设计 |
|---|---|---|
| 查订单列表 | GET /getOrderList | GET /orders |
| 查某个订单 | GET /order/query?id=123 | GET /orders/123 |
| 查订单的明细 | GET /getOrderItems?orderId=123 | GET /orders/123/items |
| 创建订单 | POST /order/create | POST /orders |
| 取消订单 | POST /order/cancel?id=123 | POST /orders/123/cancel |
| 按状态筛选 | GET /orders/filter/PAID | GET /orders?status=PAID |
| 分页 | GET /orders?pageNum=2&pageSize=20 | GET /orders?page=2&size=20 |
| 删除 | POST /order/delete?id=123 | DELETE /orders/123 |
注意最后一行:用 POST 做删除是很多老系统的做法(因为老浏览器不支持 DELETE)——现代前端已经没这个问题了。
# 创建订单
POST /orders
Content-Type: application/json
Idempotency-Key: 7f3a9c2e-1b2c-...
{ "productId": 1, "quantity": 2 }
HTTP/1.1 201 Created
Location: /orders/ORD-001
{ "data": { "orderNo": "ORD-001", "status": "CREATED", "amountInCents": 39800 } }# 查询订单(不存在)
GET /orders/ORD-999
HTTP/1.1 404 Not Found
{ "code": "ORDER_NOT_FOUND", "message": "订单不存在", "traceId": "..." }# 参数校验失败
POST /orders
{ "productId": 1, "quantity": 0 }
HTTP/1.1 422 Unprocessable Entity
{
"code": "VALIDATION_FAILED",
"message": "参数校验失败",
"details": [ { "field": "quantity", "message": "数量必须大于 0" } ]
}202 很多团队把所有操作都做成同步,导致"批量导入 10 万条"的接口请求挂 5 分钟。
正确做法:
POST /imports
HTTP/1.1 202 Accepted
Location: /imports/task-123
{ "taskId": "task-123", "status": "PROCESSING" }然后客户端轮询 GET /imports/task-123 看进度 ✓ 202 就是为这种场景设计的。
## URL
- [ ] 用名词(复数)表示资源吗?
- [ ] 层级关系用嵌套表达了吗?(不超过 2 层)
- [ ] 筛选、排序、分页用查询参数了吗?
- [ ] 命名风格统一吗?(小写 + 连字符)
## 方法
- [ ] `GET` 没有副作用吗?
- [ ] 创建用 `POST`、整体替换用 `PUT`、局部修改用 `PATCH`、删除用 `DELETE` 吗?
- [ ] 非幂等的写操作有**幂等键**吗?
## 状态码
- [ ] 创建成功返回 `201` + `Location` 吗?
- [ ] 删除成功返回 `204` 吗?
- [ ] 未认证用 `401`、无权限用 `403` 吗?
- [ ] 参数校验失败与业务规则冲突用了不同的码吗?(`422` / `409`)
- [ ] 有没有"全 200 + body 里带 code"的反模式?
## 响应体
- [ ] 列表接口有统一的分页结构吗?
- [ ] 错误响应有**机器可读的 code** 吗?
- [ ] 有 `traceId` 便于排查吗?
- [ ] 参数校验错误有逐字段的 `details` 吗?
- [ ] **没有泄露堆栈或内部细节**吗?
## 安全与稳定性
- [ ] `size` 有上限吗?
- [ ] 敏感字段(密码、身份证)没有出现在响应里吗?
- [ ] 越权访问有校验吗?(不能只靠前端隐藏按钮)POST /createOrder
GET /getOrderList
POST /updateOrderStatus问题:这是 RPC 风格,不是 REST——它的后果是"每个接口的命名都要重新想一遍",团队无法形成直觉。
修复:POST /orders、GET /orders、PATCH /orders/123。
见 5.4 节。这是最影响运维与监控的一类设计问题。
GET 做写操作 GET /orders/123/cancel危险场景:浏览器预取(Chrome 会预取页面里的链接)、爬虫、监控探活——一个 GET 请求被"顺手"发出,操作就执行了。
message 做客户端判断 // ✗ 客户端根据消息文本判断
if ("订单不存在".equals(response.getMessage())) { ... }问题:消息一改,客户端就崩。
修复:用 code(ORDER_NOT_FOUND)判断,message 只用于展示。
@GetMapping("/users/{id}")
public User get(@PathVariable Long id) {
return userRepository.findById(id).orElseThrow(); // ✗ 直接返回实体
}三个问题:
| 问题 | 说明 |
|---|---|
| 泄露敏感字段 | 密码哈希、手机号、内部备注 |
| 数据库结构被暴露 | 字段名与表结构绑定,改表就是"破坏 API" |
| N+1 或懒加载异常 | 序列化时触发额外查询(JPA 的经典坑) |
修复:用专门的 DTO / VO(《Record》 很适合做这个)✓
GET /orders?size=1000000 # 数据库直接被打垮修复:服务端强制上限(如 size = Math.min(size, 100))。
目标:把一份"混乱的接口设计"改造成规范的。
步骤:
下面是一份典型的老式接口设计,逐条找出问题:
| # | 接口 |
|---|---|
| 1 | GET /getUserList?pageNum=1&pageSize=20 |
| 2 | POST /createUser |
| 3 | POST /deleteUser?id=1 |
| 4 | GET /user/detail?id=1 |
| 5 | GET /user/updateStatus?id=1&status=ACTIVE |
| 6 | POST /user/sendEmail?id=1 |
| 7 | 所有接口返回 200 + {"code":0,"msg":"成功"} |
| 8 | GET /exportUsers(导出 10 万行数据) |
给出改造后的版本(含方法与状态码);
为第 6 条接口设计"幂等"方案(用户可能重复点击"发送邮件");
为第 8 条接口设计异步方案(含状态码、返回体、轮询接口);
写出第 4 条接口的三种错误响应(不存在、无权限、参数错误),含状态码与响应体;
用自己的话总结:"判断一个接口设计好不好"的两个问题。
验收方式:能说出第 1、3、5 条的改造理由。
扩展任务:为你项目里的一个接口写"接口文档"(含 URL、方法、请求体、响应体、状态码、错误码表、幂等说明)——用检查清单逐条自评。
基础题
GET 请求可以带副作用,只要不返回大量数据。(对 / 错)代码阅读题
@GetMapping("/orders/{id}/cancel")
public Result<String> cancel(@PathVariable Long id) {
orderService.cancel(id);
return Result.success("取消成功");
}编程题
POST 还是 PUT?为什么?综合任务
提示 1(方向):第 4 题的关键是"用 GET 做写操作"与"URL 里带动作"。
提示 2(关键点):第 6 题的"点赞"重复提交会怎样,取决于你怎么设计——取决于业务的真实语义(是"点赞数 +1"还是"标记已赞")。
提示 3(接近答案):第 7 题的回调接口必须立刻返回成功(第三方有超时与重试)——异步处理业务、同步保证幂等记录是常见做法。
GET 绝对不能有副作用——这是 HTTP 规范里的"安全方法"语义。原因是浏览器、爬虫、监控都会主动发 GET,副作用会被"意外触发"。顺带记住:GET 还必须是幂等的(多次执行结果一样)——所以 GET /orders?page=1 反复调用没问题。
201 Created(并在 Location 头给出新资源 URL);204 No Content;403 Forbidden。| 理由 | 说明 |
|---|---|
| 网关与监控失效 | Nginx、API 网关、监控系统都基于 HTTP 状态码做统计与告警——全 200 时"5xx 率"永远是 0 |
| HTTP 客户端误判 | RestTemplate、HttpClient 等默认对 4xx/5xx 抛异常——全 200 时它无法区分成功与失败 |
| 重试策略无法实施 | "只重试 5xx"是通用策略;全 200 时无法判断"该不该重试" |
| 缓存与 CDN 行为异常 | 一些缓存策略依赖状态码(如"不缓存 5xx") |
唯一的"好处"是"前端处理简单"——但那只是把复杂度从 HTTP 层挪到了业务层,而且代价由运维与监控承担。
第 4 题:两个问题。
问题一:GET 被用来执行写操作(取消订单)。
危险在哪:GET 是"安全方法",浏览器、爬虫、监控探活都可能主动请求它——一个链接被访问就会取消订单。
问题二:URL 里带动作(/cancel)。
严格 REST 里应该用"方法 + 资源"表达,而不是在 URL 里写动词。
第 5 题:两种可接受的方案:
方案 A(推荐):把"取消"建模成状态变更
PATCH /orders/123
Content-Type: application/json
{ "status": "CANCELLED" }
HTTP/1.1 200 OK优点:纯 REST,且"取消"变成了"状态流转"——天然幂等(重复 PATCH 同一个状态结果一样)。
方案 B:子资源 + 动作(务实)
POST /orders/123/cancellations
HTTP/1.1 201 Created
Location: /orders/123/cancellations/1优点:把"取消"建模为一次"取消记录"——语义清晰,且能记录"谁在什么时候取消的、原因是什么"。
什么时候用 B:当这个动作本身有需要记录的信息时(取消原因、操作人)。
注意方案 A 的一个隐患:PATCH 直接改状态绕过了状态机校验——如果业务上"已发货的订单不能取消",必须在服务端校验(不能只信任客户端传来的 status)。
实践建议:
能用"状态流转"表达的就用它(方案 A);动作本身有业务含义(需要记录、有独立生命周期)时用子资源(方案 B)。
避免
GET /cancel这类写法——它同时违反了"安全方法"与"URL 用名词"两条原则。
| 需求 | 方法 | URL | 成功 | 失败 |
|------|------|-----|------|------|
| 发布文章 | POST | /articles | 201 + Location | 422(校验失败) |
| 文章列表 | GET | /articles?authorId=1&tag=java&sort=publishedAt,desc&page=1&size=20 | 200 | 400 |
| 文章详情 | GET | /articles/{id} | 200 | 404 |
| 修改文章 | PATCH | /articles/{id} | 200 | 404 / 422 |
| 删除文章 | DELETE | /articles/{id} | 204 | 404 / 403 |
| 点赞 | PUT | /articles/{id}/likes/{userId} | 204 | 404 |
| 取消点赞 | DELETE | /articles/{id}/likes/{userId} | 204 | 404 |
| 点赞数 | GET | /articles/{id}/likes | 200 | 404 |列表的分页参数与响应体:
GET /articles?authorId=1&sort=publishedAt,desc&page=1&size=20
HTTP/1.1 200 OK{
"data": [
{ "id": 1, "title": "Java 索引笔记", "authorId": 1, "publishedAt": "2026-09-21T10:00:00Z", "likeCount": 12 }
],
"page": { "page": 1, "size": 20, "total": 137, "totalPages": 7 }
}"点赞"为什么用 PUT 而不是 POST:
| 方法 | 语义 | 重复调用的结果 |
|---|---|---|
POST /articles/1/likes | 创建一条"点赞记录" | 创建 2 条(点赞数变 2)✗ |
PUT /articles/1/likes/{userId} | 把"用户 X 赞文章 Y"这个资源置为存在 | 仍然是"已点赞"(幂等)✓ |
关键洞察:PUT 的语义是"用这个 URL 覆盖资源"——URL 里带了 userId,所以"赞两次"就是"往同一个位置写两次"→ 天然幂等 ✓
这也是"URL 设计影响幂等性"的经典案例:同样的业务需求,URL 设计不同,幂等性就不同:
POST /articles/1/likes ← 服务端自己生成 id → 每次都是新资源 → 非幂等
PUT /articles/1/likes/{userId} ← URL 里已有唯一标识 → 覆盖写 → 幂等重复点赞的行为定义(业务上要明确):
| 设计 | 说明 |
|---|---|
| 幂等(推荐) | 重复点赞不报错、不重复计数——用户无感知 |
报错(409 Conflict) | 提示"你已经点过赞了"——但网络重试时会误报 |
实现层面:用唯一约束保证(UNIQUE (article_id, user_id)),插入冲突时忽略或返回成功 ✓ (《表设计与约束》 的"约束是最后一道防线")。
接口设计:
POST /payments/callbacks/wechat
Content-Type: application/json
X-Signature: <第三方签名>
{
"outTradeNo": "ORD-001",
"transactionId": "4200001234202609210001",
"amountInCents": 19900,
"status": "SUCCESS",
"paidAt": "2026-09-21T10:00:05Z"
}响应(第三方期望的格式):
{ "code": "SUCCESS", "message": "OK" }为什么必须幂等:
| 原因 | 说明 |
|---|---|
| 第三方会重试 | 未收到成功响应(超时、网络抖动)就会重试——可能重试很多次 |
| 可能重复通知 | 有些平台在"状态变更"时也会再通知一次(如从成功变为退款) |
| 重试没有规律 | 不能假设"只重试 3 次"或"间隔固定" |
幂等实现:
-- 用支付平台的流水号作为幂等键(它是全局唯一的)
CREATE TABLE payment_callback (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
transaction_id VARCHAR(64) NOT NULL UNIQUE, -- ← 幂等键
out_trade_no VARCHAR(64) NOT NULL,
amount_in_cents BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);处理流程:
@Transactional
public void handleCallback(CallbackRequest request) {
// 1. 验签(必须!否则任何人都能伪造回调)
if (!signatureVerifier.verify(request)) {
throw new IllegalArgumentException("签名校验失败");
}
// 2. 幂等检查:尝试插入,唯一约束冲突说明已处理过
try {
callbackRepository.insert(request); // 依赖 UNIQUE (transaction_id)
} catch (DuplicateKeyException exception) {
log.info("重复回调,直接返回成功:{}", request.transactionId());
return; // ✓ 直接返回成功
}
// 3. 首次处理:更新订单状态(在同一个事务里)
orderService.markPaid(request.outTradeNo(), request.transactionId());
}同步还是异步处理:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 同步处理(推荐起点) | 在请求里完成"记录回调 + 更新订单" | 简单、可靠(数据库事务保证一致) | 处理慢时可能超时 → 第三方重试(但幂等已保证不会重复扣) |
| 异步处理 | 记录回调后立刻返回,用 MQ 触发后续 | 响应快 | 复杂度上升(消息丢失、顺序、重试) |
实践建议:
先做同步 + 幂等——因为幂等已经解决了"重试"这个最大风险。
只有当"处理逻辑确实很慢"(如需要调用多个下游)时,才考虑"记录回调(同步)→ 发消息(同步)→ 业务处理(异步)"的两段式。
但要注意:异步方案下,"记录回调"与"业务处理"不在同一个事务——必须保证消息不丢(本地消息表 + 定时补偿,或事务性消息)。
最后一个安全提醒:
| 必须做 | 说明 |
|---|---|
| 验签 | 回调 URL 是公开的,不验签等于任何人都能伪造"支付成功" |
| 金额校验 | 校验回调金额与订单金额一致(防止篡改) |
| IP 白名单(可选) | 只接受第三方平台的出口 IP |
| 不在响应里泄露内部信息 | 失败时返回通用错误即可,细节记日志 |
GET 为什么不能有副作用;traceId);401 与 403、400 与 422 的区别;Spring 核心三节到此完成。接下来可以: