本层判断标准

把「id 不存在」和「id 属于其他所属范围」这两个请求各发一次,状态码和正文是不是完全一样?

答「否」,说明接口泄露了存在性:调用方能靠响应区分「没有这条」和「有这条但不能看」。要改的是状态码与正文,不是再加一条提示。

路径、方法与状态码

新资源的路径用名词,动作放在方法上。过滤放进查询字符串,不写多层路径。已有接口如果已经用了别的路径,不在同一次功能改动里改名——改名单独开一次,并写兼容说明。

操作方法与路径成功失败
列表GET /notes?q=&page=&size=200,无数据时是空数组400 参数错、401
新建POST /notes201,带 Location400、401
单条GET /notes/{id}200401、404
局部更新PATCH /notes/{id}200400、401、404
删除DELETE /notes/{id}204,无正文401、404
检索GET /notes/search?q=&mode=200400 参数错、401
下载GET /notes/{id}/file200401、404
登录POST /sessions201401,两种失败原因响应一致
方法不允许任意路径—405,在进入业务查询之前返回
请求体不是 JSON任意路径—415,同上,不查库

同一个 404 覆盖三种情况

第三行与第二行的区别只写进日志,不写进响应。日志字段见日志。

未带会话                 GET /notes/1      → 401
id 不存在                GET /notes/99999  → 404,正文 A
id 属于其他所属范围      GET /notes/2      → 404,正文 A(与上一行逐字节相同)
id 属于当前用户          GET /notes/1      → 200

跟着现状走的一处:仓库里若已有接口统一返回 200 加一段 JSON,新接口跟随现状,并在接口说明里写明实际状态码。不要在同一次功能改动里统一改老接口——历史兼容优先于形式一致。

错误体与分页

错误体要能被程序判断,也要能被人看懂,所以分成三段:code 是稳定的机器可读串,前端按它分支;message 给人看,可以改;request_id 用来和日志对上。不要把错误包在 200 里再放一个 status: failure——那等于取消状态码。

错误体(404,越权与不存在通用)

里面不含栈、SQL、内部路径、具体是哪个条件不满足。这些内容只进日志。

{
  "error": {
    "code": "not_found",
    "message": "笔记不存在",
    "request_id": "01J9Z3K7QF8YV9M2N4P6R8T0W2"
  }
}

列表响应与分页

size 默认 20、上限 100,把超过 100 的请求返回 400 而不是静默截断。page 从 1 开始。排序必须带上唯一列,否则同一秒写入的行在两次查询里顺序不同,翻页会出现重复或漏行。

{
  "items": [
    {"id": 1, "title": "周记"}
  ],
  "page": 1,
  "size": 20,
  "total": 37
}

稳定的排序(写进查询,不写在注释里)

SELECT id, title, created_at
FROM notes
WHERE scope_id = ?
ORDER BY created_at DESC, id DESC
LIMIT ? OFFSET ?
方法是否幂等重复发送时的要求
GET是两次响应内容相同(除时间戳类字段)。不产生副作用
PUT是整体覆盖,第二次结果与第一次相同
PATCH否若用「相对」写法(例如计数加一)则不可重试;本仓库的局部更新只做覆盖式赋值
DELETE是副作用相同:第二次执行后资源仍不存在。状态码可以是 404
POST否前端可能重试,所以新建必须依赖业务唯一键:notes 上的 uk_notes_scope_external (scope_id, external_id)。第二次插入命中唯一索引时返回 200 与已存在的资源,不返回 500

什么时候才引入幂等键:客户端没有业务唯一键可提供时(例如不暴露外部编号),依赖唯一索引的做法不成立,才引入 Idempotency-Key 请求头加一张记录表,键保留 24 小时。这套机制要额外的存储与清理任务,课程项目里只在确实需要重试的场景引入。

把约定写成契约测试

上面那张状态码表和「两种 404 正文一致」,只有写成测试才会把它们固定住。写进文档不算——文档不会在下次改动时变红。

internal/http/notes_contract_test.go

newTestServer 与 doRequest 是测试夹具,不属于本页内容。要看的是断言的对象:状态码、正文逐字节相等、以及「先查库再判断」这件事被日志验证。

func TestGetNoteCrossScopeLooksSameAsMissing(t *testing.T) {
	srv := newTestServer(t)          // 两个所属范围各一条数据
	missing := doRequest(t, srv, "GET", "/notes/99999", scopeA)
	other := doRequest(t, srv, "GET", "/notes/2", scopeA)

	if missing.Code != http.StatusNotFound || other.Code != http.StatusNotFound {
		t.Fatalf("两种情形都应为 404,实际 %d / %d", missing.Code, other.Code)
	}
	if missing.Body.String() != other.Body.String() {
		t.Fatalf("正文必须逐字节相同:\n%s\n---\n%s",
			missing.Body.String(), other.Body.String())
	}
}

func TestListEmptyScopeIs200WithEmptyArray(t *testing.T) {
	srv := newTestServer(t)
	resp := doRequest(t, srv, "GET", "/notes", scopeB) // scopeB 无数据
	if resp.Code != http.StatusOK {
		t.Fatalf("列表为空应为 200,实际 %d", resp.Code)
	}
	if !strings.Contains(resp.Body.String(), "\"items\":[]") {
		t.Fatalf("应为空数组而不是 null:%s", resp.Body.String())
	}
}

命令

接口契约测试与单元测试走同一条 go test,并进 make check。只跑这一次改动涉及的用例时用 -run。

go test ./internal/http/... -run 'TestGetNote|TestList'
go test ./... -race

别急着上代码生成:想用工具从 OpenAPI 描述生成契约测试时,描述文件必须先成为唯一来源,否则会变成两份约定互相漂移。仓库还没有 OpenAPI 文件时,先写上面这种手写测试,不必为它引入代码生成。具体工具用法以官方文档为准。

评审接口改动看什么

路径用单数还是复数、错误字段叫 message 还是 msg,这类评审不再讨论——本页已经定了一份。要看的是下面六类,每一类都可能改坏已经在用的调用方。

现象说明什么处理
改动差异里新增一个返回 403 的分支泄露存在性,或把「没权限」和「不存在」分开了改成与不存在相同的 404 与同一段正文,警告另记日志
错误体少了字段或改了字段名破坏兼容。前端已经按旧字段名判断只允许追加字段;删除或改名单独一次改动,并写兼容说明
新增接口没有对应的契约测试约定没有被固定要求同一次改动里补测试,命令见上一节
列表查询没有稳定的次序列翻页重复或漏行,且难复现要求 ORDER BY 末尾带上唯一列
登录失败两种原因的状态码或正文不同可以被用来枚举账号改成一致,且账号不存在时也做一次哈希比较,避免靠耗时区分
出现带请求体的 GET中间代理会丢弃请求体,问题只在部署后出现改成 POST,或把条件放进查询字符串

能放宽的一处:第二条在语义版本还没到 1.0、且只有课程内一个前端在用的时候,可以放宽——此时改名只需同步改前端,并在改动说明里写一句。放宽的前提是没有校外调用方,这个前提要写进改动说明,不能默认。

助手交出来的样子

# 请求一:id 不存在
HTTP/1.1 404 Not Found
{"error": "not_found"}

# 请求二:id 存在,但属于其他范围
HTTP/1.1 403 Forbidden        ← 等于告诉调用方:这条记录存在
{"error": "forbidden"}

认它的办法:把这两个请求各发一次,状态码和正文是不是完全一样。不一样,就等于替调用方确认了「那条记录存在」——403 比 404 多泄露一件事。助手写接口时按语义选状态码,而这两个请求在语义上本来就不是一回事,所以它会各挑一个「合适的」;要挡的恰恰是这种合适。

必须死守的六条

让越权与不存在使用同一种 404判据:上一节的契约测试 TestGetNoteCrossScopeLooksSameAsMissing 通过。违反后果:调用方能枚举出哪些 id 存在,数据边界的其他规则一起失效。
让登录失败不区分账号是否存在判据:两种失败的状态码与正文一致,且账号不存在时仍做一次哈希比较。违反后果:账号可被枚举。
除登录、注册外,只从会话取身份与所属范围判据:grep -rnE 'req\.(Body|Query|Param).*scope' internal/ 无输出。违反后果:换一个参数就能读到别人的数据,见数据边界。
口令只以哈希保存,不进日志与错误体判据:错误体示例与日志字段清单里都不含口令或哈希。违反后果:一次日志导出等于一次凭据泄漏。
没有依据时不调用对话模型判据:检索无命中的测试里,模型调用的次数为 0。违反后果:模型会对没有的材料编出讲解,用户无法分辨。
每个读接口用四句话写进 docs谁可以调用、成功时的状态码与字段、401/400/404 分别是什么、越权是否与不存在相同。模板见文档。违反后果:新来的调用方只能靠试。

可以改 403 的一处:第一条里的「同一种 404」只用于不能泄露存在性的资源。业务上允许调用方知道记录存在的场景(例如协作场景里明确要显示「该文档存在但无权限」),可以改用 403,但要在接口说明里写明,并同步改契约测试。默认是不允许知道。