把「id 不存在」和「id 属于其他所属范围」这两个请求各发一次,状态码和正文是不是完全一样?
答「否」,说明接口泄露了存在性:调用方能靠响应区分「没有这条」和「有这条但不能看」。要改的是状态码与正文,不是再加一条提示。
新资源的路径用名词,动作放在方法上。过滤放进查询字符串,不写多层路径。已有接口如果已经用了别的路径,不在同一次功能改动里改名——改名单独开一次,并写兼容说明。
| 操作 | 方法与路径 | 成功 | 失败 |
|---|---|---|---|
| 列表 | GET /notes?q=&page=&size= | 200,无数据时是空数组 | 400 参数错、401 |
| 新建 | POST /notes | 201,带 Location | 400、401 |
| 单条 | GET /notes/{id} | 200 | 401、404 |
| 局部更新 | PATCH /notes/{id} | 200 | 400、401、404 |
| 删除 | DELETE /notes/{id} | 204,无正文 | 401、404 |
| 检索 | GET /notes/search?q=&mode= | 200 | 400 参数错、401 |
| 下载 | GET /notes/{id}/file | 200 | 401、404 |
| 登录 | POST /sessions | 201 | 401,两种失败原因响应一致 |
| 方法不允许 | 任意路径 | — | 405,在进入业务查询之前返回 |
| 请求体不是 JSON | 任意路径 | — | 415,同上,不查库 |
第三行与第二行的区别只写进日志,不写进响应。日志字段见日志。
未带会话 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——那等于取消状态码。
里面不含栈、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 正文一致」,只有写成测试才会把它们固定住。写进文档不算——文档不会在下次改动时变红。
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 多泄露一件事。助手写接口时按语义选状态码,而这两个请求在语义上本来就不是一回事,所以它会各挑一个「合适的」;要挡的恰恰是这种合适。
TestGetNoteCrossScopeLooksSameAsMissing 通过。违反后果:调用方能枚举出哪些 id 存在,数据边界的其他规则一起失效。grep -rnE 'req\.(Body|Query|Param).*scope' internal/ 无输出。违反后果:换一个参数就能读到别人的数据,见数据边界。401/400/404 分别是什么、越权是否与不存在相同。模板见文档。违反后果:新来的调用方只能靠试。可以改 403 的一处:第一条里的「同一种 404」只用于不能泄露存在性的资源。业务上允许调用方知道记录存在的场景(例如协作场景里明确要显示「该文档存在但无权限」),可以改用 403,但要在接口说明里写明,并同步改契约测试。默认是不允许知道。