Skip to main content

第六篇:从屎山到可维护 — 第一次有意识的重构

进入信号:改功能 A 导致功能 B 崩了,你完全不知道为什么;一个组件超过 300 行;你开始害怕改代码。

本篇解决的问题:代码已经失控了,但你不能停下来重写——客户还在用。怎么在不影响客户使用的前提下,逐步把代码从「屎山」变成可维护的工程?


危机的爆发

那天晚上,G 发来消息:「通知功能坏了,销售收不到确认提醒。」

你打开代码,找到通知模块。你发现是前两天给 H 加「批量通知」功能时改了 sendNotification 函数,而 L 和 G 的单条通知也复用了这个函数——被改出 bug 了。

但是你看不懂三天前自己写的代码了。那个函数里混着:飞书 API 调用、企业微信 API 调用、邮件模板拼接、数据库写入、重试逻辑。200 行挤在一个函数里。

你花了一个小时修好。修好之后你觉得不安——下一次会是什么时候?还有什么你改了但不知道会影响的地方?

你打开了 Reconciliation.tsx——523 行。 里面有文件上传、匹配结果、通知面板、审批面板、差异详情弹窗、导出按钮。每个功能都写在同一个文件里,用 useState 满天飞来管理状态。

你不敢再往下看了。


重构不是重写:五点原则

重构最大的陷阱是:「这代码已经烂了,不如重写」。但你现在有 10 个付费客户(虽然还在免费试用期),你不可能停止服务来重写。

重构的原则是:在不改变外部行为的前提下,改善内部结构。客户应该完全无感知。

五点原则:

  1. 渐进式:不是一次性重构整个系统,而是一个模块一个模块来
  2. 可回退:每次重构都在一个独立分支上,确保随时能退回
  3. 有测试:重构前先加测试——没有测试的重构是赌博
  4. 先外围后核心:先重构边缘模块(工具函数、UI 组件),再动核心(匹配引擎、数据层)
  5. 不改行为:重构期间不加新功能,所有功能需求排到重构后

第一步:加 TypeScript(如果你之前偷懒了)

很多 vibe coding 项目一开始是 JavaScript——因为 AI 写 JS 更快、更少报错。但 10 个客户之后,JS 的动态类型开始反噬你。

你决定加 TypeScript。但不是 strict: true 一次性全切——那样会爆出 500 个类型错误,一个月都改不完。

渐进式策略:

// tsconfig.json
{
"compilerOptions": {
"strict": false, // 先不强开 strict
"noImplicitAny": false, // 允许 any
"allowJs": true, // 允许 JS 和 TS 混用
"checkJs": false // 不检查 JS 文件
}
}

先只改新写的文件用 .ts,老 .js 文件保持不动。每周改 3~5 个文件。两个月内完成全量迁移。期间新旧混用完全没问题。


第二步:前端组件拆分

Reconciliation.tsx 523 行,这是第一个要动的手术。

拆分原则:每个组件只做一件事

拆分前:

src/
components/
Reconciliation.tsx ← 523 行,所有功能都在里面

拆分后:

src/
pages/
Reconciliation.tsx ← 30 行,组合 + 布局
components/
reconciliation/
FileUploadPanel.tsx ← 80 行,负责上传
MatchResultTable.tsx ← 120 行,负责展示匹配结果
DiffDetailModal.tsx ← 90 行,差异详情弹窗
NotificationPanel.tsx ← 70 行,通知状态面板
ApprovalPanel.tsx ← 80 行,审批操作
ExportButton.tsx ← 40 行,导出功能
hooks/
useReconciliation.ts ← 100 行,核心状态逻辑
useFileUpload.ts ← 50 行,上传状态
useMatchData.ts ← 60 行,匹配数据

拆分方法(实战步骤)

  1. 别上来就拆。先给 Reconciliation.tsx 写注释——给每个逻辑区块加 // === 文件上传区域 === 这样的分隔,把 500 行先变成 6 个带标签的区块
  2. 拆状态管理:把所有 useStateuseEffect 抽到对应的自定义 Hook 里。useReconciliation.ts 管理对账流程状态,useFileUpload.ts 管理文件上传状态
  3. 拆组件:每个注释区块变成一个独立组件,用 Hook 拿数据
  4. 每拆一个组件,跑一遍 E2E 手工测试:上传 → 对账 → 确认 → 导出,确认链路没断

第三步:后端分层重构

API Routes 当前的问题:业务逻辑、数据库查询、通知发送全混在一起。

拆分前(一个 API Route):

// app/api/reconcile/route.ts — 不好
export async function POST(req: Request) {
const { teamId, bankFileId, orderFileId } = await req.json();

// 权限检查 — 混在业务逻辑里
const user = await getCurrentUser();

// 数据库查询 — 直接在 handler 里写
const bankTxns = await prisma.bankTransaction.findMany({...});
const orders = await prisma.order.findMany({...});

// 匹配逻辑 — 200 行挤在一起
for (const txn of bankTxns) {
const match = orders.find(...);
if (match) {
await prisma.reconciliation.create({...});
} else {
// 发送通知 — 业务逻辑里塞了通知发送
await sendNotification({...});
}
}

// 审计日志 — 忘记写了

return Response.json({...});
}

拆分后:三层架构

app/api/reconcile/route.ts   ← 薄层,只做参数校验 + 调用 Service + 返回
lib/
services/
reconciliation.service.ts ← 对账业务逻辑
notification.service.ts ← 通知发送逻辑
audit.service.ts ← 审计日志
repositories/
bank-transaction.repo.ts ← 数据库操作封装
order.repo.ts
reconciliation.repo.ts
utils/
matching-engine.ts ← 纯函数匹配算法
csv-parser.ts ← 文件解析
// app/api/reconcile/route.ts — 重构后(薄层)
export async function POST(req: Request) {
const user = await requireAuth(req);
const input = validateReconcileInput(await req.json());

const result = await reconciliationService.reconcile(user.teamId, input);

await auditService.log(user.id, 'reconcile', result.summary);

return Response.json(result);
}
// lib/services/reconciliation.service.ts — 业务逻辑层
export class ReconciliationService {
async reconcile(teamId: string, input: ReconcileInput) {
const bankTxns = await bankTransactionRepo.findUnmatched(teamId);
const orders = await orderRepo.findUnmatched(teamId);

const { matches, diffs } = matchingEngine.run(bankTxns, orders);

for (const match of matches) {
await reconciliationRepo.createMatch(teamId, match);
}

for (const diff of diffs) {
await reconciliationRepo.createDiff(teamId, diff);
await notificationService.notifyDiff(teamId, diff);
}

return { matched: matches.length, diffs: diffs.length };
}
}

这个重构的关键收益

  • 匹配引擎是纯函数:输入银行流水和订单,输出匹配和差异。没有 I/O、没有副作用。这意味着它可以被测试——你可以造一组假数据,跑一遍匹配,断言结果。以前做不到,因为匹配逻辑藏在 API Route 的 for 循环里。
  • 通知逻辑独立:以后换通知渠道(飞书换钉钉),只改 notification.service.ts,不影响对账逻辑。
  • Repository 封装数据库:以后换 ORM(Prisma 换 Drizzle),只改 Repository 层,不影响业务逻辑。

第四步:补测试(先补最重要的)

以前你没有测试,因为需求每周在变,测试跟着改太累。现在代码稳定了,方向确定了,测试的维护成本降低了。

分层测试策略:

测试类型覆盖什么什么时候写成本
单元测试纯函数(匹配引擎、数据校验)重构完立刻写
集成测试API Route 的完整流程核心链路写
E2E 测试完整用户操作流程收入稳定后再补

现在先写单元测试——覆盖最关键的匹配引擎:

// __tests__/matching-engine.test.ts
import { runMatching } from '@/lib/utils/matching-engine';

test('同金额同日期应该匹配', () => {
const txns = [{ amount: 100, date: '2026-07-02' }];
const orders = [{ amount: 100, date: '2026-07-02', orderNo: 'ORD001' }];

const { matches, diffs } = runMatching(txns, orders);

expect(matches).toHaveLength(1);
expect(diffs).toHaveLength(0);
});

test('金额不匹配应该标记为差异', () => {
const txns = [{ amount: 100, date: '2026-07-02' }];
const orders = [{ amount: 90, date: '2026-07-02', orderNo: 'ORD001' }];

const { matches, diffs } = runMatching(txns, orders);

expect(matches).toHaveLength(0);
expect(diffs).toHaveLength(1);
});

test('同金额跨日应该容差匹配', () => {
const txns = [{ amount: 100, date: '2026-07-02' }];
const orders = [{ amount: 100, date: '2026-07-01', orderNo: 'ORD001' }];

const { matches } = runMatching(txns, orders, { dateTolerance: 1 });

expect(matches).toHaveLength(1);
});

这些测试的价值不是「找到 bug」,是让你以后改匹配引擎的时候不怕了。 改完了跑一遍测试,绿色的 pass 告诉你「没搞砸」。


第五步:数据库清理

重构不只是代码的事。几个月陪跑下来,数据库里也积累了垃圾:

  • 测试数据:你给 L 演示时导入的假数据
  • 孤儿记录:Reconciliation 记录引用的 BankTransaction 已被删除
  • 重复的匹配规则:同一个客户加了三次同样的规则

加外键约束,防止产生新的脏数据:

model Reconciliation {
// ...
bankTransaction BankTransaction @relation(
fields: [bankTransactionId],
references: [id],
onDelete: Cascade // 银行流水删了,匹配记录也删
)
}

手动清理测试数据,建一个清理脚本:

-- 清理 2026 年 5 月之前的测试数据
DELETE FROM BankTransaction WHERE created_at < '2026-05-01' AND amount = 9999.99;
-- 金额为 9999.99 的明显是你早期的测试数据

重构后的对比

维度重构前重构后
改一个匹配规则的耗时2 小时(在 200 行 for 循环里找位置)10 分钟(纯函数,改完跑测试)
加一个新通知渠道1 天(要读所有代码找发通知的地方)2 小时(只改 notification.service.ts)
「害怕改代码」的程度8/103/10
是否有测试0 个20 个单元测试(核心链路覆盖)
新人能否接手不可能看一周末差不多

什么时候进入下一阶段?

代码已经可维护了。你可以放心地加新功能,不用害怕牵一发动全身。

这时候,一个新的信号来了——G 在试用了一个月后,给你发消息:

「你们的工具确实能省钱省时间。我们想长期用。你们怎么收费?」

在之前的每次聊天里,都是你问客户「你觉得值多少钱」。这是第一次,客户主动问你怎么收费。

进入下一阶段的信号:客户主动问「你们怎么收费?」——他准备好给钱了。

这是第七篇的内容。


本篇总结

做了什么花了多久
渐进式加 TypeScript持续,每周改几个文件
前端组件拆分(523 行 → 7 个组件 + 3 个 Hook)3 天
后端三层架构重构(API Route → Service → Repository)2 天
补 20 个单元测试1 天
数据库加外键 + 清理测试数据半天

当前系统形态: 有分层架构、有 TypeScript、有测试的单体应用。前端组件化完成,后端 Service/Repository 分层。代码可维护、可测试、可交接。

本月账单:$0


下一步

客户主动问怎么收费——进入第七篇:开始收钱。