AI 代码重构实战——用 Composer 安全重构,技术债务从哪来还到哪去
第11章:AI 代码重构实战——用 Composer 安全重构,技术债务从哪来还到哪去
AI 重构的安全原则:先测试,再重构。本章用 3 个真实案例演示 Cursor Composer 重构工作流:把 200 行上帝函数拆分为职责清晰的小函数、用 AI 找到项目里散落的重复逻辑、引入 Repository Pattern 解耦数据层。每步都有可回滚的 Git 节点。
重构的铁律:没有测试就别重构
AI 重构和手动重构面临同一个风险:你以为只在改代码结构,没有改功能,但实际上不小心引入了 bug。AI 重构的速度太快——Composer 一次可能改动十几个文件——这让问题更难察觉。
快速建立测试网(针对遗留代码): 如果你面对的模块没有测试,在重构之前,先让 AI 写特性测试(Characterization Tests):
Cursor Chat Prompt
@src/legacy/UserManager.ts
这是一个遗留模块,目前没有任何测试。
在重构之前,请为这个模块写一套"特性测试"(characterization tests):
- 测试目的不是验证行为是否正确,而是记录当前行为
- 覆盖所有公共方法,包括边界输入(空值、超长字符串、负数)
- 这样重构后如果行为发生变化,测试就会失败并告警
使用 Jest,不要 mock 数据库,使用 in-memory SQLite 替代
特性测试写完后,运行一次确认全绿,然后 commit 一次。这就是你的安全网。之后每次重构操作都运行这套测试,任何行为变化都会被抓住。
Composer 的 diff 视图是你最重要的安全关卡。 在接受任何修改之前,逐行看一遍 diff。AI 有时会在重构时"顺手"改掉一些它认为有问题的逻辑——这些改动可能是好的,也可能是危险的,必须人工判断。
实战案例 1:把 200 行上帝函数拆分
起点:一个处理订单的 200 行函数
// 反面教材:一个函数做了 7 件事,200 行混在一起
async function handleOrder(orderId) {
// 第 1-30 行:验证订单状态
const order = await db.orders.findById(orderId)
if (!order) throw new Error('Order not found')
if (order.status !== 'PENDING') throw new Error('Invalid status')
// 第 31-55 行:检查并锁定库存
for (const item of order.items) {
const stock = await db.inventory.findByProductId(item.productId)
if (stock.quantity < item.quantity) throw new Error('Out of stock')
await db.inventory.decrement(item.productId, item.quantity)
}
// 第 56-95 行:计算最终价格(含税、折扣、运费)
// ...40 行价格计算逻辑...
// 第 96-115 行:调用支付网关
const payment = await stripe.charges.create({ amount: total })
// 第 116-140 行:发送确认邮件
// ...25 行邮件模板和发送逻辑...
// 第 141-165 行:更新订单状态
await db.orders.update(orderId, { status: 'COMPLETED', paymentId: payment.id })
// 第 166-200 行:记录操作日志
await auditLog.record({ action: 'ORDER_COMPLETED', orderId })
}
Composer Prompt:明确拆分目标和约束
Cursor Composer Prompt
@src/services/order.ts 第 45-250 行是 handleOrder 函数
请将这个函数按职责拆分为以下小函数:
1. validateOrder(orderId): Promise<Order> — 验证订单存在且状态合法
2. lockInventory(items: OrderItem[]): Promise<void> — 检查并原子锁定库存
3. calculateTotal(items, coupon?): number — 计算最终价格(含税和折扣)
4. processPayment(amount, paymentMethod): Promise<PaymentResult> — 调用支付网关
5. sendConfirmationEmail(order, user): Promise<void> — 发送确认邮件
约束(必须遵守):
- 原来的 handleOrder 函数继续存在,内部调用这些新函数(不改变外部接口)
- 每个新函数加 JSDoc 注释,说明参数类型和可能抛出的错误
- 不要修改任何业务逻辑,只做结构性拆分
- 不要新增任何 TODO 或 FIXME
重构后:handleOrder 变成纯粹的编排者
/**
* 处理订单完整流程:验证 → 锁库存 → 计算价格 → 支付 → 通知
* @throws {OrderNotFoundError} 订单不存在
* @throws {InsufficientStockError} 库存不足
* @throws {PaymentFailedError} 支付失败
*/
async function handleOrder(orderId) {
const order = await validateOrder(orderId)
await lockInventory(order.items)
const total = calculateTotal(order.items, order.coupon)
const payment = await processPayment(total, order.paymentMethod)
await db.orders.update(orderId, { status: 'COMPLETED', paymentId: payment.id })
await sendConfirmationEmail(order, order.user)
await auditLog.record({ action: 'ORDER_COMPLETED', orderId })
}
// 每个子函数职责单一,可以独立测试和理解
重构后的 handleOrder 从 200 行压缩到 10 行,读一遍就能理解整个业务流程。每个子函数可以被独立测试:测试库存锁定逻辑时不需要触发支付,测试邮件发送时不需要真实订单数据。
实战案例 2:消除重复代码(DRY)
第一步:用 AI 找出项目里所有重复的分页逻辑
Cursor Chat Prompt
@Codebase
查找项目里所有实现了 offset/limit 分页的代码。
列出所有相关文件和代码行,
标注各处实现的差异点(有些可能还有 cursor-based 分页)
假设 AI 找到了 5 个地方,每处都有 8-15 行几乎相同的分页代码。接下来用 Composer 提取:
Cursor Composer Prompt
@src/routes/users.ts @src/routes/products.ts @src/routes/orders.ts
这三个文件里有重复的分页逻辑,请:
1. 在 src/utils/pagination.ts 创建通用分页工具函数:
- parsePaginationParams(query): { page, limit, offset } — 从 query 参数解析,含默认值和上限校验
- buildPaginationMeta(total, page, limit): PaginationMeta — 构建返回给前端的分页元数据
2. 把三个文件里的重复代码替换为调用这两个工具函数
3. 保持各文件原有的查询逻辑不变,只替换分页部分
先展示 pagination.ts 的完整代码,我确认后再修改其他文件
两步走策略: 先让 AI 展示提取的公共函数,你确认签名和逻辑正确后,再让它去修改所有调用方。这样避免 AI 因为对某处调用场景理解有误而批量引入 bug。
实战案例 3:引入 Repository Pattern
场景:Prisma 查询散落在各个 route handler 里
// src/routes/users.ts — 数据库查询直接写在路由里
app.get('/users/:id', async (req, res) => {
// 这种写法让路由承担了数据访问职责
const user = await prisma.user.findUnique({
where: { id: parseInt(req.params.id) },
include: { profile: true, orders: { take: 5, orderBy: { createdAt: 'desc' } } }
})
if (!user) return res.status(404).json({ error: 'Not found' })
res.json(user)
})
// 同样的 Prisma 查询在 admin routes、webhook handlers 里也有副本
// 测试这个路由必须连接真实数据库,无法 mock
Cursor Composer Prompt
@src/routes/ 目录下的所有文件
当前问题:Prisma 查询直接写在 route handler 里,
导致:1)相同查询在多处复制 2)路由测试必须连数据库 3)切换 ORM 要改所有路由
请重构为 Repository Pattern,只处理 User 相关的部分:
1. 创建 src/repositories/userRepository.ts,包含:
- findById(id: number): Promise<UserWithProfile | null>
- findByEmail(email: string): Promise<User | null>
- create(data: CreateUserInput): Promise<User>
- update(id: number, data: UpdateUserInput): Promise<User>
- findWithRecentOrders(id: number, orderLimit?: number): Promise<UserWithOrders | null>
2. 把 src/routes/users.ts 里所有 prisma.user.* 调用替换为 userRepository 的方法
3. 保持路由的请求/响应接口不变
其他 resource(product、order)我们后续单独处理,这次只改 user
重构后:路由变成纯粹的 HTTP 层
// src/repositories/userRepository.ts
import { prisma } from '../db'
import type { Prisma } from '@prisma/client'
export const userRepository = {
async findById(id: number) {
return prisma.user.findUnique({
where: { id },
include: { profile: true }
})
},
async findWithRecentOrders(id: number, orderLimit = 5) {
return prisma.user.findUnique({
where: { id },
include: {
profile: true,
orders: { take: orderLimit, orderBy: { createdAt: 'desc' } }
}
})
},
async create(data: Prisma.UserCreateInput) {
return prisma.user.create({ data })
}
}
// src/routes/users.ts — 路由现在只处理 HTTP,不知道 Prisma 存在
app.get('/users/:id', async (req, res) => {
const user = await userRepository.findWithRecentOrders(parseInt(req.params.id))
if (!user) return res.status(404).json({ error: 'Not found' })
res.json(user)
})
引入 Repository Pattern 后,测试路由只需要 mock userRepository 对象,不需要连接真实数据库。将来切换 ORM(从 Prisma 到别的),只需要改 repository 文件,所有路由不受影响。
常见重构陷阱
| 陷阱 | 具体表现 | 预防方法 |
|---|---|---|
| AI 悄悄改变了函数行为 | 测试通过但逻辑与原来不同(AI"顺手"修了一个它认为有问题的地方) | 先写特性测试锁定当前行为,用 git diff 仔细对比每个改动 |
| 重构范围失控 | 说好只改 user 模块,结果 order、product 也被动了 | Composer Prompt 里明确写"只改 XXX,其他文件不要动" |
| 引入不必要的抽象 | 提取了一个只被调用一次的公共函数,代码反而更难读 | 问 AI:"这个抽象在什么情况下会被复用?如果只有一个调用方,值得提取吗?" |
| 重构后忘记更新测试 | 旧测试直接调用了被拆分的私有实现细节,重构后失效 | 每次重构后立即跑测试,失败的测试先分析原因再修改 |
重构安全检查清单
每次重构操作完成后,过一遍这个清单再提交:
- 所有原有测试仍然通过(不允许"暂时跳过")
- 外部接口没有变化(函数签名、API 路由、返回值结构)
- 没有引入新的 TODO 或 FIXME(AI 喜欢留坑)
- 性能没有明显下降(有 benchmark 的话跑一下对比)
- git diff 已经人工检查过,没有意外的改动
- commit message 清楚描述了做了什么重构(方便回滚时定位)
本章 5 要点
| 要点 | 核心原则 |
|---|---|
| 1. 测试先行 | 没有测试网的重构是裸奔。遗留代码先写特性测试再动手 |
| 2. 小步提交 | 每完成一个重构动作就 commit 一次,保证随时可以回滚到安全状态 |
| 3. 明确约束 | Composer Prompt 里必须写清楚"只改什么""不动什么",避免范围失控 |
| 4. 两步确认 | 批量修改多文件前,先让 AI 展示核心设计(接口定义),确认后再执行 |
| 5. 警惕过度抽象 | 如果重构后代码更难读,可能是过度设计。及时回退,不要硬撑 |