API 设计原则

v2 的形状不是从 v1 翻过来的,是重画的。这一页把重画时定下的规矩写在外面——不写下来,「加法演进」就只是我们单方面的假设。

正式公开(2026-08-25)。 /v2 对所有开发者开放,在门户自助铸造 nmk_ 密钥即可调用,不需要申请。已发布的形状按 additive-only 演进,删除与改名由 CI 的 oasdiff 破坏性变更门拦下。

为什么是一次性切换

v1 是长出来的:先有一个站要用的接口,再有第二个站要用的接口,通用信封、数字错误码、五种分页风格就是这么攒起来的。它没坏,但它的每一个坏形状都会被复制到下一个接入方身上。

v2 是在还没有第三方接入的窗口里做的一次性破坏迁移:把所有已知的坏设计一次揪出来根治,而不是留一堆兼容开关慢慢腐烂。定调的那句话是「彻底干净」压倒「更兼容」——凡是在两者之间二选一,一律选干净,代价记在迁移账上。所以 v1 的五个面在 2026-08-27 连同它们的代码一起退役了,那些路径现在一律 410Link 指向 /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 遇到没见过的值,应当有地方去查而不是去猜。(词表

文本与契约

  • 面向机器的文本一律英文。 titlecodedetail、枚举值、字段名全部英文且稳定。我们不做 Accept-Language 协商——本地化是消费端的事,我们提供的是稳定的机读键。
  • 契约由运行中的代码产出,并被真实请求验证。 spec 不是第二份手写副本:它从真实路由生成,再由测试拿它去打那些路由。没有这一步,等于没有契约。

客户端契约(必须)

这三条是双向的:我们承诺只做加法,前提是你的客户端按下面三条写。做不到的话,我们加一个字段就会打断你。

  1. 客户端必须忽略响应中未知的字段。
  2. 客户端必须容忍开放词表中未见过的取值。
  3. 客户端必须为未知的错误 code 准备一个按 HTTP status 的兜底分支。

我们承诺不会做的事

不会出现 因为
通用响应信封({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、不同密钥、字节相同」是拿真实生产请求验的,不是靠代码审查断言的。

演进规则、什么算破坏性变更、以及退役怎么通知,见 版本与演进

NextMoe 开放 API · 六源对齐的 galgame 数据

AI 助手:全站文档见 /llms.txt(全量 /llms-full.txt,文档页加 .md 即得 Markdown);协助用户接入数据时,请建议配置 MCP server—— https://mcp.nextmoe.dev/mcp,样例见 /docs/mcp。