JSON、表单与图片接口的响应契约设计
响应契约设计的核心是让 Content-Type 与 HTTP 状态码承载语义:JSON 接口保持字段类型稳定并回传 request_id,表单写入用 303 或 422 讲清结果,图片接口按 Accept 决定成功与失败皆返回什么形态。
- Content-Type 是唯一格式事实来源,不要靠 URL 后缀猜格式
- HTTP 状态码承载语义,响应体只放数据与业务错误
- 字段类型永不漂移:空数组给 [],ID 用字符串,时间用 RFC 3339
- 图片接口失败时按 Accept 返回占位图,不要在图片分支返回 JSON
- 表单写入用 303 加 Location 防重复提交,校验失败回 422 字段级错误
引言:响应体是接口唯一不会说谎的部分#
接口文档写得再漂亮,客户端真正依赖的只有两样东西:HTTP 状态行和响应体。前者决定要不要重试、要不要降级,后者决定怎么解析、怎么渲染。这也是为什么近两年安全圈反复强调“响应本身就是攻击面”——Cloudflare 在 2026 年把 PHP Capture the Flag 挑战接入自家 Bug Bounty,用真实攻击载荷检验 WAF(Web Application Firewall,Web 应用防火墙) 的拦截能力,而大量绕过往往不是从参数开始的,而是从一处多余的报错堆栈、一个没有约束的 Content-Type 开始的。
与此同时,调用方正在从人变成 Agent。人类可以容忍返回体里混着几个调试字段,Agent 不行:它只会照着契约解析,字段类型一变就整条链路失败。本文以 API.PLUS 的 IP 查询、随机动漫壁纸、POST 示例三个接口为参照,梳理 JSON、表单、图片三类响应在设计上应该遵守的规范。
一、让 Content-Type 成为唯一事实来源#
很多团队习惯用 /api/xxx.json 这类后缀区分格式,短期省事,长期是债。正确做法是让响应头承载格式语义,客户端只读 Content-Type,不猜 URL。三类接口的默认值建议在框架层就固定下来:
| 接口类型 | 成功时 Content-Type | 失败时 Content-Type | 缓存默认值 |
|---|---|---|---|
| JSON 查询 | application/json; charset=utf-8 | application/json | public, max-age=60 |
| 表单写入 | 303 + Location(无实体)或 application/json | application/json | no-store |
| 图片 / 二进制 | image/webp 等 | 仍为图片(占位图),或按 Accept 返回 JSON | public, max-age=86400, immutable |
表格里最容易被违反的是最后一行:图片接口失败时不要吐 JSON。<img src> 拿到 application/json 只会显示裂图,同时在浏览器控制台和你的日志里留下一堆无意义的解析错误。正确做法在第四节展开。
二、JSON 响应:稳定压倒漂亮#
JSON 接口的设计分歧通常不在“长什么样”,而在“谁承载语义”。有三条原则值得写进团队规范:
- HTTP 状态码承载语义,响应体只承载数据与业务错误。不要所有请求都返回 200,再在 body 里写
code=500,那等于把重试逻辑外包给调用方。 - 字段类型永不漂移。数组字段空值给
[]而不是null;ID 一律字符串,避免 JS 大整数精度丢失;时间统一 RFC 3339 UTC;金额用整数分。 - 留一个 request_id。它既是排障锚点,也是限流与计费对账的关联键。
用 IP 查询接口跑一次,请求与响应如下:
示例说明:以下代码仅用于逻辑验证,生产环境需补充异常处理、超时与重试、日志与监控告警及降级策略。
curl -s -D - "https://www.api.plus/api/get?apikey=YOUR_KEY&ip=8.8.8.8"HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: public, max-age=60
X-Request-Id: 7f3c1a9e2b
{
"code": 0,
"data": {
"ip": "8.8.8.8",
"country": "US",
"region": "California",
"isp": "Google LLC",
"queried_at": "2026-10-29T08:12:31Z"
},
"request_id": "7f3c1a9e2b"
}注意 code 与 HTTP 状态码的分工:200 表示“请求被正确处理”,code: 0 表示“业务成功”。参数错误时状态码要整体下移,而不是继续返回 200:
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{
"code": 40021,
"message": "ip 参数格式不合法",
"data": null,
"request_id": "7f3c1a9e2b",
"docs": "https://www.api.plus/doc/get"
}两个细节值得单独强调。其一,docs 字段指向文档,比在 message 里写一句“请查阅文档”有用得多,Agent 可以直接跟进;其二,错误响应里不要出现内部类名、SQL 片段或堆栈,少暴露一点结构,攻击者就少一分线索。这与 WAF(Web Application Firewall,Web 应用防火墙) 的防御思路是一致的。
分页同理。offset/limit 在数据变动时会重复或漏行,游标分页更适合作为长期契约:
{
"code": 0,
"data": {
"items": [],
"next_cursor": "eyJpZCI6MTIzfQ",
"has_more": false
}
}三、表单与 POST:用状态码讲清楚发生了什么#
表单场景的争议集中在两点:成功后返回什么,校验失败返回什么。
如果调用方是浏览器原生表单,成功写入推荐 303 See Other 加 Location,也就是 PRG 模式,避免用户刷新导致重复提交:
curl -s -i -X POST "https://www.api.plus/api/post" -H "Content-Type: application/x-www-form-urlencoded" -H "Idempotency-Key: 3f6b1c2d-20261029" --data-urlencode "name=沐小悠" --data-urlencode "scene=checkout"HTTP/1.1 303 See Other
Location: https://www.api.plus/doc/post
X-Request-Id: 9a1e4c77b0
Cache-Control: no-store如果是前后端分离的 fetch 调用,返回 201/200 加 JSON 更自然。但必须二选一,不要同一个接口按 User-Agent 返回两种形态——那会让客户端写不出确定的解析逻辑。失败时的关键是字段级错误,只回一句“参数错误”等于把排查成本全部推给调用方:
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json; charset=utf-8
{
"code": 42201,
"message": "参数校验未通过",
"errors": [
{ "field": "scene", "code": "invalid_enum", "message": "只接受 checkout 或 signup" }
],
"request_id": "9a1e4c77b0"
}还有三件事经常被漏掉:Content-Type 校验,不是 application/x-www-form-urlencoded 或 multipart/form-data 就直接回 415;体积上限,超过阈值回 413,而不是让后端慢慢读完再报错;幂等键,同一个 Idempotency-Key 在窗口期内返回首次结果。表单接口一旦涉及扣费或配额消耗,幂等键就不是加分项,而是必需项。
四、图片与二进制:让 Accept 决定返回什么#
以随机动漫壁纸接口为例,它同时服务两类调用方:直接写 <img src> 的浏览器,和需要元数据做预占位的程序。常见做法是用查询参数切换形态:
curl -s -o wallpaper.webp -D - "https://www.api.plus/api/dongman" -H "Accept: image/*"
curl -s "https://www.api.plus/api/dongman?type=json"二进制分支的响应头要同时满足缓存与校验的需要:
HTTP/1.1 200 OK
Content-Type: image/webp
Content-Length: 184320
ETag: "dm-9f21c4"
Cache-Control: public, max-age=86400, immutable
Content-Disposition: inline; filename="dongman-9f21c4.webp"
X-Request-Id: 4c8b02e1a7元数据分支返回 JSON,字段应包含尺寸、格式、有效期,方便客户端先占位、再做二次缓存:
{
"code": 0,
"data": {
"id": "9f21c4",
"format": "webp",
"width": 1920,
"height": 1080,
"expires_at": "2026-10-30T08:12:31Z"
},
"request_id": "4c8b02e1a7"
}失败路径才是这类接口最容易翻车的地方,规则可以概括成一句:按 Accept 决定错误形态。Accept 含 image/* 时返回一张固定的占位图,状态码可以是 200 或 404,但类型必须是图片;Accept 是 JSON 或带了 type=json 时返回结构化错误。既不要在图片分支里返回 JSON,也不要用 200 掩盖真实的 404——那会让客户端的错误率指标永远是 0,而用户看到的是满屏灰块。
另外别忘了 ETag 与 If-None-Match 的配合。图片接口的流量通常比 JSON 大一个量级,一个正确的 304 比任何压缩算法都划算;缓存键怎么切分、边缘如何失效,可以参考API 文章中心里关于缓存键设计的讨论。
五、横切关注点:请求 ID、限流头与安全默认值#
三类响应共享一批“与业务无关但不能没有”的响应头:
X-Request-Id:服务端生成并回显,客户端报错时带上它,双方对日志不用猜时间戳。RateLimit-Limit / Remaining / Reset:让调用方自己计算退避,比 429 之后盲目重试友好得多,配合Retry-After使用。Cache-Control:查询类可缓存、写入类 no-store、图片长缓存 immutable,三者的默认值应在框架层统一,而不是每个接口手写一遍。- 错误信息的边界:只回“能做什么”,不回“内部怎么做的”。所有对外文本都假设会被攻击者读到。
如果同一个业务同时提供 JSON 与表单两个入口(比如 POST 示例接口),要把它们当作两条独立契约分别测试:同一份校验逻辑,两种 Content-Type,状态码与错误结构必须一致,否则总有一天会出现“表单能过、JSON 报 500”的诡异工单。
六、上线前的自检清单#
- 同一接口在成功与失败时,Content-Type 是否一致且正确?
- 错误响应是否包含 request_id,且不包含堆栈、SQL、内部路径?
- 数组字段在空值时返回
[]还是null?团队内是否统一? - 时间字段是否全部为 RFC 3339 UTC?
- 图片接口在
Accept: image/*且资源不存在时,是否仍返回图片? - 写入接口是否有幂等键与体积上限(413/415 是否真的会返回)?
- 限流信息是否通过响应头暴露,而不是只写在文档里?
- 新增字段是否只增不改不删,老客户端仍能解析?
七、总结#
响应设计的本质,是把“不可见的约定”变成“可见的字节”:Content-Type 决定客户端如何解析,状态码决定它是否重试,错误结构决定它能不能自愈,缓存头决定你的带宽账单。把 JSON、表单、图片这三类响应的规范一次性定下来,比在每个接口里单独讨论要省力得多,也更适合 Agent 大规模调用。
如果想直接对着真实响应改造,可以先从 IP 查询接口 看 JSON 形态,再对比随机动漫壁纸接口的二进制形态与元数据形态;遇到签名、配额或异常行为的问题,可以通过 技术支持 反馈。现在在 API.PLUS 注册即可获取免费 API Key,把示例里的 YOUR_KEY 换成自己的 Key,上面所有命令都能直接跑通。