CHAPTER 04 / 自学试学版

把功能变成明确的接口和责任

读解释、检查证据、修订自己的设计。正文与参考记录可离线阅读,真实网络观察需要联网或启动本机服务。

导出包含第 0–7 章作答。导入会替换八章全部作答,包括文件中未包含的章节;建议先导出备份。

4.1 · 把一句愿望变成可检查的承诺

“帮我保存”还不是完整的接口

小林在手机上给笔记改标题,电脑上也开着同一条笔记。前端同学说“我把新文字发过去”,后端同学说“我存进数据库”。两个人都完成了工作,旧电脑却把手机的新标题覆盖了。问题不只在代码:双方从未约定修改哪一条、基于哪个版本,以及过期修改应当怎么办。

接口契约描述调用者提供什么,接收方检查什么,什么条件下改变数据,以及返回结果意味着什么。它可以是 HTTP 接口,也可以是同一个程序中的函数接口。写出契约,才能判断两份实现是否合作正确。

本章沿用笔记系统,先固定创建、读取和改标题三个操作。身份与权限的细节留给下一章,但始终假设业务层已拿到可信身份,不能让请求自己宣布“我是所有者”。本章的路径与 JSON 是拟议的 HTTP 表达;新增证据是实际运行的应用函数与 SQLite 记录,没有经过 HTTP 网络。第一章提供的是另一组真实 HTTP 实验。

最终交付一份别人能照着实现和验收的接口表。每一行都要有输入、前置条件、成功结果、拒绝结果和数据变化,不能只写一个网址。

4.2 · 先确定读写含义,再选择方法

三种操作,三种不同的承诺

创建会产生新对象,读取返回现有对象,编辑改变已有对象。按钮都可以叫“保存”,内部却不能混用。下面是本课程选择的一套契约;它不是所有应用必须照搬的命名标准。

拟议 HTTP 契约;记录区用应用函数执行相应操作
操作与表达输入及条件成功后能检查什么本章的拒绝约定
创建
POST /api/notes
标题;所有者来自可信身份201,返回新编号、标题和版本;数据多一条标题不合要求:422
读取单条
GET /api/notes/1
目标编号;须有读取权限200,返回当前可读的笔记和版本;数据不变不存在或无权查看:404
改标题
PATCH /api/notes/1
新标题、读到的版本;须有编辑权限200,同一编号的新标题及递增版本;条数不变输入无效:422;版本过期:409

举例:先创建“阅读清单”,取得编号 1、版本 1;再次读取,得到相同对象;用 {"title":"周末阅读","version":1} 请求改标题。若当前仍为版本 1,成功后成为版本 2。随后旧窗口携带版本 1 再提交,就应被拒绝,保留版本 2 的内容。版本检查与更新必须作为同一个受保护的修改步骤,不能先查完、隔很久再无条件覆盖。

这里选 PATCH 表达部分修改。另一种可行设计是提交完整笔记进行替换,但必须明确未提供字段如何处理,并承担传输更多字段及误覆盖的代价。方法名称不会自动完成版本校验。若采用标准 HTTP 条件请求,还可使用 ETag 与 If-Match;本课先用显式版本字段表达同一个设计需求,不混用两套规则。

列表接口也要有边界:一次最多返回多少条,按什么顺序,如何拿下一页。可从“按编号排序、每页至多 20 条”这样的约定开始。这里是设计练习,不声称证据包已经实现分页。限制单次返回量,才能让调用者知道完整列表可能需要多次读取。

4.3 · 谁帮助输入,谁守住规则

浏览器拦住了,不代表服务端检查过了

输入框可以提示“标题不能为空”,避免用户提交后才发现错误;但请求也可能来自脚本或另一个客户端。应用必须独立检查进入自己的数据,数据库再用必要约束防止错误状态落下。三层责任可以互相支持,不能把一层的表现当成另外两层的证据。

本例要求标题为文本,先去除首尾空白,再检查长度为 1–80;长度按 Python 的 Unicode 码点计算,不按字节数。一些组合字符或表情看起来是一个图形,却未必算一个码点,因此前后端必须约定计数方式。编辑还必须提供正整数版本。数字、缺字段、全空白和超过上限,是不同的拒绝用例。

还有“内容格式正确,但当前不能这么做”的情况:旧版本文本本身可能完全合法,却与最新数据冲突。把它和空标题都显示成“请重试”,用户就无法作出正确行动。本例用 422 提示修正输入,用 409 提示先读取新版本再处理差异;权限错误在下一章补齐。

空标题触发页面红字,哪一个结论成立?

4.4 · 让契约面对实际执行

拒绝是否正确,要同时看结果和状态

下面的记录来自隔离数据库中的 Python 应用函数调用:测试入口传入参数,函数校验并操作 SQLite,记录返回结果和数据库前后快照。返回的数值状态用于表达预期 HTTP 语义,不是服务器发出的 HTTP 响应包。因此它可支持业务规则的判断,不能证明路由、网络、请求解析与认证集成都正确。想观察真实网络往返,可回到第一章的本机 HTTP 实验。

已执行的 Python / SQLite 记录 · 不是当前 HTTP 请求

采集时间(UTC):2026-10-04T22:34:20.017956+00:00;SQLite 3.42.0。仅用虚构数据与隔离临时库。

这里真正运行了数据库操作或应用函数。身份由测试程序提供,没有登录认证流程;状态码是函数返回的契约字段。原始前后快照供核对,不是发送给访问者的业务响应。

1. 合法创建:服务决定归属和编号
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "create",
    "noteId": null,
    "payload": {
      "title": "  契约示例  ",
      "body": "初始正文"
    }
  },
  "响应": {
    "status": 201,
    "body": {
      "requestId": "req-001",
      "note": {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
2. 读取新编号
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "read",
    "noteId": 3,
    "payload": null
  },
  "响应": {
    "status": 200,
    "body": {
      "requestId": "req-002",
      "note": {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
3. 直接调用非法标题:''
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "create",
    "noteId": null,
    "payload": {
      "title": ""
    }
  },
  "响应": {
    "status": 422,
    "body": {
      "requestId": "req-003",
      "error": "invalid_title"
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
4. 直接调用非法标题:' '
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "create",
    "noteId": null,
    "payload": {
      "title": " "
    }
  },
  "响应": {
    "status": 422,
    "body": {
      "requestId": "req-004",
      "error": "invalid_title"
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
5. 直接调用非法标题:'长长长长长长长长长长长长长长长长长长长长长长长长长
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "create",
    "noteId": null,
    "payload": {
      "title": "长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长"
    }
  },
  "响应": {
    "status": 422,
    "body": {
      "requestId": "req-005",
      "error": "invalid_title"
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
6. 直接调用非法标题:None
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "create",
    "noteId": null,
    "payload": {
      "title": null
    }
  },
  "响应": {
    "status": 422,
    "body": {
      "requestId": "req-006",
      "error": "invalid_title"
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
7. 错误正文类型
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "create",
    "noteId": null,
    "payload": {
      "title": "合法",
      "body": 1
    }
  },
  "响应": {
    "status": 422,
    "body": {
      "requestId": "req-007",
      "error": "invalid_body"
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
8. 按version=1编辑
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "edit",
    "noteId": 3,
    "payload": {
      "title": "契约示例",
      "body": "新版正文",
      "version": 1
    }
  },
  "响应": {
    "status": 200,
    "body": {
      "requestId": "req-008",
      "note": {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "新版正文",
        "version": 2
      }
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "初始正文",
        "version": 1
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "新版正文",
        "version": 2
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
9. 只修改标题时保留原正文
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "edit",
    "noteId": 3,
    "payload": {
      "title": "只改标题",
      "version": 2
    }
  },
  "响应": {
    "status": 200,
    "body": {
      "requestId": "req-009",
      "note": {
        "id": 3,
        "owner_id": "alice",
        "title": "只改标题",
        "body": "新版正文",
        "version": 3
      }
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "契约示例",
        "body": "新版正文",
        "version": 2
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "只改标题",
        "body": "新版正文",
        "version": 3
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
10. 旧版本编辑被拒
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "edit",
    "noteId": 3,
    "payload": {
      "title": "契约示例",
      "body": "旧版覆盖",
      "version": 1
    }
  },
  "响应": {
    "status": 409,
    "body": {
      "requestId": "req-010",
      "error": "version_conflict"
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "只改标题",
        "body": "新版正文",
        "version": 3
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "只改标题",
        "body": "新版正文",
        "version": 3
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
11. 缺少version被拒
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "edit",
    "noteId": 3,
    "payload": {
      "title": "契约示例"
    }
  },
  "响应": {
    "status": 422,
    "body": {
      "requestId": "req-011",
      "error": "invalid_version"
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "只改标题",
        "body": "新版正文",
        "version": 3
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "只改标题",
        "body": "新版正文",
        "version": 3
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
12. 不存在的编号
{
  "调用": {
    "fixturePrincipal": "alice",
    "action": "read",
    "noteId": 999,
    "payload": null
  },
  "响应": {
    "status": 404,
    "body": {
      "requestId": "req-012",
      "error": "not_found"
    }
  },
  "数据前": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "只改标题",
        "body": "新版正文",
        "version": 3
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  },
  "数据后": {
    "notes": [
      {
        "id": 1,
        "owner_id": "alice",
        "title": "草稿一",
        "body": "A已经确认的编辑",
        "version": 2
      },
      {
        "id": 2,
        "owner_id": "alice",
        "title": "带标签的笔记",
        "body": "",
        "version": 1
      },
      {
        "id": 3,
        "owner_id": "alice",
        "title": "只改标题",
        "body": "新版正文",
        "version": 3
      }
    ],
    "note_tags": [
      {
        "note_id": 2,
        "tag_id": 1
      }
    ],
    "shares": []
  }
}
怎样复现与检查范围

下载底部实验包,运行 python3 evidence_lab.py。脚本在临时目录创建、检查和清理数据库;不会打开你的笔记库。运行结果只支持这些用例,不证明生产安全、并发压力、真实断电或云灾备。

记录对应源码 SHA-256:e7aecd720f9614777ee3d5c70996c518a381157d17f4988736b9061b7706481d。

CREATE TABLE users(id TEXT PRIMARY KEY);
CREATE TABLE notes(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
 title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 80),
 body TEXT NOT NULL DEFAULT '', version INTEGER NOT NULL DEFAULT 1 CHECK(version > 0));
CREATE TABLE tags(id INTEGER PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id),
 name TEXT NOT NULL, UNIQUE(owner_id,name));
CREATE TABLE note_tags(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
 tag_id INTEGER NOT NULL REFERENCES tags(id), PRIMARY KEY(note_id,tag_id));
CREATE TABLE shares(note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
 reader_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY(note_id,reader_id));
INSERT INTO users VALUES ('alice'),('bob'),('eve');
INSERT INTO tags VALUES (1,'alice','学习'),(2,'bob','私有标签');

选一条成功创建记录:输入标题是什么,返回编号是什么,数据库新增了哪一条?再选空标题或超长标题:应用收到的值是什么,为什么拒绝,全部笔记的内容和条数是否保持相同?只比较条数会漏掉“没有新增,却误改了旧笔记”的问题。

最后检查编辑:成功时应改变指定笔记的标题和版本;过期版本被拒绝时,新标题不能被旧值覆盖。一个拒绝案例通过,只证明这一组前置状态与输入符合预期。若没有执行缺版本的情况,就把它列为待测,不把正常输入成功扩大成“接口已经没问题”。

提示 1:先确定拒绝原因

这是输入格式、对象不可用,还是版本冲突?不同原因对应不同前置条件。

提示 2:比较对象内容,不只比较数量

对照目标编号、标题、所有者与版本。再看非目标对象有没有改变。

提示 3:一种参考推理

空标题应返回 422,笔记快照保持相同。若记录没有缺版本案例,新增“已有笔记,编辑时省略版本”的测试。另一种合理方案是检查错误编号,只要明确预期拒绝结果与不应改变的数据。

4.5 · 别让不同编号承担同一项工作

请求编号用来追踪,不会自动防止重复保存

用户说“刚才保存失败了”,你需要定位是哪一次调用。入口可以为每次处理生成 requestId,在相关日志和返回结果中带上它。如此可以把输入校验、数据库动作与返回结果串起来。这里只记录定位所需的结果和元信息,不能为了方便排查就把密码或整篇私人笔记写进日志。

笔记编号标识对象,版本标识对象的修订状态,请求编号标识一次处理。重复发送同一保存意图时,可能产生两个请求编号;它们并不会自动合并。第六章会再引入“同一次业务操作”的身份,解决重试造成的重复副作用。

两次创建都生成了不同 requestId,能断定不会重复创建吗?

4.6 · 把同样的推理迁移到照片说明

让另一位开发者能够写出验收用例

为相册中的“修改照片说明”写一份小契约:说明可为空吗,长度怎样计数,如何定位照片,旧页面覆盖新修改时怎么办,成功与拒绝分别改变什么。至少给一个正常输入、一个边界输入和一个过期版本案例。不要把照片文件重新上传误当成修改说明所必需的步骤。

参考判断与可接受替代

可以允许空说明表示清除,也可以要求非空,但要说明对应需求。可用字段级修改,或完整对象替换;后者须定义其他字段和并发更新规则。重要的是调用者不必猜测,不是必须选某个状态码。未收到结果属于结果未知,不能塞进“输入错误”。

自评不等于验收通过。把本章接口表加入你的项目设计档案,下一章再给每条操作补上“谁能调用”的条件。

选读:只读与本章决定有关的部分
  • MDN · PATCH:读开头与 “Successfully modifying a resource”。比较部分修改与完整替换;不要把示例的 204 当成唯一成功状态。
  • OWASP · Input Validation,Common Pitfalls:找客户端单独校验和只检查外层字段的问题。说明本章的三层职责;不要求背完整安全清单。
  • OWASP · Logging,Event attributes:选读关联编号的 Note A,再看 “Data to exclude”。为一个失败请求挑出足够定位、又不暴露私人正文的日志字段。