API 契约:资源、错误、分页与幂等
接口不是“后端写了一个 URL”。它是调用双方对输入、权限、结果与失败行为的约定。契约清楚,前端才知道什么时候展示成功、什么时候重试,升级时也更容易发现破坏兼容的改动。
1. 从资源而不是表名出发
订单是资源,GET /orders/42 表示读取它的一种表示。API 暴露的是业务能力,未必与数据库一张表一一对应。一个订单响应可以聚合商品快照和配送信息,也不该直接泄露内部密码字段、锁版本或调试列。
GET、HEAD 等安全方法的定义是客户端不请求改变资源状态,服务端记访问日志不违背这个含义。幂等说的是重复相同请求的预期服务端效果,而不是每次响应的字节完全相同。DELETE 第一次成功、第二次返回 404,仍可能符合幂等语义。POST 默认没有这样的保证,下单接口需自己定义重试契约。
2. 错误要能被程序使用
用 HTTP 状态表达大类,用稳定错误类型表达业务含义。400 可表示请求格式问题,401 涉及缺失或无效身份凭据,403 表示拒绝访问,409 适合某些状态冲突。具体选择应在接口文档保持一致,不让所有错误都伪装成 200。
以下是基于 Problem Details 的示意响应;example.com 只是教学域名,不是需要调用的接口。
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://example.com/problems/insufficient-stock",
"title": "库存不足",
"status": 409,
"detail": "请求 3 件,目前最多可购买 1 件。",
"requestId": "req-demo-42"
}
调用方判断 type 或约定的稳定代码,不匹配中文错误句子。响应中的 status 与真实状态码保持一致。堆栈、数据库连接串、完整 SQL 参数留在受控诊断渠道,而不是返回给浏览器。
3. 参数校验和兼容
JSON 可解析只是第一步。接着检查字段类型、长度、数量范围、金额精度和允许的枚举。quantity: 1.5 是合法 JSON,却可能不是合法下单数量。未知字段是忽略还是拒绝,也要说明。
新增可选响应字段通常比删除字段温和,但客户端若使用严格 Schema,仍可能失败。枚举新增值也可能破坏穷举处理。升级前用旧客户端的契约测试检查,不把“只新增字段”当成必然兼容。
4. 分页要有稳定顺序
OFFSET 10000 LIMIT 20 简单直观,但深页可能需要扫描并丢弃很多行;数据插入或删除还可能让翻页重复、遗漏。游标分页把“上一页最后一条的位置”作为下一次起点。订单按创建时间倒序时,时间可能重复,所以加入唯一 ID 做次排序。
-- PostgreSQL:$1 是上一页的 created_at,$2 是上一页的 id。
SELECT id, created_at, total_minor
FROM orders
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
真实接口还应在 SQL 中附加用户或租户过滤。游标可编码位置,但编码不是授权;排序字段变化、过滤条件变化都要按契约处理。首屏不带上述位置条件。对跨页快照严格一致的报表,还需另行设计快照或任务导出机制。
5. 重复点击该怎样处理
客户端为一次逻辑下单创建 idempotency key,重试时沿用它。服务端将 key 与身份范围、请求指纹、结果关联保存,并用唯一约束处理并发。相同 key 携带不同商品或数量时返回冲突,而不是静默复用旧订单。
保存结果和业务写入尽量处于同一个数据库事务。记录保留多久要覆盖业务重试窗口;过期以后再次发送可能变成新操作。这和网络层“请求发了几次”是两件事。
自检与参考
给创建订单写四个契约测试:合法请求;小数数量;同 key 同参数重试;同 key 改参数。再用两个客户端同时发送相同 key,确认只出现一个逻辑结果。