第 11 章

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:"这个抽象在什么情况下会被复用?如果只有一个调用方,值得提取吗?"
重构后忘记更新测试 旧测试直接调用了被拆分的私有实现细节,重构后失效 每次重构后立即跑测试,失败的测试先分析原因再修改

重构安全检查清单

每次重构操作完成后,过一遍这个清单再提交:

本章 5 要点

要点 核心原则
1. 测试先行 没有测试网的重构是裸奔。遗留代码先写特性测试再动手
2. 小步提交 每完成一个重构动作就 commit 一次,保证随时可以回滚到安全状态
3. 明确约束 Composer Prompt 里必须写清楚"只改什么""不动什么",避免范围失控
4. 两步确认 批量修改多文件前,先让 AI 展示核心设计(接口定义),确认后再执行
5. 警惕过度抽象 如果重构后代码更难读,可能是过度设计。及时回退,不要硬撑
本章评分
4.8  / 5  (27 评分)

💬 留言讨论