作者跳过规格说明书直接用 AI 构建系统,数月后发现跨租户安全漏洞和大量死代码——根源不是 AI 差,而是从未定义清楚"完成"的标准。
TL;DR:我跳过了写规格说明书,直接上线了一个跨租户安全漏洞,然后靠一次额外的数据库查询修复了它。这件事让我明白了一个道理——AI 只会按你说的做,不会按你心里想的做。
我搭建这个校园管理平台的方式,和大多数人第一次用 AI 做事的方式一模一样:打开 Gemini Antigravity,让它帮我建一个校园管理系统。没有规格说明书,没有计划,只有一个想法和一条提示词。然后加一个功能,再加一个功能。
它跑起来了。这就是问题所在。它跑得足够好,好到我几个月都没有停下来问一问,所谓"跑起来了"到底意味着什么。
当我终于坐下来和 Claude 一起认真审视我写的东西——不是往上面加功能,仅仅是看它——我发现了安全漏洞、性能问题、扩展性问题,还有一些从第二周开始就悄无声息烂在那里的死代码。这些问题都不是因为用到的 AI 不擅长写代码,而是因为我从来没有告诉它"完成"需要包含什么。
我是一个独立开发者,不是什么高级工程师。我这点本事是在 Flincap 和 Venturage 实习时捡的,再加上自己动手做东西,包括这个平台,很多都是用硬核的方式摸索出来的。这篇文章要说的是我从"AI 写的代码能跑"到"AI 写的代码实际上可以安全上线"之间的差距中学到的东西,以及为什么这个差距需要我来填,而不是 AI 来填。
让这一切变得真实的那个 bug
来一个具体的。平台某处有一条路由,允许老师或管理员更新考试题目。我让 AI 构建"一种编辑 CBT 题目的方式",一个合理的需求,得到了能跑的代码,然后就去搞下一个功能了。
几个月后,和 Claude 一起做安全审查时,我发现这条路由在检查正确的层级,但检查的层级不对。它确认了正在编辑题目的人拥有他们所在的考试,但没有检查被更新的那道题目是否真的属于那个考试。所以只要你有一个有效的问题 ID,哪怕来自平台上完全不同的另一所学校,你也可以通过自己的考试完成授权,然后越界去覆盖一道不属于你的题目。
据我所知没有人利用过这个漏洞。但事后回过头来看,一旦理解了到底发生了什么,这个失败就完全说得通了:我从来没有告诉 AI 题目需要被限定在它们的考试范围内。我告诉它去构建"编辑"功能,它就构建了一层编辑的所有权检查,检查通过,能工作。它不会自己推理到"还要在更深一层做这道锁",因为我没有要求它这么做,而它也没有办法知道我没说出口的那些。
这就是一个 bug 里藏着的全部教训。AI 失败不是因为它粗心,而是因为它很实在:你递给它什么问题它就解决什么问题,不是你递给它时脑子里想的那个问题。这两者之间的差距,恰好就是规格说明书存在的地方,也是我当初跳过的部分。
现在已经修复了:路由在执行任何更新之前,会先验证这道题目确实属于它被授权对抗的那个考试。很小的检查,但它之所以存在,是因为有人——这里是 AI 在审查它自己犯的错误——在事后而不是事前找到了它。
把真实名称和路径都剥离掉,模式大致是这样的。修复前:
// checks that the caller owns the exam...
const exam = await db.exam.findFirst({
where: { id: examId, ownerId: userId },
});
if (!exam) throw new ForbiddenError();
// ...then updates a question by raw id, with no check
// that the question actually belongs to that exam
await db.question.update({
where: { id: questionId },
data: updates,
});
修复后:
const exam = await db.exam.findFirst({
where: { id: examId, ownerId: userId },
});
if (!exam) throw new ForbiddenError();
// the missing link: verify the question belongs to
// the exam we just proved the caller owns
const question = await db.question.findFirst({
where: { id: questionId, examId: exam.id },
});
if (!question) throw new ForbiddenError();
await db.question.update({
where: { id: questionId },
data: updates,
});
就多了一次 findFirst 调用。这就是整个修复方案。难点从来不在写它,而在于知道要一开始就提出这个要求。
我现在怎么做
我以前以为工具才是最重要的——只要找到对的 AI 编程助手,它就能 catch 到我遗漏的东西。我用过 Codex、Gemini Antigravity、Claude,还有(短暂地)Warp。每个工具的失败模式都一样,这让我确信这不是工具问题。工具的选择改变的是上限——一个好的 agentic 模式或者更大的上下文窗口能自己 catch 到多少——但它替代不了下限,也就是你真正去具体规定需要什么为真。
所以变化在这里:
我在写提示词之前先把技术需求写下来,不一定是什么正式的规格说明书,但要对"这需要处理什么?"给出真实的答案,而不只是"这需要做什么?"对于 CBT 题目路由来说,区别在于"允许教师编辑题目"和"允许教师编辑他们拥有的、限定在他们拥有的考试范围内的题目,拒绝任何不匹配的请求"。AI 会完全按照你描述的版本去构建。它不会构建你以为"不言自明"的那个版本。
我也尽量保持代码库本身可读——分解良好的函数、清晰的命名、最少的嵌套嵌套:AI 在能真正读懂已有代码时写得更好。杂乱的现有代码库会在上面叠加更杂乱的建议。可读性不只是为了下一个接触你代码的人类。它也是你的 AI 工具每次在现有项目里写提示词时会读取的上下文。
这些都没让我变成专家。我仍然不知道我做的算不算教科书意义上的"规格说明书优先的提示词",管他什么教科书。但我知道跳过它的代价是多少,因为我搭建这个平台时付过这个代价,我宁愿把它付在一份规格说明书上,也不愿意付在一次生产环境安全审计上。
你在提示词之前会写多小的规格说明书,或者你也会跳过它?