深入版:速查版给了方法(四步验收法、危险信号清单),这一篇讲「为什么必须审」和「具体怎么审」:AI 补全代码的机制、怎么按项目类型找到入口、怎么追一条完整调用链、依赖的三层追法、N+1 怎么自己查出来、异常处理的三种坏味道、以及测试怎么从 0 到 1。
全部结论基于实测扫描,不美化:
13 个有效项目(package.json 命中 18 个,对应 13 个独立项目),其中 36 个 Express 包全部由 AI 生成。技术分布:express 85、react 85、vue 74、fastapi 64、ffmpeg 67、better-sqlite3 26、sharp 24、jsonwebtoken 9、zod 9、drizzle-orm 7、playwright 1.63;Vite 有 5/6/8 三个版本,Tailwind 有 3 和 4;tRPC 只在2 个项目里用——delimao-system 和 2026-08-09-task-100 的 shower-order-p3。
*.test.* / *.spec.* 全盘 0 命中。唯一的例外是 delimao-system 的 packages/engine/test/run.ts——用 node:assert 手写了 6 个型号的断言,npm run engine:test 一条命令、不起数据库不起服务就能跑完。这6 个断言就是这一篇第 10 节要讲的起点。一句话:它会补全它认为「该有」的代码,而不是你项目真实需要的代码。所以它假设了很多没跟你确认的前提。
AI 写代码时,它看到的是一个片段——你的几句描述、加上现有的几个文件。它会按「一个规范的、完整的、教科书式的实现」去补全。问题是:「教科书式的完整」不等于「你这个项目需要的完整」。
| AI 会自动补上的 | 它没问你的 | 你项目里的真实后果 |
|---|---|---|
| 参数校验(类型、必填、范围) | 业务上什么算合法 | 生产 400 单 97% 无尺寸——类型校验全过了,业务照样算不出价。因为「有没有尺寸」不是类型问题,是业务规则 |
| 错误处理(try-catch + 提示) | 出错时该崩还是该兜住 | 它默认兜住并提示。结果错误被吞掉,没有人知道发生过 |
| 默认值 / 兜底逻辑 | 没有数据时该怎么办 | 它编了一个默认值。那个默认值从哪来?只有它知道——而它不知道,因为那是个业务决策。 |
| 一个完整的字段集合 | 这个字段实际会填吗 | collections 用单个 JSON blob 存了一堆东西,结果写坏一次就整条报废 |
入口就是「这个项目从哪开始跑」。找到入口,才有地方开始读。不找入口直接全文搜索,是最慢的方式。
package.json 的 scripts这一步直接告诉你项目怎么启动。你要找的是 dev / start / main 这几个字段。很多项目的 README 会过期,但 scripts 一般不会——以 scripts 为准。
"scripts": {
"dev:server": "npm run dev -w @delimao/server",
"db:migrate": "npm run db:migrate -w @delimao/db"
}比如 tsx watch src/index.ts——入口就是 src/index.ts。注意这里有个细节:-w @delimao/server 表示「在 @delimao/server 这个包里执行」,所以要先进 apps/server/ 目录,入口是 apps/server/src/index.ts,不是根目录。
app.use / app.get)在入口文件里搜 app.use 和 app.get。这一屏就是整个项目的骨架地图:有哪些中间件、哪些路由。你会立刻看出「这是 REST 路由」还是「这是 tRPC 挂载」。
// apps/server/src/index.ts 大致长这样
app.use('/trpc', trpcRouter) // ← 所有接口都在这一个挂载点
app.use(express.json()) // ← 中间件:解析请求体
app.listen(3000) // ← 端口在这package.json 入口 +框架线索有 vite 就是 Vite 项目。找到 vite.config 里的别名配置(@ 指向哪)——这能省掉大量猜路径的时间。
React 看 main.tsx → 里面引用的路由配置;Vue 看 router/index.ts。路由配置就是这个前端有哪些页面的完整清单——不用一个个文件翻。
路由表里写着 path: '/bt/' 指向哪个文件——那个文件就是这个页面的入口。顺着它再找它调用的 API,就接上了后端那条线。
apps/server/src/index.ts(入口)→ tRPC 的 routers/index.ts(所有接口在这挂载)→ packages/db/src/schema.ts(表结构)。前端 apps/web 走 tRPC client,方法名和后端 router 名一一对应,所以追起来很顺。2026-08-09-task-100):同样用 tRPC adapter。你全盘只有这 2 个项目用 tRPC,但它们恰好是最好追的——原因见下一节。trpc.engine.quote.useQuery(),后端 router 就叫 engine.quote。名字一样,搜一下就找到了。用传统 fetch 时,前端写的是路径字符串 '/api/quote',后端可能挂在任何地方,全靠猜。这是「类型即契约」在追代码上的实际价值——不只是编译期报错,而是让人能顺着名字读下去。以「查一个型号的报价」为例——这是你 delimao-system 里最核心的功能。追完这六站,你就懂了这个项目最值钱的那部分。
trpc.engine.quote.useQuery({ modelNo })。注意它只知道「有个叫 engine.quote 的接口」,完全不知道后端怎么实现。这一站的关键词是「名字」——记住这个名字,下一站直接搜它。/trpc,请求头带上 authorization: Bearer <JWT>。关键:它怎么知道这个接口该收什么参数?——类型是从 server直接 import 过来的,不是手写的。所以参数写错了在前端就编译报错,根本发不出去。/trpc/* 交给 tRPC 路由,JWT 校验通过(用的是 jsonwebtoken 9),然后找到 engine.quote 这个 router 函数。这一站能看到两件事:输入参数被 zod 校验了没有(你项目里有 9 处用到 zod),以及它调了哪个 service。dependencies 为空。所以它能被单测(node:assert 手写 6 个型号的断言就是测这一层)。@delimao/db,用 drizzle-orm 0.38 + @libsql/client 连库(你项目里 drizzle-orm 用了 7 处)。db 只负责存取,不含任何业务判断——这个分层是你 monorepo 设计的功劳,好处是「算价逻辑」可以脱离数据库单测。依赖是你看不懂但正在运行的代码。追它只需要三层,每层一个命令。
package.json 声明了什么这是它的「身份证」:叫什么名字、什么版本、哪个依赖类型。要看的字段:name、version、main/exports(入口在哪)、dependencies vs devDependencies(后者只在开发时用,不会进线上——这是安全上的重要区别)。
node_modules 里它实际做了什么这一层是绝大多数人跳过的,也是最值钱的。一个包 「声明了什么」不等于「它实际做了什么」——要看它真正被 require 的那个入口文件。
# 看它的目录结构 ls node_modules/<包名>/ # 看它的入口文件是谁(从 package.json 的 main / exports 读出来) cat node_modules/<包名>/package.json # 直接看它的入口文件在干什么 head -50 node_modules/<包名>/<入口文件>
搜你在哪些文件 import 了它,看它在你项目里承担的角色。比如 drizzle-orm 用了 7 处——那 7 处就是它在你系统里的全部影响面。范围明确,才能判断出问题时会影响什么。
better-sqlite3 26 处 + drizzle-orm 7 处已经覆盖了数据需求);②它能不能被替换(如果哪天它挂了,你要多久能换掉);③它的漏洞历史(见第 09 节)。不是为了省事,是为了保持「系统是可读懂的」。N+1 = 本该一次查完的东西,代码在循环里查了 N 次。名字里的 N 是「条数」。这是 AI 生成代码里最常见、也最隐蔽的性能问题——因为它在数据少的时候完全看不出来。
// ✗ 坏:3 条数据 = 1 + 3 = 4 次数据库往返
const orders = await db.select().from(orders) // 1 次
for (const order of orders) {
// ✗ 在循环里 await 查询!
const items = await db.select().from(items)
.where(eq(items.orderId, order.id)) // 每条1 次
order.itemCount = items.length
}
// ✓ 好:2 次查询,用一条 IN 把所有 id 打包查回来
const orders = await db.select().from(orders) // 1 次
const ids = orders.map(o => o.id)
const allItems = ids.length
? await db.select().from(items).where(inArray(items.orderId, ids)) // 1 次
: []
const countMap = new Map()
for (const it of allItems) {
countMap.set(it.orderId, (countMap.get(it.orderId) ?? 0) + 1)
}
for (const order of orders) {
order.itemCount = countMap.get(order.id) ?? 0
}
for/forEach/map,看循环体里有没有 await 加上一个查库函数。看到就警惕。IN 查回来,然后在内存里对。改完查询次数从 N+1 降到 2,而且数据量再大也不会更慢。异常处理是 AI 生成代码里问题最集中的地方,因为它「看起来处理了」。三种坏味道,按危险程度排。
catch {} 空吞——最危险// ✗ 吞掉异常:出错了,但我假装没发生
try {
await saveOrder(order)
} catch (e) {
}
// ✗ 只记个日志就完事:记了,但没恢复,也没往下报
try {
await saveOrder(order)
} catch (e) {
console.log('出错了', e)
}
// ✓ 至少要说清楚发生了什么,并让它能被注意到
try {
await saveOrder(order)
} catch (e) {
logger.error('保存订单失败,orderId=' + order.id, e)
throw new Error('保存订单失败,请稍后重试', { cause: e })
}
为什么空吞最危险:它把「崩溃」变成了「静默失败」。崩了你会看到界面报错、会去查;吞掉了,程序继续往下跑,然后返回一个错的结果——你可能一个月后在客户对账时才发现数据没存上。
// ✗ 调用方以为成功了,其实拿到的是假数据
async function getPrice(modelNo) {
try {
return await calcPrice(modelNo)
} catch (e) {
return 0 // ← 0 会被当成「 legitimately 价格为 0」
}
}
// ✓ 要么明确失败,要么明确说「这是默认值」
async function getPrice(modelNo) {
try {
return await calcPrice(modelNo)
} catch (e) {
logger.error('算价失败,modelNo=' + modelNo, e)
throw new Error('算价失败') // ← 明确失败,让调用方知道
}
}
SQL 注入 = 把用户输入直接拼进 SQL 字符串里。攻击者可以通过输入内容,改变这条SQL 的含义,从而读到或改掉他不该碰的数据。
// ✗ 危险:字符串拼接
const sql = "SELECT * FROM users WHERE id=" + id
const sql2 = `SELECT * FROM orders WHERE name='${name}'`
// 如果 name 是:' OR '1'='1
// 拼出来是:SELECT * FROM orders WHERE name='' OR '1'='1'
// → 条件永远成立 → 整表数据全被读出来
// ✓ 安全:参数化查询(占位符 + 单独传值)
const rows = await db.execute(
sql`SELECT * FROM users WHERE id = ${id}`
)
// 或者用 ORM / query builder,让参数自动被转义
const rows = await db.select().from(users).where(eq(users.id, id))
delimao-system 里用了 tRPC + zod(zod 全盘 9 处)。zod 在入口处就把参数校验了——类型不对、字段不对的请求根本进不到数据库那一层。加上 drizzle-orm 7 处是 query builder,它的模板语法天然是参数化的。所以tRPC + zod + ORM 这三层叠起来,已经帮你挡掉了大部分。db.run(sql\`...\`) 拼动态表名),zod 救不了你。"SELECT 和 ${ 出现在同一行的位置。看到就人工确认一遍。${} 出现在 SQL 模板里但包裹的是一个值而不是列名/表名」,通常就是安全的(drizzle 的 sql\`\` 模板就是这么工作的)。装依赖就是把别人写的代码放进你的系统。它现在能读你的数据、能往外发请求。所以它需要定期看一眼。
# 一条命令看全项目的已知漏洞 npm audit # 更详细(能看到每个漏洞的严重程度和影响范围) npm audit --json # 自动修复能修的(修完一定要跑一遍测试) npm audit fix
| 做法 | 具体怎么做 | 为什么 |
|---|---|---|
| 频率:每次交付前 + 每月一次 | 不是每天(每天会被噪音淹了),也不是从不。每个项目交付前跑一次,加一个月度全量扫 | 新增依赖是风险最高的那一刻——审查频率不用高,但要卡住「新增」这个动作 |
锁版本:^ vs 固定 | ^4.21.2 =「允许 4.x 的任意小版本」。4.21.2 =「就用这一个版本,不许动」 | ^ 是「我信这个大版本没问题,更新会自动帮我拿补丁」;固定是「我不敢动,出事我不负责」。对安全敏感的东西(认证、加密)用固定,对普通工具用 ^ |
| 长期不更新的依赖 | 如果一个包两年没更新了,要问一句:是它已经稳定到不需要更新,还是作者放弃了? | 后者意味着以后有漏洞也不会有人修。这时候要找替代方案,或者自己 fork 一份维护 |
你现在全盘 0 个测试文件(*.test.* / *.spec.* 一个都没有)。这是最该补的一件事,而且它没有你想象的那么难。
delimao-system 的 packages/engine/test/run.ts——用 Node 自带的 node:assert 手写了 6 个型号的断言,跑法是 npm run engine:test。没有装任何测试框架,不起数据库、不起服务器,几秒钟跑完。这已经是正确做法的核心部分。只挑一个。你项目里最好的候选是 packages/engine 里的报价计算(或者型号解析)——因为它是纯 TypeScript、零框架依赖、不碰数据库。这意味着测它不需要起任何东西,测试成本最低。
为什么必须从纯函数开始:因为它输入什么、输出什么是确定的。测试一个「输入输出确定」的函数,测试代码就是「给定这个输入,断言输出等于这个值」——一行搞定。
直接用 node:assert(Node 自带,零安装)。你已经在用了:
import assert from 'node:assert/strict'
// 断言 1:真实型号能解析出正确的关键字段
const r = parseModelNo('DL-2409-1')
assert.equal(r.series, 'DL')
assert.equal(r.width, 2409)
// 断言 2:非法型号要明确报错,而不是返回垃圾
assert.throws(() => parseModelNo('乱写的型号'))
// ↑ 这一条特别重要:它验证「异常是抛出来的」,
// 正好挡住第 06 节的坏味道 ②关键:先跑起来,再考虑好不好看。不要等学完测试框架再开始——一个能跑的笨测试,价值远高于一个没写的漂亮测试。
这是从 0 到 1 唯一真正可持续的路径。你改了 bug,先写一个测试证明「改之前它是错的」,再改代码,再跑一遍证明「现在对了」。这个测试就留下来,以后每次改动都会自动验证这条不会退化。
为什么这一条最关键:因为它不需要你额外花时间做测试——你本来就要改 bug,多花五分钟把「下次不会再犯」这件事固定下来而已。
用 -w @delimao/server 这种包名执行命令时,实际工作目录是那个包,不是根目录。你在根目录搜 src/index.ts 可能什么都搜不到,因为文件在 apps/server/src/ 里。对策:先确认 cwd(当前目录)是什么,再决定搜哪里。
README,不读 scriptsREADME 会过期,scripts 一般不会。AI 生成的项目里,README 经常是「看起来很完整但命令是编的」。以 scripts 为准。
追到第三站发现「怎么这么绕」,就改成全文搜索。那你永远不知道是哪一环断了。链子的价值就在于它完整——断在哪一环,就是问题所在。
node_modules 里的大文件劝退有些包几十万行代码,从头读是浪费时间。你只需要看它的入口文件和README——看它的「对外承诺」是什么,而不是它的内部实现。大多数包你永远不需要读源码,只需要知道它对外做什么。
这是这一篇最重要的一条。
AI 会非常自信地告诉你「我已经加了测试,覆盖了主要路径」——它可能只是「打算加」,或者测试文件里只有一个空的 describe,或者测试跑起来全是空的断言(什么都不检查,直接通过)。
你必须亲眼看到三样东西:① 测试文件真实存在(不是它说的,是你在磁盘上看到的);② 里面有真实的断言(有具体的输入和具体的期望值,不是空跑);③ 你把它改坏,它真的会失败——最后这条是唯一的验证方式:你故意把被测的那行代码改错,跑一次测试,看到它红了,才算确认这个测试是有用的。
你项目现状就是最好的例子:测试 0 个。如果当初AI 说「已测试」,你信了,那这个数字会比你以为的更难发现。所以这条红线不是理论问题,是你已经遇到过的模式。
-w @delimao/server 的工作目录是那个包,不是根目录npm audit 怎么用、^ vs固定版本的区别、单项目内部版本要统一packages/engine/test/run.ts 的 6 个型号断言已经是正确起点,不用等学完框架先说机制,因为机制决定了为什么要审。AI 生成代码时会补全它认为「该有」的代码——校验、异常处理、默认值、边界情况,但这些都是它猜的,不是你要求的。它猜的东西看起来很专业,因为那些代码在它自己的假设下完全正确;问题是那些假设从来没被确认过。所以我审查的本质不是「找 bug」,是「把它的假设一条条挖出来」。
我的教训最典型的两个。一是生产 400 单里 97% 无尺寸、引擎算不出价——因为「有没有尺寸」是类型上完全合法、业务上完全无效的数据,zod 校验挡不住,只有业务规则能挡。二是collections 用单个 JSON blob 存东西,写坏一次整条报废——AI 为了快把一堆东西塞进一个字段,它不知道那个字段其实没有约束、没有部分更新、没有事务保护。
方法上,我分两层。第一层是四步验收法:用大白话复述这个功能要干什么(说不出来就说明需求本身没搞清,这时候看代码是浪费时间)→ 找入口 → 只追一条线 → 问三个问题。第一步和第三步最关键:一条完整的调用链胜过十处泛读,因为一条线能让你发现「哪一环是猜的」,泛读只能让你知道有哪些文件。
以 delimao-system 的报价为例,六站:前端组件调 trpc.engine.quote.useQuery → tRPC client 发 HTTP 并带 JWT → server router 校验 JWT 和 zod → engine 算价(纯 TypeScript、零依赖) → db 查价格表 →结果返回。这一条线走完,97% 无尺寸的问题就定位到了 engine 那一站——它需要一个尺寸才能算价,而输入没有。所以「它假设了什么」这个问题的答案就很具体了:它假设型号存在、假设有尺寸、假设价格表是全的,这三条假设每一条都可能是业务里的真实例外。
第二层是一张危险信号清单——这不需要会写代码,看到形式异常就能判断:单字段塞整个 JSON、注释写着 TODO/暂时/先这样、try-catch 里什么都不做、拼 SQL、密码硬编码在前端、循环里查库、列表没分页、状态用魔法数字、一个测试也没有、依赖锁死老版本。我的原则是:不阻止这些代码上线,而是知道哪里要问一句。
具体技术上我会盯三类问题。第一类是 N+1 查询——本该一次查完的,代码在循环里查了 N 次。它在小数据测试时永远看不出来:100 条数据是 101 次查询约 200 毫秒,你完全没感觉;10 万条就是 10 万次查询约 200 秒,直接卡死。而且它只会在客户用了几个月之后才爆。最可靠的发现方法不是看代码,是打开 Network 面板看同一个接口是不是请求了几十次。
第二类是异常处理的三种坏味道:catch {} 空吞、只console.log 不恢复、catch 后返回默认值。第三种最隐蔽,因为它返回的是看起来正常的数据——0 元、0 库存都是合法返回值,系统不报错,页面正常显示,只是数字错了。我的判断标准是:catch 之后有没有把「我出错了」这个信息传出去。传出去就是好的,咽下去就是坏的。这块我付过真实代价:LumenX 两天重启 482 次——482 次静默崩溃加无人发现,这就是坏味道的组合后果。出问题不可怕,可怕的是你不知道它在出问题。
第三类是 SQL 注入。这块我相对放心,因为 tRPC + zod 在入口就校验了参数,drizzle-orm 的模板语法天然参数化,三层叠起来挡掉了大部分。但仍然要盯三处:绕过 ORM 直接写的字符串 SQL(zod 救不了)、动态排序字段和表名(列名不能参数化,必须白名单映射)、以及模板里同时出现 ${ 和列名的地方。
依赖审计我的做法是:每次交付前 + 每月一次 npm audit;卡住「新增依赖」这个动作比提高频率更有效;安全敏感的(认证、加密)锁固定版本,普通工具用 ^;两年没更新的包要问一句是稳定还是弃养。我项目里 Vite 有 5/6/8、Tailwind 有 3 和 4,跨度大是因为 13 个项目各自独立演进,但单个项目内部必须统一,否则构建行为会不一致。
最后是我最想改的一件事:测试从 0 到 1。我全盘零个测试文件,唯一例外是 packages/engine/test/run.ts,用 node:assert 手写了 6 个型号的断言。我的起步路径很明确,不要求上全套测试框架:第一,挑一个最关键的纯函数——就是 engine 里的报价计算,因为它零框架依赖、不碰数据库,测试成本最低;第二,用 Node 自带的 node:assert 写断言,不装任何框架,其中一定要有一条 assert.throws,专门验证「异常是抛出来的」,正好挡住刚才说的坏味道 ②;第三,每次改 bug 先补一个能复现的测试——这是唯一可持续的路径,因为它不需要额外花时间,你本来就要改 bug,多花五分钟把「下次不会再犯」固定下来而已。我不会为了覆盖率好看去测 getter/setter——我的目标是报价算错能被发现、无尺寸订单能被发现、非法型号能被发现,就这三个,比 80% 覆盖率有用。
最后一条红线我想强调:不要把「AI 说它测过了」当成测试证据。AI 会非常自信地说「已加测试、覆盖主要路径」,但它可能只是打算加,或者测试里只有一个空的 describe,或者断言根本不检查任何东西就直接通过。所以我坚持三个验证:测试文件在磁盘上真实存在、里面有真实的具体断言、而且我故意把它改坏之后它真的会失败——最后这条是唯一的验证方式。这条不是理论问题,我全盘 0 个测试这件事,本身就是这套模式的产物。
想看一句话版本、四步验收法、三个必问的问题表、十条危险信号清单、可直接复制的提问模板、15 分钟最小可行验收、三条红线,回速查版 19 · 怎么读懂 AI 写的代码。