FDE·技术基础
基础目录
速查版 · 一屏看懂
①请求旅程 ②读懂monorepo ③数据库存东西 ④并发锁事务 ⑤前端为什么能跑 ⑥读懂AI代码
深入版 · 原理到能排障
①请求旅程 ②monorepo ③数据库 ④并发事务 ⑤前端渲染 ⑥读懂AI代码

深入 · 怎么读懂 AI 写的代码

深入版:速查版给了方法(四步验收法、危险信号清单),这一篇讲「为什么必须审」和「具体怎么审」: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。

全盘最刺眼的事实:测试 0 个。*.test.* / *.spec.* 全盘 0 命中。唯一的例外是 delimao-system 的 packages/engine/test/run.ts——用 node:assert 手写了 6 个型号的断言,npm run engine:test 一条命令、不起数据库不起服务就能跑完。这6 个断言就是这一篇第 10 节要讲的起点。
01

先讲机制:AI 生成的代码到底有什么特点

一句话:它会补全它认为「该有」的代码,而不是你项目真实需要的代码。所以它假设了很多没跟你确认的前提。

它是怎么工作的

AI 写代码时,它看到的是一个片段——你的几句描述、加上现有的几个文件。它会按「一个规范的、完整的、教科书式的实现」去补全。问题是:「教科书式的完整」不等于「你这个项目需要的完整」。

AI 会自动补上的它没问你的你项目里的真实后果
参数校验(类型、必填、范围)业务上什么算合法生产 400 单 97% 无尺寸——类型校验全过了,业务照样算不出价。因为「有没有尺寸」不是类型问题,是业务规则
错误处理(try-catch + 提示)出错时该崩还是该兜住它默认兜住并提示。结果错误被吞掉,没有人知道发生过
默认值 / 兜底逻辑没有数据时该怎么办它编了一个默认值。那个默认值从哪来?只有它知道——而它不知道,因为那是个业务决策。
一个完整的字段集合这个字段实际会填吗collections 用单个 JSON blob 存了一堆东西,结果写坏一次就整条报废
这个机制讲清,「为什么必须审」就自动成立了。AI 的问题不是它写得不对——它写的那段代码在它自己的假设下是完全正确的。问题是那些假设从来没有被确认过。所以审查的本质不是「看代码有没有 bug」,是「把它的假设一条条挖出来问你」。
这也就解释了「为什么 AI 写的代码看起来特别专业却经常不能用」:它补全的是形式(有校验、有异常处理、有注释、有错误提示),缺的是内容(你的业务规则、你的数据现实、你的边界条件)。而项目能不能用,恰恰取决于后者。
02

怎么定位「入口」(按项目类型)

入口就是「这个项目从哪开始跑」。找到入口,才有地方开始读。不找入口直接全文搜索,是最慢的方式。

Node / Express 后端

1

先看 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"
}
2

顺着命令找到 main 文件

比如 tsx watch src/index.ts——入口就是 src/index.ts。注意这里有个细节:-w @delimao/server 表示「在 @delimao/server 这个包里执行」,所以要先进 apps/server/ 目录,入口是 apps/server/src/index.ts,不是根目录。

3

看它挂了什么(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)                    // ← 端口在这

React / Vue 前端

1

看 package.json 入口 +框架线索

有 vite 就是 Vite 项目。找到 vite.config 里的别名配置(@ 指向哪)——这能省掉大量猜路径的时间。

2

找路由配置文件(这是前端的「入口图」)

React 看 main.tsx → 里面引用的路由配置;Vue 看 router/index.ts。路由配置就是这个前端有哪些页面的完整清单——不用一个个文件翻。

3

路由指向哪个组件,就是那个页面的入口

路由表里写着 path: '/bt/' 指向哪个文件——那个文件就是这个页面的入口。顺着它再找它调用的 API,就接上了后端那条线。

你项目里的两个具体案例:
① delimao-system:apps/server/src/index.ts(入口)→ tRPC 的 routers/index.ts(所有接口在这挂载)→ packages/db/src/schema.ts(表结构)。前端 apps/web 走 tRPC client,方法名和后端 router 名一一对应,所以追起来很顺。
② shower-order-p3(2026-08-09-task-100):同样用 tRPC adapter。你全盘只有这 2 个项目用 tRPC,但它们恰好是最好追的——原因见下一节。
为什么 tRPC 特别好追?因为它前后端用同一份名字:前端写 trpc.engine.quote.useQuery(),后端 router 就叫 engine.quote。名字一样,搜一下就找到了。用传统 fetch 时,前端写的是路径字符串 '/api/quote',后端可能挂在任何地方,全靠猜。这是「类型即契约」在追代码上的实际价值——不只是编译期报错,而是让人能顺着名字读下去。
03

怎么追一条调用链(真实例子)

以「查一个型号的报价」为例——这是你 delimao-system 里最核心的功能。追完这六站,你就懂了这个项目最值钱的那部分。

第 1 站
前端组件(apps/web)
页面里调 trpc.engine.quote.useQuery({ modelNo })。注意它只知道「有个叫 engine.quote 的接口」,完全不知道后端怎么实现。这一站的关键词是「名字」——记住这个名字,下一站直接搜它。
第 2 站
tRPC client(在浏览器进程里)
把这句调用变成一个 HTTP 请求发到 /trpc,请求头带上 authorization: Bearer <JWT>。关键:它怎么知道这个接口该收什么参数?——类型是从 server直接 import 过来的,不是手写的。所以参数写错了在前端就编译报错,根本发不出去。
第 3 站
server router(apps/server)
Express 把 /trpc/* 交给 tRPC 路由,JWT 校验通过(用的是 jsonwebtoken 9),然后找到 engine.quote 这个 router 函数。这一站能看到两件事:输入参数被 zod 校验了没有(你项目里有 9 处用到 zod),以及它调了哪个 service。
第 4 站
engine 算价(packages/engine)
真正干活的地方:解析型号 → 出 BOM → 校验 → 出玻璃图 → 算报价。它不认 Express、不认数据库、不认 tRPC——纯 TypeScript、dependencies 为空。所以它能被单测(node:assert 手写 6 个型号的断言就是测这一层)。
⚠️ 这一站就是「97% 无尺寸」出问题的地方:它需要一个尺寸才能算价,而输入没有尺寸。这里究竟是崩了、还是返回了一个默认值?——这就是速查版里那个「为什么问数据是空的会怎样」的真实落点。
第 5 站
db 查表(packages/db)
需要价格表 / SKU 主数据时走 @delimao/db,用 drizzle-orm 0.38 + @libsql/client 连库(你项目里 drizzle-orm 用了 7 处)。db 只负责存取,不含任何业务判断——这个分层是你 monorepo 设计的功劳,好处是「算价逻辑」可以脱离数据库单测。
第 6 站 · 回程
结果原路返回,页面显示
engine 返回纯数据 → service 加工 → tRPC 序列化成 JSON → 前端 TanStack Query 拿到数据 → 组件重渲染 → 你看到报价。整条链上没有任何一步是「后端渲染页面」,前后端彻底分开。
追完这一条线,你至少能回答四个问题:①「这行为什么要这么写」——因为 engine 要能被单测,所以它不能依赖框架。②「如果数据是空的会怎样」——无尺寸进到 engine 会怎样,这是你项目 400 单 97% 无尺寸的根因位置。③「出错会怎样」——engine 抛错 → tRPC 转成错误响应 → 前端显示错误;还是 engine 返回默认值 → 前端显示一个错误的报价,你完全看不出来。④「它假设了什么」——它假设型号存在、假设有尺寸、假设价格表是全的。这三条假设,每一条都可能是你业务里的真实例外。
追链子的一个实用技巧:不要一次追五个功能。追通一条就停下来,把这一条讲给别人听(或者按第 01 节的方法问 AI 那三个问题)。你会发现追一条线的收益远大于泛读整个项目——因为一条完整的线能让你发现「哪一环是猜的」,而泛读只能让你知道「有哪些文件」。
04

读懂依赖:三层追法

依赖是你看不懂但正在运行的代码。追它只需要三层,每层一个命令。

1

第一层:package.json 声明了什么

这是它的「身份证」:叫什么名字、什么版本、哪个依赖类型。要看的字段:name、version、main/exports(入口在哪)、dependencies vs devDependencies(后者只在开发时用,不会进线上——这是安全上的重要区别)。

2

第二层:node_modules 里它实际做了什么

这一层是绝大多数人跳过的,也是最值钱的。一个包 「声明了什么」不等于「它实际做了什么」——要看它真正被 require 的那个入口文件。

# 看它的目录结构
ls node_modules/<包名>/

# 看它的入口文件是谁(从 package.json 的 main / exports 读出来)
cat node_modules/<包名>/package.json

# 直接看它的入口文件在干什么
head -50 node_modules/<包名>/<入口文件>
3

第三层:它在你代码里被怎么调

搜你在哪些文件 import 了它,看它在你项目里承担的角色。比如 drizzle-orm 用了 7 处——那 7 处就是它在你系统里的全部影响面。范围明确,才能判断出问题时会影响什么。

一个值得说的观察:你项目的依赖结构其实是健康的。全盘 0 个 Redis、0 个消息队列、0 个 WebSocket、0 个 MySQL/PostgreSQL、0 个图表库(recharts/chart.js/echarts/d3)、0 个 antd、0 个 Dockerfile。
这不是功能不够——是「依赖少 = 攻击面小 = 能读懂」。每多一个中间件,你就多一个「它到底做了什么、我怎么知道它没出错、我怎么知道它挂了会怎样」的盲区。你自己排查问题时能查得动,很大一部分原因就是依赖树足够浅。
反过来说,新增依赖前要想清楚三件事:①能不能用现有方案解决(你的 better-sqlite3 26 处 + drizzle-orm 7 处已经覆盖了数据需求);②它能不能被替换(如果哪天它挂了,你要多久能换掉);③它的漏洞历史(见第 09 节)。不是为了省事,是为了保持「系统是可读懂的」。
05

N+1 查询:怎么自己发现

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
}
⚠️ 为什么这类问题在小数据测试时看不出来?
因为数据库查一次是有固定成本的(网络往返 + 解析),成本大致固定,与数据量关系不大。
100 条数据:101 次查询,每次假设 2毫秒 → 约 200 毫秒。你完全感觉不到。
10 万条数据:100001 次查询 × 2 毫秒 → 约 200 秒。页面直接卡死,用户以为服务器挂了。
更糟的是:这类问题在你开发和测试阶段永远不会暴露,因为那时候数据量就是小的。它是在客户开始用、并且用了几个月之后才爆的——那时候你已经在改别的功能了。
怎么自己发现(三个方法,不用工具也能做):
① 搜索「循环里 await」这个模式:搜 for/forEach/map,看循环体里有没有 await 加上一个查库函数。看到就警惕。
② 看请求日志的数量:打开页面的 Network 面板,如果你看到同一个接口被请求了几十次,就一定是 N+1。这个方法最直接,也最不会漏。
③ 问自己「这个列表页有多少条数据会进循环」:如果答案是「用户不知道,可能上万条」,那这个循环里就不能有查询。
速查版里那条「循环里查数据库」的信号,落在这里。识别出来之后改法就一条:把所有 id 收集起来,用一条 IN 查回来,然后在内存里对。改完查询次数从 N+1 降到 2,而且数据量再大也不会更慢。
06

异常处理的三种坏味道

异常处理是 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 })
}

为什么空吞最危险:它把「崩溃」变成了「静默失败」。崩了你会看到界面报错、会去查;吞掉了,程序继续往下跑,然后返回一个错的结果——你可能一个月后在客户对账时才发现数据没存上。

坏味道 ② catch 之后返回默认值——最隐蔽

// ✗ 调用方以为成功了,其实拿到的是假数据
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('算价失败')    // ← 明确失败,让调用方知道
  }
}
这个坏味道为什么最难发现?因为它返回的是「看起来正常」的数据。0 元、0 库存、0 条记录——这些都是合法的返回值。系统不会报错,页面会正常显示,只是数字是错的。你项目里的 400 单无尺寸,如果engine 在无尺寸时返回了 0 或者某个默认值而不是报错,那就是这个坏味道的典型表现——而你在前端只会看到「报价是 0」,不会看到任何异常。
判断标准很简单:catch 之后有没有把「我出错了」这个信息传出去?传出去了(抛错、打日志、发告警)就是好的;咽下去了就是坏的。返回默认值是中间地带——只有当你确实想让调用方用默认值继续跑,并且默认值本身有明确的业务含义时,才是合理的。
关联你的真实教训:LumenX 两天重启 482 次。
482 次重启 = 482 次静默崩溃 + 无人发现。这就是坏味道 ① 和 ② 的组合后果:错误要么被吞掉、要么没人监控,于是系统反复挂、反复被拉起,而没有人知道它到底为什么挂。
所以「静默失败」的真实代价不是「不好看」,是「你失去了发现问题的能力」。出问题不可怕,可怕的是你不知道它在出问题。
07

SQL 注入怎么自查

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 这三层叠起来,已经帮你挡掉了大部分。
但仍然要注意三件事:
① zod 挡的是「类型和格式」,不是「SQL 拼接」。如果代码里绕过 ORM 直接写字符串拼接的 SQL(比如为了做复杂聚合、或者用 db.run(sql\`...\`) 拼动态表名),zod 救不了你。
② 动态排序字段、动态表名是重灾区。列名和表名不能参数化(数据库不允许),所以必须用白名单映射,不能直接拼用户输入。
③ 搜索「字符串拼接 SQL」这个模式:搜 "SELECT 和 ${ 出现在同一行的位置。看到就人工确认一遍。
一条实用的判断:如果你写的 SQL 里出现了用户输入,就问「这个值是被当作值传进去的,还是被当作代码拼进去的?」参数化查询是前者(安全),拼接是后者(危险)。「${} 出现在 SQL 模板里但包裹的是一个值而不是列名/表名」,通常就是安全的(drizzle 的 sql\`\` 模板就是这么工作的)。
08

依赖审计:多久该看一次

装依赖就是把别人写的代码放进你的系统。它现在能读你的数据、能往外发请求。所以它需要定期看一眼。

# 一条命令看全项目的已知漏洞
npm audit

# 更详细(能看到每个漏洞的严重程度和影响范围)
npm audit --json

# 自动修复能修的(修完一定要跑一遍测试)
npm audit fix

三个现实做法

做法具体怎么做为什么
频率:每次交付前 + 每月一次不是每天(每天会被噪音淹了),也不是从不。每个项目交付前跑一次,加一个月度全量扫新增依赖是风险最高的那一刻——审查频率不用高,但要卡住「新增」这个动作
锁版本:^ vs 固定^4.21.2 =「允许 4.x 的任意小版本」。4.21.2 =「就用这一个版本,不许动」^ 是「我信这个大版本没问题,更新会自动帮我拿补丁」;固定是「我不敢动,出事我不负责」。对安全敏感的东西(认证、加密)用固定,对普通工具用 ^
长期不更新的依赖如果一个包两年没更新了,要问一句:是它已经稳定到不需要更新,还是作者放弃了?后者意味着以后有漏洞也不会有人修。这时候要找替代方案,或者自己 fork 一份维护
你项目的现状:依赖不多,但版本跨度大——Vite 有 5 / 6 / 8 三个版本,Tailwind 有 3 和 4,同时存在。
这本身不是问题:你的 13 个项目是独立的、各自演进的项目,同一个项目内部用同一个版本就够了。
但要注意两件事:①单个项目内部版本要统一(同一个项目里同时有 Vite 5 和 Vite 8,会出现构建行为不一致);②大版本跨越要留意 breaking change(Tailwind 3 → 4 的配置写法差别很大,不能直接混)。
一条能立刻用的判断:你新增一个包之前,先问「这个东西我能不能自己写一个简单的版本」或者「现有方案能不能解决」。你的依赖树浅,不是因为你运气好,是因为你一直在做这个判断——0 个 Redis、0 个消息队列、0 个 WebSocket 都是这么来的。保持这个习惯,你排查问题的能力就能一直保持。
09

怎么让测试从 0 到 1

你现在全盘 0 个测试文件(*.test.* / *.spec.* 一个都没有)。这是最该补的一件事,而且它没有你想象的那么难。

先说好消息:你已经有正确的起点了。delimao-system 的 packages/engine/test/run.ts——用 Node 自带的 node:assert 手写了 6 个型号的断言,跑法是 npm run engine:test。没有装任何测试框架,不起数据库、不起服务器,几秒钟跑完。这已经是正确做法的核心部分。

最小起步路径(不要求上全套)

1

挑一个最关键的纯函数

只挑一个。你项目里最好的候选是 packages/engine 里的报价计算(或者型号解析)——因为它是纯 TypeScript、零框架依赖、不碰数据库。这意味着测它不需要起任何东西,测试成本最低。

为什么必须从纯函数开始:因为它输入什么、输出什么是确定的。测试一个「输入输出确定」的函数,测试代码就是「给定这个输入,断言输出等于这个值」——一行搞定。

2

用现成的东西写断言,不要先学框架

直接用 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 节的坏味道 ②

关键:先跑起来,再考虑好不好看。不要等学完测试框架再开始——一个能跑的笨测试,价值远高于一个没写的漂亮测试。

3

每次改 bug,先补一个能复现的测试

这是从 0 到 1 唯一真正可持续的路径。你改了 bug,先写一个测试证明「改之前它是错的」,再改代码,再跑一遍证明「现在对了」。这个测试就留下来,以后每次改动都会自动验证这条不会退化。

为什么这一条最关键:因为它不需要你额外花时间做测试——你本来就要改 bug,多花五分钟把「下次不会再犯」这件事固定下来而已。

为什么先做这件事:因为它直接解决你现在最大的风险——「能跑,但不知道改动有没有弄坏别的地方」。6 个型号的断言现在只覆盖 engine,但方向是对的:从纯函数开始、从最高价值的逻辑开始、零框架依赖。下一步可以是 db 层的读写,再下一步才是接口层。不要求一次到位。
不要做的事:不要为了「覆盖率好看」去测简单的 getter/setter。测试的价值是「能抓到 bug」,不是「数字大」。你的目标应该是:报价逻辑算错了能被发现、无尺寸订单能被发现、非法型号能被发现——就这三个,比 80% 覆盖率有用得多。
10

常见坑 / 红线(深入版补充)

坑 1 · 入口找错,在错误的目录里搜

用 -w @delimao/server 这种包名执行命令时,实际工作目录是那个包,不是根目录。你在根目录搜 src/index.ts 可能什么都搜不到,因为文件在 apps/server/src/ 里。对策:先确认 cwd(当前目录)是什么,再决定搜哪里。

坑 2 · 只读 README,不读 scripts

README 会过期,scripts 一般不会。AI 生成的项目里,README 经常是「看起来很完整但命令是编的」。以 scripts 为准。

坑 3 · 追链子追一半就放弃

追到第三站发现「怎么这么绕」,就改成全文搜索。那你永远不知道是哪一环断了。链子的价值就在于它完整——断在哪一环,就是问题所在。

坑 4 · 被node_modules 里的大文件劝退

有些包几十万行代码,从头读是浪费时间。你只需要看它的入口文件和README——看它的「对外承诺」是什么,而不是它的内部实现。大多数包你永远不需要读源码,只需要知道它对外做什么。

红线 · 不要把「AI 说它测过了」当成测试证据,要看真实的测试文件

这是这一篇最重要的一条。
AI 会非常自信地告诉你「我已经加了测试,覆盖了主要路径」——它可能只是「打算加」,或者测试文件里只有一个空的 describe,或者测试跑起来全是空的断言(什么都不检查,直接通过)。
你必须亲眼看到三样东西:① 测试文件真实存在(不是它说的,是你在磁盘上看到的);② 里面有真实的断言(有具体的输入和具体的期望值,不是空跑);③ 你把它改坏,它真的会失败——最后这条是唯一的验证方式:你故意把被测的那行代码改错,跑一次测试,看到它红了,才算确认这个测试是有用的。
你项目现状就是最好的例子:测试 0 个。如果当初AI 说「已测试」,你信了,那这个数字会比你以为的更难发现。所以这条红线不是理论问题,是你已经遇到过的模式。

最后把整篇的逻辑收一句:AI 让「写出来」变得极其便宜,于是「验收」成了整条链上最稀缺的那一环。你的竞争力不在写得快,在你能判断这份东西对不对。而判断的依据不是感觉,是那条链子、那三个问题、那份信号清单、那 15 分钟的动作。
✓

自查清单

  • ✓我能解释 AI 补全代码的机制:它补它认为该有的,不补我项目真实需要的
  • ✓我知道审查的本质不是「找 bug」,是「把它的假设挖出来」
  • ✓我知道生产 400 单 97% 无尺寸,是因为「有没有尺寸」是业务规则,不是类型校验能挡的
  • ✓我会按项目类型定位入口:Node 看 scripts → main → app.use;前端看 package.json → 路由配置 → 页面组件
  • ✓我知道 -w @delimao/server 的工作目录是那个包,不是根目录
  • ✓我能画出报价的六站调用链:组件 → tRPC client → server router → engine → db → 返回
  • ✓我知道 tRPC 前后端同名是追链子最省力的原因(追库那 2 个项目都靠它)
  • ✓我会依赖三层追法:package.json 声明 → node_modules 实际做的 → 在我代码里被怎么调
  • ✓我知道0 个 Redis / 0 个消息队列 / 0 个 WebSocket 是好事——依赖少=攻击面小=能读懂
  • ✓我能解释 N+1 是什么,以及为什么 100 条没事、10 万条卡死
  • ✓我知道 N+1 的三个发现方法,最可靠的是看 Network 面板同一接口请求了几十次
  • ✓我能说出异常处理三种坏味道:空吞 / 只 log 不恢复 / 返回默认值
  • ✓我知道判断标准:catch 之后有没有把「我出错了」传出去
  • ✓我知道静默失败的代价不是不好看,是失去了发现问题的能力(LumenX 两天重启 482 次)
  • ✓我能识别 SQL 拼接,也知道 tRPC + zod + ORM 已经挡了一层但不能全靠它们
  • ✓我知道动态排序字段和表名是重灾区(列名不能参数化,必须白名单)
  • ✓我知道 npm audit 怎么用、^ vs固定版本的区别、单项目内部版本要统一
  • ✓我知道我的测试起步路径:挑纯函数 → 用 node:assert → 改 bug 先补复现测试
  • ✓我知道 packages/engine/test/run.ts 的 6 个型号断言已经是正确起点,不用等学完框架
  • ✓我守住的红线:不把「AI 说它测过了」当证据,要看真实测试文件,并验证「改坏了它真的会失败」
面试怎么答

题目:你用 AI 开发,怎么保证质量?

先说机制,因为机制决定了为什么要审。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 写的代码。