【AI Native开发-02】从一句需求到一次交付:SDD 四道闸门如何拦住错误
这是“AI Native开发”系列的第 2 篇。前两篇分别讨论了 AI Native 开发改变什么,以及 AI 为什么会把未决策的问题直接做成代码。本篇完整走一遍“删除草稿文章”的交付过程。
**案例说明:**本文的删除接口、鉴权、Vitest/Playwright 测试均为一个假想全栈项目的示意产物,不是本博客当前仓库已经存在或已经执行的功能。真实 Astro 静态博客的删除操作应通过本地 CMS/Git 工作流完成,不能把下文的 API 和测试结果当作本仓库事实。
摘要
本文用一个完整的交付实例,走完 Spec、Plan、Implement、Verify 四道闸门:给博客后台增加删除文章功能。
与前两篇的差别在于,本文提供可直接复制的产物全文 —— Spec、Plan、文本用例、Playwright 自动化用例、需求追踪矩阵、Verify 结论,以及一次示例失败的完整归因过程。
核心论点:四道闸门的作用不是增加流程,而是让每类错误在成本最低的阶段暴露。 文中每道闸门都对应拦下了一类典型错误:Spec 拦住规则未定义,Plan 拦住架构假设错误,Implement 拦住范围失控,Verify 拦住无效测试。
一句需求,为什么还不能开始写代码
需求只有一句:
给博客后台增加删除文章功能。
如果让 AI 直接执行,它可能同时修改文章列表、内容文件、构建索引和删除接口。真正的问题不是它能不能写,而是:它有没有被明确授权删除什么,以及谁能删除。
下面让这个需求依次经过四道闸门。每道闸门都回答两个问题:现在能不能继续?如果不能,缺什么?
Gate 1:Spec,先把“正确行为”写出来
初始 Spec
功能:删除文章
角色:管理员可以删除未公开(hide: true)文章;普通用户和访客不能删除。
状态:已公开(hide 为 false 或缺省)文章不能直接删除。
交互:删除前必须二次确认;用户取消时不发起请求。
安全:服务端再次校验登录状态和管理员权限。
失败:删除失败时保留列表状态,并提供重试提示。
非目标:本次不做回收站、恢复、批量删除和审计日志。Spec 准入与准出
准入:有明确用户问题、目标角色、业务场景和约束。仅有“优化一下”不能进入。
准出:
[ ] 角色和目标明确
[ ] 正常、异常和边界流程明确
[ ] 权限和数据规则明确
[ ] 每条规则可转换成测试
[ ] 非目标明确
[ ] 重要规则有 REQ 编号
[ ] 两个人可以得出相同验收结论把规则编号后,Spec 才能成为后续工作的索引:
REQ-001 管理员可以删除未公开(hide: true)文章
REQ-002 非管理员请求返回 403 且无副作用
REQ-003 已公开(hide 为 false 或缺省)文章不能直接删除,返回 409
REQ-004 取消确认不发请求
REQ-005 删除后重新构建仍不存在
REQ-006 删除失败保留页面状态如果“删除”到底是物理删除还是归档仍未决定,Gate 1 应该拒绝放行,而不是让 AI 自己选。
Spec 是仓库里的文件,不是聊天记录
这一点常被忽略:Spec 写在对话框里,下一个会话就没了;Spec 写进仓库,才能被 diff、被评审、被追溯。
specs/
article-delete/
spec.md # 上面这份,含 REQ 编号
plan.md # 下一节产出
tests.md # 文本用例
verify.md # 验证证据与结论判断标准很简单:如果换一个人(或换一个 AI 会话)接手,他能不能只靠仓库里的文件复现同样的验收结论。 做不到,说明 Spec 还停留在聊天记录里。
Gate 2:Plan,先研究当前项目再动手
AI 先阅读当前项目,而不是凭经验生成方案。对这个博客,需要确认:文章是数据库记录还是内容文件?构建时如何生成文章列表?是否存在管理端权限?删除会不会影响 Git 或索引?
一份可执行的 Plan
1. 读取内容集合 schema 和文章状态字段
2. 找到文章列表页及其数据来源
3. 找到当前鉴权入口和管理员判断方式
4. 设计删除接口,只允许未公开/已归档文章
5. 服务端校验权限和文章状态
6. 设计失败响应:401/403/404/409
7. 先生成接口文本用例,再生成自动化测试
8. 实现接口并运行接口测试
9. 增加删除按钮和二次确认
10. 增加端到端测试并运行受影响测试
11. 检查构建、Diff 和文章索引Plan 阶段拦住的一次错误
第一次分析时,AI 可能假设文章存储在数据库,计划使用 DELETE FROM articles。但代码库实际使用 Markdown 文件和 Git 管理内容。
这就是 Plan Gate 的价值:**它在代码写出来之前暴露了架构假设错误。**正确方案应结合项目架构决定:对本博客是通过本地 CMS/Git 受控地删除或改写内容文件,而不是新增不存在的归档状态。
准入:Spec 已评审,相关代码入口和构建/测试命令已找到。
准出:影响文件、执行顺序、测试计划、风险、回滚方式和每条 REQ 的映射都已明确;没有无关重构。
技术不确定时,先做 Spike 而不是硬写 Plan
上面那个“数据库还是文件”的问题,如果连人也答不上来,Plan 就写不实。这时正确动作是插入一次 Spike:用最小代价验证一个技术问题,代码写完就丢掉。
Spike 目标:确认删除内容文件后,构建能否正常生成文章列表
时间盒:不超过半天
产物:一段结论,不是一份可合并的实现
结束条件:能回答“删除后构建是否报错、索引是否残留”Spike 的输出会回流成 Spec 或 Plan 的约束条件。Spike 的代码是一次性的,Spike 的结论是永久的。 混淆这两者,就会出现「原型直接上线」这种最常见的事故。
Gate 3:Implement,限制 AI 的动作范围
Plan 审核通过后,不能再用一句“全部实现”把范围重新打开。建议按小任务执行:
任务 1:实现服务端状态和权限判断
任务 2:生成并补齐接口自动化测试
任务 3:实现前端按钮与二次确认
任务 4:生成并调试端到端测试
任务 5:运行受影响测试并检查 Diff每个任务都要求:只修改批准文件,先看 Diff,再跑对应检查;发现 Spec 与现有代码冲突时暂停。
准入:Spec 和 Plan 已批准,基线构建/测试结果已记录,依赖和环境可用,回滚方式明确。
准出:Plan 步骤完成,代码和测试都已实现,异常路径已覆盖,类型与静态检查通过,没有临时代码、未批准依赖或越界文件。
注意:Implement 准出只表示“可以正式验证”,不表示“已经正确”。
Gate 4:Verify,用证据而不是感觉交付
测试从 Spec 开始产生,Verify 阶段将它们变成证据链:
REQ-001
→ 文本用例 TC-001
→ 自动化用例 e2e/article-delete.spec.ts
→ 执行命令和测试环境
→ 日志、截图、响应和结果
→ 人工验收
→ Verify 结论文本用例示例
TC-001 管理员删除未公开文章
Given 当前用户是管理员,目标文章为未公开状态(hide: true)
When 管理员确认删除
Then 删除请求成功,文章从列表消失
TC-002 普通用户越权删除
Given 当前用户不是管理员
When 调用删除接口
Then 返回 403,文章仍然存在
TC-003 取消删除
Given 删除确认弹窗已打开
When 用户点击取消
Then 不发起删除请求,文章仍然存在
TC-004 删除已发布文章
Given 目标文章为已公开状态(hide 为 false 或缺省)
When 管理员确认删除
Then 返回 409,文章仍可访问自动化用例:从文本用例到可执行代码
TC-002(越权删除)生成的接口测试大致如下。它值得完整贴出,因为其中几个细节决定了这个测试是否真的有效:
// api/article-delete.test.ts
import { describe, it, expect, beforeEach } from "vitest"
import { createArticle, loginAs, cleanup } from "./helpers"
describe("DELETE /api/articles/:id", () => {
beforeEach(async () => {
await cleanup()
})
it("TC-002 普通用户越权删除返回 403,文章仍存在", async () => {
const article = await createArticle({ hide: true })
const token = await loginAs("normal_user")
const res = await fetch(`/api/articles/${article.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
expect(res.status).toBe(403)
// 关键:不能只断言状态码,必须验证副作用没有发生
const stillThere = await fetch(`/api/articles/${article.id}`)
expect(stillThere.status).toBe(200)
})
it("TC-004 已公开文章不可删除,返回 409", async () => {
const article = await createArticle({ hide: false })
const token = await loginAs("admin")
const res = await fetch(`/api/articles/${article.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
})
expect(res.status).toBe(409)
const stillThere = await fetch(`/api/articles/${article.id}`)
expect(stillThere.status).toBe(200)
})
})两处注释标出的断言是这个测试的价值所在:只断言 403 无法区分“被正确拒绝”和“接口报错了但文章已被删”。 补上副作用检查后,测试才真正覆盖了 REQ-003。
对应的端到端用例覆盖交互层:
// e2e/article-delete.spec.ts
import { test, expect } from "@playwright/test"
test("TC-003 取消确认不发起删除请求", async ({ page }) => {
await page.goto("/admin/articles")
let deleteCalled = false
page.on("request", (req) => {
if (req.method() === "DELETE") deleteCalled = true
})
await page.getByTestId("article-row-unpublished-1").getByTestId("delete-btn").click()
await expect(page.getByTestId("confirm-dialog")).toBeVisible()
await page.getByTestId("confirm-cancel").click()
// 断言「什么都没发生」,比断言「弹窗关了」更接近 Spec 原意
expect(deleteCalled).toBe(false)
await expect(page.getByTestId("article-row-unpublished-1")).toBeVisible()
})这里用监听网络请求来验证 REQ-004,而不是只检查弹窗关闭。Spec 写的是“取消时不发起请求”,测试就应该断言请求没有发生,两者要逐字对应。
选择器全部使用 data-testid 而非文案或 CSS 类,原因是后者会在样式调整或文案修改时莫名失败,制造 flaky 假象。
生成后必须做的三步
AI 可以根据文本用例生成 Playwright、Vitest 或接口测试,但生成后不能直接信任。至少要经过三步:
- 调试:检查选择器、登录态、fixture、测试数据、等待条件和清理逻辑;失败时保留请求响应、截图、视频或 trace。
- 执行:先跑单条用例,再跑受影响测试,最后跑完整回归;区分代码失败、断言失败、环境失败和 flaky test。
- 审查:确认测试验证的是用户可观察行为,而不是某个私有函数或实现细节;确认断言没有被放宽到“页面没崩”。
例如,若测试只断言“接口返回 204”,还没有证明文章真的消失,也没有证明普通用户不能越权。因此需要同时检查状态变化、权限和页面结果。
分层执行:不是每次都跑全量
写代码时 → 只跑当前改动相关的单元与接口测试(秒级)
提交前 → 跑受影响模块的完整测试 + 类型与静态检查(分钟级)
合并前 → 跑完整回归,包含 E2E(可以接受十几分钟)
发布前 → 冒烟用例 + 人工验收关键路径分层的目的是让反馈快到能被真正使用。每改一行就跑二十分钟全量回归,结果是没人跑测试。
一次真实的失败与归因
这次交付里,TC-004(删除已发布文章应返回 409)第一次执行就失败了,实际返回 204,文章被删掉了。
第一反应是“权限判断写错了”,但归因结果不是:
现象:已公开文章被成功删除,返回 204
排查:服务端确实有状态校验,读取的是 article.status
根因:内容文件里控制公开与否的字段是 hide 布尔,
并没有 status 字段,article.status 为 undefined,
undefined 不等于 "published",校验被绕过这个失败该回到哪一层?不是 Implement,是 Spec。Spec 里写的“已发布文章不能直接删除”隐含假设了存在一个 status 字段,而项目实际用的是布尔 hide 标记。规则描述与数据模型不一致,代码只是忠实执行了一个错误前提。
修复顺序因此是:先补 Spec 里的状态定义(hide: true 为不公开,无该字段或 false 为已公开),再改 Plan 中的判断依据,最后才改代码。如果直接在代码里打补丁,Spec 和实现的偏差会留在仓库里,下一次有人按 Spec 改代码就会再踩一次。
需求追踪矩阵
Verify 的最终产物不是一句“测试通过”,而是一张能逐条对账的表:
| REQ | 规则 | 文本用例 | 自动化 | 状态 |
|---|---|---|---|---|
| REQ-001 | 管理员可删未公开文章 | TC-001 | article-delete.spec.ts | 通过 |
| REQ-002 | 非管理员请求返回 403 且无副作用 | TC-002 | api/delete.test.ts | 通过 |
| REQ-003 | 已公开文章不可直接删除 | TC-004 | article-delete.spec.ts | 通过(修 Spec 后) |
| REQ-004 | 取消不发请求 | TC-003 | article-delete.spec.ts | 通过 |
| REQ-005 | 删除后重新构建仍不存在 | TC-005 | — | 人工验收 |
| REQ-006 | 删除失败保留页面状态并提示错误 | TC-006 | — | 人工验收 |
| REQ-007 | 目标不存在返回 404,重复删除无额外副作用 | TC-007 | api/delete.test.ts | 通过 |
空缺和人工项都要显式写出来。表里出现“—”不可怕,可怕的是表里没有这一行。
flaky 测试:必须当作缺陷而不是噪音
自动化跑起来之后,最消耗信任的不是失败,而是时而失败。一旦团队开始习惯性重跑,自动化的判定能力就归零了 —— 因为没人再相信红色意味着有问题。
常见根因与对应处理:
| 症状 | 根因 | 处理 |
|---|---|---|
| 偶发元素找不到 | 用了固定 waitForTimeout | 改为等待具体状态或响应 |
| 单跑通过、并行失败 | 用例间共享测试数据 | 每个用例自建数据并清理 |
| 换文案后失败 | 选择器依赖文案或 class | 改用 data-testid |
| 跨天失败 | 断言依赖当前时间 | 注入固定时间或用相对断言 |
| 首次运行失败、重跑通过 | 依赖前序用例留下的状态 | beforeEach 重置 |
处理原则建议写成硬规则:
flaky 测试的处理顺序:
1. 定位根因并修复(默认动作)
2. 无法立即修复时,标记 skip 并建缺陷单,
同时在 Verify 报告中显式列为「未覆盖项」
3. 禁止的做法:加重试次数掩盖、删掉断言、直接删测试第 2 条的要点是降级要留痕。跳过一个用例本身可以接受,但它必须出现在需求追踪矩阵里变成一个可见的空缺,而不是无声消失。
闸门的度量指标
闸门是否真的在工作,需要能被观察。以下指标可按迭代统计:
| 指标 | 含义 | 健康信号 |
|---|---|---|
| Gate 1 拒绝率 | Spec 评审退回比例 | 明显大于零;长期为零说明评审是橡皮章 |
| 缺口发现阶段分布 | 问题在哪个阶段被发现 | 重心持续左移 |
| Spec 后变更率 | Spec 批准后又改规则的比例 | 逐步下降 |
| 计划外文件修改数 | 超出 Plan 范围的改动 | 趋近于零 |
| 空实现测试通过数 | 无效测试数量 | 应为零 |
| 逃逸缺陷数 | 流程未拦住、生产才暴露 | 持续下降 |
| 重跑率 | 因 flaky 重跑的比例 | 低于 1% |
其中最有诊断价值的是 Gate 1 拒绝率 和 缺口发现阶段分布。前者说明闸门是否在真正判断,后者说明整套流程是否达到了它唯一的目的 —— 让错误更早被发现。如果几个迭代下来,问题重心仍然停在测试阶段和生产环境,那么流程虽然跑了,收益并没有产生。
一个提醒:这些指标用于团队自检,不适合作为个人考核项。 一旦拿来考核,Gate 1 拒绝率会立刻被优化成一个漂亮的数字,而实际判断质量会下降。
Verify 准出
[ ] 每条 REQ 都有文本用例和自动化或人工证据
[ ] 类型、静态、单元、接口和关键 E2E 通过
[ ] 权限、状态、失败和边界场景通过
[ ] 失败、跳过和未执行项有明确解释
[ ] 人工验收通过
[ ] 验证命令、环境和版本可复现
[ ] 已知限制已记录四道闸门如何减少返工
没有闸门时,错误可能在上线后才暴露:
需求模糊 → AI 猜测 → 改十几个文件 → 测试只测 happy path → 线上误删有闸门时,错误尽量在更便宜的地方被发现:
Spec Gate:发现删除规则未定义
Plan Gate:发现存储方式被误判
Implement Gate:发现越界修改或缺少权限测试
Verify Gate:发现自动化只测返回码闸门不是为了让流程更重,而是为了避免用实现成本解决本应在需求阶段解决的问题。
失败后回到哪里
业务规则矛盾或漏写 → 回到 Spec
技术方案不可行或影响范围错误 → 回到 Plan
代码行为不符合已批准方案 → 回到 Implement
选择器、fixture 或断言错误 → 回到测试设计
环境、数据或登录态不稳定 → 回到测试执行
证据不足 → 不能准出 Verify不要把所有失败都叫作“代码 bug”。一次失败的归因,本身就是软件生产知识的沉淀。
归因决策树
上面的对照表在实际排查时不够用,因为难点是判断顺序。按下面的顺序提问,能避免最常见的误判 —— 直接跳到改代码:
测试失败
│
├─ 1. 这条测试断言的行为,Spec 里写了吗?
│ 没写 → Spec 缺口,回到 Gate 1(不要改代码)
│ 写了但自相矛盾 → Spec 冲突,回到 Gate 1
│
├─ 2. Spec 的描述与真实数据模型一致吗?
│ 不一致 → Spec 前提错误,回到 Gate 1
│ (本文 TC-004 属于这一类)
│
├─ 3. Plan 的方案能实现这条 Spec 吗?
│ 不能 → 方案缺陷,回到 Gate 2
│ 影响范围判断错 → 回到 Gate 2
│
├─ 4. 代码行为与已批准的 Plan 一致吗?
│ 不一致 → 实现缺陷,回到 Gate 3
│
├─ 5. 测试本身写对了吗?
│ 选择器、fixture、断言、等待条件错 → 回到测试设计
│ 断言弱于 Spec 原意 → 回到测试设计
│
└─ 6. 环境、数据、登录态稳定吗?
不稳定 → 回到测试执行,按 flaky 处理第 2 步是实践中最容易被跳过的一环,而它恰好是本文那次失败的答案。默认思维是“测试失败等于代码写错”,于是直接在代码里补一个判断,Spec 与实现的偏差就永久留在了仓库里。
一条经验法则:如果修复动作是“在代码里加一个 Spec 没提到的判断”,那么真正该改的是 Spec。
归因结论要沉淀成什么
一次归因不应该只留在聊天记录或工单评论里。建议固定沉淀成两处:
specs/article-delete/verify.md
→ 记录本次失败、根因、归因层级、修复顺序
docs/pitfalls.md
→ 记录可复用的教训,供后续所有功能引用本次归因写进 pitfalls 的条目是:
## 内容模型的状态表达
本项目文章公开状态用 frontmatter 的 hide 布尔字段表达,
不存在 status 字符串字段。
编写涉及文章状态的 Spec 时,必须使用 hide: true/false 表述,
不得引入 status 概念。
历史事故:曾因 Spec 写「已发布不可删除」而实现读取
article.status,值为 undefined 导致校验被绕过。这条笔记的价值在于它能被下一次的 Spec 起草直接消费 —— 尤其当起草者是 AI 时。踩坑笔记是把一次性教训转成持续约束的最低成本手段,这也是下一篇要展开的上下文工程主题。
如何在现有项目里落地这套流程
一次性把全流程铺开通常会失败。推荐按四步推进,每步都有明确的退出条件:
第一步:只做 Verify(一到两周)。 不改变现有开发方式,只要求每次变更提交验证证据与需求追踪矩阵。这一步几乎不增加阻力,却能立刻暴露测试基建的真实水平。退出条件:关键路径能产出可复现证据。
第二步:加入 Spec(两到四周)。 只对高风险变更要求 Spec(权限、删除、资金、迁移),其余照旧。退出条件:Spec 平均质量分达到 7 分以上,且拒绝率不为零。
第三步:加入 Plan 与范围约束(两到四周)。 要求 AI 先研究代码库产出 Plan,实现阶段不得超出批准文件范围。退出条件:计划外文件修改数趋近于零。
第四步:按模块调整自主级别。 覆盖率与稳定性达标的模块提升到 L3,高风险模块保持 L2。退出条件:逃逸缺陷数持续下降。
顺序刻意从 Verify 开始而不是从 Spec 开始,原因有两个:Verify 的改造成本最低、见效最快,容易建立团队信心;而没有证据能力时,Spec 写得再好也无法闭环 —— 闸门会退化成签字。
结语
从一句需求到一次可靠交付,中间不是多写几份文档,而是不断把猜测变成决定,把决定变成实现,把实现变成证据。
Spec 决定方向,Plan 控制范围,Implement 受控落地,Verify 证明结果。
附录:Verify 报告模板
可直接放入 specs/<feature>/verify.md,本次交付的填写示例如下:
# Verify 报告:删除文章
## 结论
通过,可交付。含 1 项人工验收、0 项未覆盖。
## 需求追踪矩阵
| REQ | 用例 | 自动化 | 结论 |
| REQ-001 | TC-001 | e2e/article-delete.spec.ts | 通过 |
| REQ-002 | TC-002 | api/article-delete.test.ts | 通过 |
| REQ-003 | TC-004 | api/article-delete.test.ts | 通过(修 Spec 后) |
| REQ-004 | TC-003 | e2e/article-delete.spec.ts | 通过 |
| REQ-005 | TC-005 | — | 人工验收通过 |
| REQ-006 | TC-006 | — | 人工验收通过 |
| REQ-007 | TC-007 | api/article-delete.test.ts | 通过 |
## 执行环境(假想全栈项目示例)
提交:<commit sha>
Node 22.x / pnpm 9.x / Playwright 1.4x
命令:pnpm test:api && pnpm test:e2e && pnpm build
构建结果:构建成功(示意)
## 失败与归因记录
TC-004 首次失败:已公开文章返回 204 而非 409
根因:Spec 假设存在 status 字段,实际为 hide 布尔标记
归因层级:Spec(非 Implement)
修复顺序:补 Spec 状态定义 → 更新 Plan 判断依据 → 修改实现
已沉淀至 docs/pitfalls.md
## 有效性自检
空实现测试:替换为空实现后 5 条用例全部失败(预期)
计划外文件修改:0
## 已知限制
- 未实现回收站与恢复(Spec 非目标)
- 未实现批量删除(Spec 非目标)
- 无审计日志,删除操作不可追溯操作人(建议下一迭代)模板里最值得保留的两栏是 失败与归因记录 和 已知限制。前者让流程改进有依据,后者让下一个接手的人知道边界在哪 —— 两者都是“测试通过”这三个字无法承载的信息。
下一篇,我们讨论上下文工程:如果 AI 不理解项目的目录、架构和历史决策,再好的 Spec 也可能被错误实现。
