3年没人敢碰的老代码,我用AI重构了它——然后翻车了

0 阅读12分钟

上个月,我干了一件所有人都劝我别干的事。

把一个3年没人动过的核心服务,用AI做了一次完整重构。

我的同事老刘知道之后,发来一条消息:

"你疯了吗?那个服务每天处理几十万笔流水,出了事你担得起吗?"

我没回他。

因为我其实也怕。

但我更怕的是,这块代码再这样烂下去,下一个踩坑的人,是我自己。


一、为什么非要动它

这个服务是3年前一个离职同事写的,核心逻辑是处理商户结算和账单生成。

没有人完整读过它。

不是因为代码量大,总共也就2000行。

是因为它太重要了,没人敢随便碰。

每次有新需求,大家的做法都一样:找一个最不重要的角落,加一段if-else,测通了就上线,绝对不碰核心逻辑。

3年下来,这个服务变成了这个样子:

BillService.java          -- 1个类,823行
BillService.java里有:
  - 账单生成逻辑
  - 手续费计算逻辑  
  - 商户信息查询逻辑
  - 短信通知逻辑
  - 报表数据组装逻辑
  - 定时任务触发逻辑
  ...共17个方法,最长的一个方法214行

17个方法,6种职责,全在一个类里。

上个月产品提了一个新需求:支持账单按日汇总和按周汇总两种模式,商户可以自己切换。

我打开BillService,看了20分钟,关掉了。

不是看不懂,是找不到从哪里下手。

每改一行,都不知道会影响哪里。

那天晚上我想了很久,做了一个决定:与其继续往这个泥潭里加代码,不如先把坑填了。


二、重构之前,我做了一件很重要的事

很多人一拿到要重构的代码,第一件事是打开Cursor,开始让AI分析。

我没有。

我做的第一件事,是给这个服务补单元测试

原来的单元测试覆盖率:11%。

是的,11%。三年了,只有11%。

我花了两天时间,把核心逻辑的单元测试补到了68%。

补测试前:
- 核心方法覆盖率:11%
- 重构后怎么知道没有破坏现有逻辑?
- 靠感觉?

补测试后:
- 核心方法覆盖率:68%
- 重构后跑一遍测试
- 通过了,说明行为没变
- 这是底线,不是锦上添花

这两天,是整个重构里最关键的两天。

后来翻车,也和这件事有关。等我讲到翻车那部分你就明白了。


三、把代码喂给AI,让它先"读懂"

测试补好之后,我开始让AI介入。

第一步不是让AI重构,而是让AI读懂这个服务

我的Prompt:

这是一个Java后端服务,核心是商户账单生成和结算逻辑。
我把主要的类和方法都贴出来了。

请帮我做一件事:
1. 用你自己的语言,描述这个服务现在在做什么
2. 找出职责划分有问题的地方(一个类/方法做了不止一件事)
3. 找出你认为重构风险最高的部分(改了最容易出问题)
4. 给出重构的优先级建议

[粘贴完整代码]

AI的分析,超出了我的预期:

服务现在在做什么: BillService承担了账单生命周期的全部管理职责:创建、计算、通知、归档。 这是一个典型的"上帝类"(God Class)反模式。

职责划分问题(3处主要):

  1. generateBill()方法同时负责数据查询、费用计算和账单持久化, 这3件事应该拆开,当前任何一环出错都会导致整个方法失败且难以定位
  2. sendNotification()嵌套在账单生成的主流程里, 通知失败会导致账单生成事务回滚,这不合理
  3. 定时任务的触发逻辑和业务逻辑在同一个类里, 导致单元测试需要mock时钟,增加了测试难度

重构风险最高的部分: 第87-143行的手续费计算逻辑。 这段代码包含多个嵌套条件,涉及不同商户类型的差异化处理, 注释几乎为零,改错了直接影响资金计算。 建议:这段逻辑在有充分测试覆盖之前,不要动。

重构优先级:

  1. 先把通知逻辑抽出来(低风险,影响面小)
  2. 再把定时任务触发层分离(中风险)
  3. 最后拆分核心账单生成流程(高风险,需要充分测试)
  4. 手续费计算逻辑,建议最后处理,且单独做一次专项测试

我把这份分析打印出来,钉在显示器旁边。

后来证明,AI给的这个优先级顺序,救了我一命。


四、重构过程:哪些顺了,哪些翻了

顺利的部分

第一步:把通知逻辑抽出来

按照AI的建议,先从风险最低的部分开始。

我给Cursor的Prompt:

把BillService里所有和通知相关的逻辑(短信、站内信、webhook)
抽取成一个独立的BillNotificationService。

要求:
1. BillService通过依赖注入调用BillNotificationService
2. 通知失败不影响账单生成主流程(用try-catch隔离)
3. 通知记录异步处理,不阻塞主流程
4. 保持原有的方法签名不变,只是移动代码

项目技术栈:Spring Boot 3, MyBatis-Plus

AI生成了BillNotificationService,我review了一遍,改了2处细节,跑单元测试,全部通过。

耗时:40分钟。

原来我估计要半天。

第二步:分离定时任务层

同样顺利。AI生成了BillScheduleService,把触发逻辑从业务逻辑里剥离出来。

测试通过,上灰度,监控正常。

耗时:1小时。

这两步完成之后,BillService从823行缩减到了571行,职责清晰多了。


翻车的部分

第三步,我开始动核心账单生成流程。

AI建议的是把generateBill()这个214行的大方法,按照"数据查询 → 费用计算 → 持久化"拆成3个方法。

我觉得这个方向对,让AI来做:

generateBill()方法按照职责拆分:
1. queryBillData():负责所有数据查询
2. calculateBillAmount():负责费用计算
3. saveBill():负责持久化和事务

保持原有的@Transactional注解在主方法上

AI生成的代码,看起来很干净。

我review了一遍,没发现问题,跑单元测试,通过了。

提交,上灰度。

然后,账单金额开始对不上。


五、翻车的原因,让我沉默了很久

灰度上线后的第二个小时,风控那边发来消息:

"你们结算服务有问题,有几笔账单金额和流水对不上,差了几块到几十块不等。"

我第一反应是:不可能,单元测试全通过了。

然后我想起来了。

11%。

原来的测试覆盖率是11%。

我补到了68%,但还有32%没有覆盖。

而翻车的那段逻辑,正好在那32%里。


具体是什么问题?

AI在拆分方法的时候,把queryBillData()calculateBillAmount()拆成了两个独立的方法调用。

但原来的generateBill()里,有一段逻辑是这样的:

// 原代码(214行大方法里的一段)
List<Transaction> transactions = queryTransactions(merchantId, startDate, endDate);
// 查完之后,要过滤掉已经计入上期账单的流水
transactions = transactions.stream()
    .filter(t -> t.getBillId() == null)  // 关键:只统计未出账的流水
    .collect(Collectors.toList());

BigDecimal totalAmount = calculateAmount(transactions);

AI拆分之后变成了:

// AI拆分后
List<Transaction> transactions = queryBillData(merchantId, startDate, endDate);
// ❌ filter逻辑被AI"优化"掉了——它认为这个filter应该在query层做
// 但实际上,AI没有意识到这个filter依赖一个业务规定:
// "同一笔流水不能重复出账"
BigDecimal totalAmount = calculateBillAmount(transactions);

AI不知道t.getBillId() == null这个条件背后有一条业务规定。

它看到一个filter,觉得应该下沉到queryBillData里,但查询层没有加这个条件,于是这段逻辑就消失了。

结果:部分已经出过账的流水被重复计算,账单金额偏高。

这个逻辑,在原来的11%单元测试里没有覆盖。

我补的68%,恰好也没有覆盖这个边界条件。


六、处理过程

发现问题之后,我做了3件事。

第一,立刻回滚。

灰度只影响了5%的流量,受影响的商户不多,但涉及资金,回滚是第一优先级。

15分钟内完成回滚,错误金额暂停结算,等修复后重新核算。

第二,复盘根因,不是修bug。

那个消失的filter不是bug,是我和AI都没有意识到的隐性业务规则

这条规则没有写在任何注释里,没有写在任何文档里,只存在于当年写代码的人的脑子里,而那个人已经离职了。

我用了半天,把这个服务里所有"隐性规则"全部翻出来,写成了注释,加到代码里。

一共找到了7条。

/**
 * 注意:此处filter是核心业务规则:
 * 同一笔流水(transaction)只能归入一期账单。
 * billId不为null表示该流水已被归入历史账单,当期不得重复计算。
 * 这条规则来源于结算协议第4.2条,不可删除或修改。
 * 
 * 如需修改此逻辑,必须同时更新:
 * 1. BillServiceTest.testNoDuplicateTransaction()
 * 2. 通知财务团队
 * ——2026年3月,重构时补充
 */
transactions = transactions.stream()
    .filter(t -> t.getBillId() == null)
    .collect(Collectors.toList());

第三,补测试,再重构。

把这7条隐性规则全部写成单元测试,覆盖率从68%提到了91%。

然后重新让AI重构,这次给了更详细的上下文:

重构generateBill()方法时注意以下业务规则(绝对不能丢失):
1. filter(t -> t.getBillId() == null) 是核心约束,不能移到query层
2. 手续费计算依赖商户类型,不同类型走不同分支
3. [其他6条...]

请在拆分时确保这些规则在重构后的代码中有明确体现,
并说明每条规则在新代码的哪个位置得到了保留。

这次AI生成的代码,我逐条对照规则清单做了review。

单元测试全部通过。

上线,监控正常,没有再出问题。


七、复盘:翻车教会了我什么

重构完成后,我坐下来想了很久。

翻车的根本原因,不是AI出错了。

AI做的事是对的:把一个大方法按职责拆分。这个方向完全正确。

翻车的原因是:我没有把"业务规则"告诉AI。

AI是一个技术能力很强的工程师,但它是第一天入职的那种。

它不知道结算协议第4.2条,不知道"同一笔流水不能重复出账",不知道这段filter背后有一个财务合规要求。

这些知识,不在代码里,不在注释里,不在文档里。

只存在于人的经验里。

而我,没有把这些经验传递给AI。


这次经历,让我总结出了3条重构的铁律:

铁律1:先测试,后重构。覆盖率低于80%,不要动核心逻辑。

不是80%就足够安全,而是80%以下根本不够。

我补到68%以为差不多了,结果那32%里藏着最核心的业务规则。

现在我的标准是:核心业务逻辑,覆盖率不到80%不动刀,动刀之前先补到90%+。

铁律2:让AI重构之前,先把"隐性规则"显性化。

每一段"莫名其妙"的代码,背后可能都有一个业务原因。

在让AI重构之前,把你知道的所有"不能动的理由"写成注释,写进Prompt。

AI不会帮你发现你不知道的东西,但它能帮你保留你告诉它的东西。

铁律3:分步骤重构,每步上灰度验证,不要一次性大改。

通知逻辑、定时任务层、核心流程,分3次上线,每次只动一块,出了问题影响面有限,回滚成本低。

如果我一次性把整个服务全改了再上线,出问题的时候根本不知道是哪里出了问题。


八、最终结果

整个重构从开始到稳定上线,花了3周时间。

重构前:
- BillService.java:823行,6种职责,17个方法
- 单元测试覆盖率:11%
- 最长方法:214行
- 注释密度:几乎为零

重构后:
- BillService.java:主流程,187行
- BillNotificationService.java:156行
- BillScheduleService.java:89行
- BillCalculationService.java:203行
- 单元测试覆盖率:91%
- 最长方法:67行
- 核心业务规则全部有注释说明

线上表现:
- 功能:完全一致
- 性能:账单生成耗时降低了23%(拆分后可以并行查询)
- 新需求(按日/按周汇总):在新结构上,2小时做完

原来预估2天做不完的新需求,在重构之后2小时做完了。

这才是重构真正的价值:不是让代码好看,是让后面的工作变得可能。


写在最后

那个3年没人敢碰的老服务,现在有人敢碰了。

那个人是我,但也可以是任何一个新来的同事。

AI在这次重构里帮了很大的忙——分析代码结构、生成拆分方案、写单元测试、执行重构。

但它救不了我自己没做好的事:测试覆盖不够,隐性规则没有显性化,上线步骤太激进。

翻车,是我的问题,不是AI的问题。

AI是一把很好用的刀。

但刀不会告诉你,你在切什么。


如果你的项目里也有一个"3年没人敢碰的老服务",我的建议是:

先别急着重构,先把它的测试补上去。

那个过程会让你重新读懂这段代码。

读懂了,再动刀。

带着AI一起。


你有没有类似的经历?动过"没人敢碰"的老代码吗?结果怎么样?

欢迎评论区聊聊。


后端AI实验室 不讲概念,只谈实战 代码开源,每周更新

扫码_搜索联合传播样式-标准色版.png