用户登录

技术文章

外部链接将在新标签页打开。

JSON、表单与图片接口的响应契约设计

一句话回答

响应契约设计的核心是让 Content-Type 与 HTTP 状态码承载语义:JSON 接口保持字段类型稳定并回传 request_id,表单写入用 303 或 422 讲清结果,图片接口按 Accept 决定成功与失败皆返回什么形态。

核心要点 TL;DR
  • 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-8application/jsonpublic, max-age=60
表单写入303 + Location(无实体)或 application/jsonapplication/jsonno-store
图片 / 二进制image/webp 等仍为图片(占位图),或按 Accept 返回 JSONpublic, max-age=86400, immutable

表格里最容易被违反的是最后一行:图片接口失败时不要吐 JSON。<img src> 拿到 application/json 只会显示裂图,同时在浏览器控制台和你的日志里留下一堆无意义的解析错误。正确做法在第四节展开。

二、JSON 响应:稳定压倒漂亮#

JSON 接口的设计分歧通常不在“长什么样”,而在“谁承载语义”。有三条原则值得写进团队规范:

  1. HTTP 状态码承载语义,响应体只承载数据与业务错误。不要所有请求都返回 200,再在 body 里写 code=500,那等于把重试逻辑外包给调用方。
  2. 字段类型永不漂移。数组字段空值给 [] 而不是 null;ID 一律字符串,避免 JS 大整数精度丢失;时间统一 RFC 3339 UTC;金额用整数分。
  3. 留一个 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”的诡异工单。

六、上线前的自检清单#

  1. 同一接口在成功与失败时,Content-Type 是否一致且正确?
  2. 错误响应是否包含 request_id,且不包含堆栈、SQL、内部路径?
  3. 数组字段在空值时返回 [] 还是 null?团队内是否统一?
  4. 时间字段是否全部为 RFC 3339 UTC?
  5. 图片接口在 Accept: image/* 且资源不存在时,是否仍返回图片?
  6. 写入接口是否有幂等键与体积上限(413/415 是否真的会返回)?
  7. 限流信息是否通过响应头暴露,而不是只写在文档里?
  8. 新增字段是否只增不改不删,老客户端仍能解析?

七、总结#

响应设计的本质,是把“不可见的约定”变成“可见的字节”:Content-Type 决定客户端如何解析,状态码决定它是否重试,错误结构决定它能不能自愈,缓存头决定你的带宽账单。把 JSON、表单、图片这三类响应的规范一次性定下来,比在每个接口里单独讨论要省力得多,也更适合 Agent 大规模调用。

如果想直接对着真实响应改造,可以先从 IP 查询接口 看 JSON 形态,再对比随机动漫壁纸接口的二进制形态与元数据形态;遇到签名、配额或异常行为的问题,可以通过 技术支持 反馈。现在在 API.PLUS 注册即可获取免费 API Key,把示例里的 YOUR_KEY 换成自己的 Key,上面所有命令都能直接跑通。

常见问题(FAQ)

接口全部返回 200,再用响应体里的 code 表示错误,可以吗?
不推荐。HTTP 状态码应承载语义,响应体只承载数据与业务错误;参数错误时状态码要整体下移,例如返回 400。否则重试与降级逻辑全部被推给调用方,Agent 调用更容易整条链路失败。
图片接口请求失败时应该返回什么?
按 Accept 决定错误形态。Accept 含 image/* 时返回一张固定占位图,状态码可以是 200 或 404,但类型必须是图片;Accept 或 type=json 时才返回结构化错误,也不要用 200 掩盖真实的 404。
表单提交成功后应该返回什么?
浏览器原生表单推荐 303 See Other 加 Location,走 PRG 模式避免刷新重复提交;前后端分离的 fetch 调用返回 201/200 加 JSON 更自然。但同一接口必须二选一,不要按 User-Agent 返回两种形态。
JSON 响应里为什么建议保留 request_id?
它既是排障锚点,也是限流与计费对账的关联键。服务端生成并在响应头 X-Request-Id 与响应体中回显,客户端报错时带上它,双方对日志就不必猜时间戳。
表单接口涉及扣费或配额消耗时需要幂等键吗?
需要。同一个 Idempotency-Key 在窗口期内应返回首次结果。表单接口一旦涉及扣费或配额消耗,幂等键就不是加分项,而是必需项。

作者:沐小悠

本文约 15 分钟读完 · 关键词:API 响应设计、JSON 响应规范、图片接口、Content-Type、API.PLUS

更多文章 GET 示例接口

「」标签下的其他文章