API 设计原则
v2 的形状不是从 v1 翻过来的,是重画的。这一页把重画时定下的规矩写在外面——不写下来,「加法演进」就只是我们单方面的假设。
正式公开(2026-08-25)。
/v2对所有开发者开放,在门户自助铸造nmk_密钥即可调用,不需要申请。已发布的形状按 additive-only 演进,删除与改名由 CI 的 oasdiff 破坏性变更门拦下。
为什么是一次性切换
v1 是长出来的:先有一个站要用的接口,再有第二个站要用的接口,通用信封、数字错误码、五种分页风格就是这么攒起来的。它没坏,但它的每一个坏形状都会被复制到下一个接入方身上。
v2 是在还没有第三方接入的窗口里做的一次性破坏迁移:把所有已知的坏设计一次揪出来根治,而不是留一堆兼容开关慢慢腐烂。定调的那句话是「彻底干净」压倒「更兼容」——凡是在两者之间二选一,一律选干净,代价记在迁移账上。所以 v1 的五个面在 2026-08-27 连同它们的代码一起退役了,那些路径现在一律 410,Link 指向 /v2。
十四条公理
每一条都是可执行的,不是口号——括号里是它在这份文档里的落点。
协议层
- 资源即响应体。 一次成功的读,响应体就是那个资源;一次成功的集合读,响应体就是那个集合。没有包住它们的通用外壳,没有「先解包再用」这一步。(请求与响应)
- 错误是一等类型,不是成功响应的变体。 走 RFC 9457
application/problem+json,有自己的 media type 和自己的 schema。成功与失败在类型系统里可判别,不靠读一个字段的值。(错误处理) - 元信息属于协议层。 分页、缓存、限流、退役、请求关联——凡 HTTP 已经有位置的,一律放响应头,不进 body。body 里只有领域数据。(缓存与条件请求)
- 每个凭证体系一个前缀。 一条请求只带一个凭证。需要两种身份同时在场的操作,说明它被放错了面。(鉴权与凭据)
表示层
- 一个概念一个名字,一个名字一个概念。 同一个概念在任何面上叫同一个名字、是同一个类型;同一个名字在任何面上指同一个概念。
- 不发不可达的字段,不发无法兑现的承诺。 一个字段进契约的前提是它现在能有值,或者它的
null有明确定义的语义。我们不会为了「看起来像现代 API」而发自己并不具备的东西——计费用量、分布式 trace、sandbox 标志都不在响应里。 - 拒绝,不降级;缺席,不含糊。 非法输入是错误,不是「按默认值处理」。能力不足是拒绝,不是「给你能看的那部分」。一个键缺席只能有一种解释。
- 默认瘦,按需胖,且每一档都是静态 schema。 默认响应只含身份内核,更多数据由
view=/include=显式索取,每一档对应一个能写死在 spec 里、能被缓存、能被代码生成器建模的形状。(字段裁剪与批量读)
可寻址性
- 每个被发布出去的 id 都必须有地址。 响应里出现
xxx_id,就必须存在GET /v2/…/xxx/{id}。没有地址的 id 是死信息。 - 每个实体族都必须有批量读。 任何可寻址的族都支持
?ids=(≤100)。这是消灭 fan-out 的唯一结构,也是 agent 与镜像消费者价值最高的那个面。(集合与分页) - 一个 URL 一个表示。 公开读面的 200 响应体是请求 URL 的纯函数——同一个 URL,任何合法凭证拿到的字节相同。凭证只决定 200 还是 4xx,不决定字段集。
- 词表可发现。 开放枚举的取值必须能通过 API 自己取到。agent 遇到没见过的值,应当有地方去查而不是去猜。(词表)
文本与契约
- 面向机器的文本一律英文。
title、code、detail、枚举值、字段名全部英文且稳定。我们不做Accept-Language协商——本地化是消费端的事,我们提供的是稳定的机读键。 - 契约由运行中的代码产出,并被真实请求验证。 spec 不是第二份手写副本:它从真实路由生成,再由测试拿它去打那些路由。没有这一步,等于没有契约。
客户端契约(必须)
这三条是双向的:我们承诺只做加法,前提是你的客户端按下面三条写。做不到的话,我们加一个字段就会打断你。
- 客户端必须忽略响应中未知的字段。
- 客户端必须容忍开放词表中未见过的取值。
- 客户端必须为未知的错误
code准备一个按 HTTPstatus的兜底分支。
我们承诺不会做的事
| 不会出现 | 因为 |
|---|---|
通用响应信封({code,message,data} 及其 success / status / timestamp 变体) |
它给每条响应加一层解包,给每个 schema 加一个「data 可能不存在」的分支 |
| 数字业务错误码 | 数字空间会分叉、会撞、不可读、不能自解释 |
| JSON number 类型的实体 id | JS Number 在 2^53 以上失真,一旦发出去就再也收不回来 |
| 整数编码的序数枚举(分级、剧透档) | 招来算术与大小比较,而在既有阶梯中间插一档就是破坏性变更 |
| 裸 content hash,或要求你自己拼 URL 的字段 | 图床搬家时一半引用坏掉、一半自愈,而 CDN base 从来不是公开契约的一部分 |
静默忽略的 token(未知 include / fields / sort / facet 名) |
200 + 缺块 + 零信号 = 你会把它读成「数据里没有」 |
静默降级的参数解析(nsfw=on 当成 false、limit=5oo 当成 100) |
被降级过的调用方会把窄结果读作全部真相 |
next_cursor: null,或与之并存的 has_more |
两个真值来源必然不同步;末页应当省略游标 |
| 同一个 URL 按凭证档位返回不同的字段集 | 边缘缓存永远开不了——缓存住哪一份都是错的 |
additionalProperties: false |
与加法演进直接矛盾:我们加一个字段就打断严格客户端 |
数组或 map 序列化成 null |
你要为永不触发的分支写判空,而真会 null 的那个反而没被声明 |
纯读操作用 POST,或 /lookup、/batch 这类动词路径 |
不可缓存、不可条件请求,而资源 + 标准方法足以表达全部现有语义 |
这些承诺怎么被守住
/v2/catalog/openapi.json由运行中的路由生成,不是手写的第二份副本。- 一组 CI 门在每次改动上跑:破坏性变更(oasdiff)、错误码注册表互斥、词表封闭标注、每个字段的
description非空、声明过的状态码齐全、没有无约束的type: string。 - 「同一 URL、不同密钥、字节相同」是拿真实生产请求验的,不是靠代码审查断言的。
演进规则、什么算破坏性变更、以及退役怎么通知,见 版本与演进。