老炮踩坑录 · A01· AI 时代系列
· 基于「企业融合平台」真实源码
· 关键词:AI 重构 · 上下文缺失 · 重构的隐藏前提
上一篇,我花了三天把一个停更多年的老项目跑了起来。
跑起来之后,我做了件很多人会做的事:把最扎眼的那个文件喂给了 AI,让它给重构建议。
这个文件是 EnterpriseRegistController类,总共1600 行,是整个项目里最长的 Controller。打开它的第一眼,任何有代码洁癖的人都会皱眉——Excel 导出、参数校验、业务逻辑、样式设置全糊在一个文件里,同一段整数转中文的 if-else 复制粘贴了 5 遍。
看过上一篇文章《Controller 写了1600行,我打开一看全是业务逻辑,差点晕过去》的朋友可能还记得,那篇文章我拆解了这个 Controller 的 5 个反模式,给出了"如果重来我会怎么改"的重构方案——枚举提取、策略模式、Validation 框架,一个不落。
那篇写的是"设计原则"。今天这篇,是另一个视角。
我把同样的代码喂给 AI,它给了 6 条建议——和我在上一篇文章里给出的方案几乎一模一样。前 4 条,我一边看一边点头。第 5 条,我看完差点冒冷汗。第 6 条看完,我直接把对话窗口关了。
同一个文件,同样的方案,为什么上一篇文章说"应该这么改",这一篇却说"一条都没敢用"?
这就是本文要聊的事。
先看看这个文件长什么样
在逐条分析之前,我们得先知道这 1600 行到底都写了啥。我把它按功能拆成 5 块:
| 功能区域 | 行数范围 | 行数 | 做了什么 |
|---|---|---|---|
注册校验 validateSubmitData | 167-357 | 190 | 分两步校验企业注册信息,20+ 个字段 |
企业信息导出 backExportEnterprise | 543-704 | 160 | 遍历企业数据,逐字段转中文,导出 Excel |
诊断导出 exportPlustekDiagnosis | 737-1101 | 365 | 动态二级表头 + 合并单元格 + 冻结列 |
| 三个模型导出(Platform/Factory/Cloud) | 1110-1455 | 345 | 结构和上面类似,但维度不同 |
| Excel 样式(表头/内容/合并) | 1463-1598 | 135 | POI Cell Style 设置 |
剩下约 400 行是常规的 CRUD 功能接口(注册、查询、省市区联动等),没什么特别要讲的。
真正让 AI "兴奋"的,是中间那 1000 行导出逻辑和 200 行校验逻辑。
我把代码喂给 AI,它给了 6 条建议
我没有给 AI 任何业务背景,只给了它文件内容,然后问:"这段代码有什么问题?你怎么重构?"
三分钟后,6 条建议出来了。
建议一:把重复的整数转中文提取成枚举
enterpriseTurnover(企业年营业额)的转换逻辑,同一段 7 层 if-else 在文件里出现了 5 次:
if (enterpriseTurnover == 0) {
item.put("enterpriseTurnoverStr", "2000万以下");
} else if (enterpriseTurnover == 1) {
item.put("enterpriseTurnoverStr", "2000万至7000万");
} else if (enterpriseTurnover == 2) {
item.put("enterpriseTurnoverStr", "7000万至1个亿");
}
// ... 一共 7 个分支
businessType 的 0/1 → "离散行业" / "流程行业" 也是 5 次重复。
AI 说:“提取成枚举类,一处定义、处处调用。”
看起来确实该提取。 5 次重复是代码腐化的经典信号。上一篇文章里,我就是用这个例子给出了枚举提取的方案,5处 × 10行 → 5行,代码量降低10倍。
但这次我不动。 是因为这 7 个字符串不是普通文本——它们是政府申报表上的固定选项。"2000万至7000万" 不是 "2000万-7000万",不是"2000~7000万",政府文件都是非常严谨的,必须和政府文件一字不差。
提取成枚举后,映射值从 5 个地方收拢到 1 个地方——改起来方便了,但改错的后果也从影响 1 个报表变成影响 5 个报表。
现在 if-else 虽然笨,但每个导出方法里的转换逻辑是独立的。一个方法改错了,其他 4 个不受影响。提取成公共方法后,一次修改同时影响 5 个场景,你反而失去了 "逐场景验证" 的安全网。
结论:有道理,"笨拙但隔离"比"优雅但牵一发动全身"更安全。不改动。
建议二:4 个导出方法用模板方法模式合并
exportInternetPlatform、exportExampleFactory、exportEnterpriseCloud 三个方法结构高度相似:
for (Map<String, Object> dataMap : applyInfoData) {
item = new HashMap<>();
item.put("enterpriseName", dataMap.get("enterpriseName"));
// ... businessType 转换 ...
// ... enterpriseTurnover 转换 ...
// 分数
Map<String, Object> data = xxxService.diagnoseData(rapplyid);
// 按维度名称映射到字段
dataExcel.add(item);
}
elecExcelExportUtil.exportByTpl(response, N, "某某报表");
AI 说:“抽取抽象基类,定义模板方法,子类覆盖差异部分。”
三个方法确实像三胞胎。 但仔细看,差异不在细枝末节,而在核心数据:
| Platform | Factory | Cloud | |
|---|---|---|---|
| 调用的 Service | reportAppService | reportCapabilityService | reportCloudService |
| 维度字段数 | 5 个 | 4 个 | 10 个 |
| 维度名称 | "应用服务能力"等 | "生产现场优化"等 | "开发设计优化"等 |
| 导出模板 | appsRes | cxRes | deRes |
如果强行抽取模板方法,抽象方法会占子类 80% 的代码量——"模板"本身只剩 20% 的公共部分。如果新增一个导出类型时,你还是要从头写核心逻辑,模板能复用的只有遍历和导出调用。更别说第四个方法 exportPlustekDiagnosis 是个彻底的异类——365 行动态二级表头算法,和其他三个完全没有公共部分。
结论:结构相似 ≠ 可以合并。差异部分是核心业务逻辑,不是参数配置。不采纳,不改。
建议三:Controller 里的 Excel 样式代码应该下沉到 Service
createHeadCellStyle、createContentCellStyle、setDataStyleAndHeight 三个方法共 135 行,全是 POI 的样式设置:
protected void setDataStyleAndHeight(Sheet sheet, Workbook wb) {
CellRangeAddress cellRangeAddress0 = new CellRangeAddress(0, 1, 0, 0);
CellRangeAddress cellRangeAddress1 = new CellRangeAddress(0, 1, 1, 1);
// ... 9 个合并区域,每个设 4 条边框 = 36 行重复代码
}
Controller 确实不该管样式。 这条原则我没有异议。
但 "下沉到 Service" 只是搬家。这个项目的 Service 层本身就很薄——查数据库、组装 JSONObject、返回。把 135 行样式代码移进去,Service 就从"薄薄的数据层" 变成了 "既管数据又管样式的混合层"。
而且 setDataStyleAndHeight 里的 9 个 CellRangeAddress 对应的是诊断报表的前 9 列固定列,和动态表头算法紧密耦合。移到工具类里,它就失去了和表头算法的上下文关联,变成 "9 个不知道为什么这么写的魔法数字"。
正确的做法不是移动代码,而是报表模板化——把列名、样式、合并规则全定义在 Excel 模板文件里。但这是架构改造,不是"把方法从 Controller 搬到 Service"能解决的。
结论:原则没错,但解决方案不是移动代码,而是改变报表生成方式。在架构没变之前,样式代码留在 Controller 里至少和调用它的导出方法在一起,上下文完整。不采纳,不改。
建议四:paperid 的 if-else 用策略模式
if (paperid == 0) {
return exportPlustekDiagnosis(response);
} else if (paperid == 1) {
return exportInternetPlatform(response, data);
} else if (paperid == 2) {
return exportExampleFactory(response, data);
} else if (paperid == 3) {
return exportEnterpriseCloud(response, data);
}
AI 说:”用策略模式 + 工厂,每种模型一个策略类,通过 paperid 自动路由。“ 新增模型时只需加一个策略类,不用改 Controller。
完全遵循了 "开闭原则"——对扩展开放,对修改关闭。 的确是教科书级的正确。上一篇文章里,我给出了完整的策略模式 + 工厂实现代码,说"以后新增导出类型只要加一个实现类,Controller一行都不用改"。
方案本身没问题。但那是"如果重来"的理想状态。 回到现实——看看这 4 个分支各自在做什么:
- paperid=0:365 行,动态二级表头,不传
applyInfoData - paperid=1:110 行,5 个维度,调 Service A
- paperid=2:100 行,4 个维度,调 Service B
- paperid=3:120 行,10 个维度,调 Service C
4 个策略实现的接口都不一样——第一个连入参都不同。你得定义一个足够宽泛的接口来容纳所有实现,结果就是接口里全是 Object 和 Map,类型安全比现在的 if-else 还差。
策略模式适用场景是 "同一种操作的不同算法"——比如不同的折扣计算方式。但这里的 4 个导出是完全不同的操作,公共部分只有"接收请求、返回 Excel"这个壳子。用策略模式,只是把 if-else 换成了工厂 + 接口 + 4 个实现类,代码量一行没少,还多了间接层。
结论:设计模式本身没错,但用错了场景。4 个分支是完全不同的操作,不是同种算法的变体。不采纳,不改。
到这里,4 条建议我们都分析了,每条都有不改的理由,但每条也确实 "看起来对" 。
然后我看到了第 5 条。
建议五:用 Validation 框架重写 200 行校验方法(这条差点毁掉生产)
AI 说:
validateSubmitData有 200 行,全是 if-null-append 的重复模式。应该使用 JSR 380,在 VO 上加@NotNull、@NotBlank注解,一行代码替代 200 行。
先看现在的代码长什么样:
private void validateSubmitData(CompanyRegistVo companyRegistVo, MultipartFile[] file) {
StringBuilder sb = new StringBuilder();
EnterpriseBaseinfo baseinfo = companyRegistVo.getEnterpriseBaseinfo();
int stepNo = baseinfo.getStepNo();
if (stepNo == SysContants.ENTERPRISE_REGIST_STEP_FIRST) { // 第一步
if (baseinfo.getEstablishTime() == null) {
sb.append("企业成立时间必填;");
}
if (StringUtils.isEmpty(baseinfo.getTotalAssets())) {
sb.append("企业总资产(万元)必填;");
// } else if (!ValidateUtils.isNumeric(baseinfo.getTotalAssets())) {
// sb.append("企业总资产(万元)格式不正确;");
}
// ... 还有 15 个类似的 if ...
} else if (stepNo == SysContants.ENTERPRISE_REGIST_STEP_SECOND) { // 第二步
// ... 12 个联系人信息的 if ...
}
if (sb.length() != 0) {
throw new SystemException(sb.toString());
}
}
AI 的重构方案很 "标准":
// VO 上加注解
public class EnterpriseBaseinfo {
@NotNull(message = "企业成立时间必填")
private Date establishTime;
@NotBlank(message = "企业总资产(万元)必填")
private String totalAssets;
// ...
}
// Controller 里一行搞定
@PostMapping("submitRegistInfo")
public Result submitRegistInfo(@Valid @RequestBody CompanyRegistVo vo, BindingResult result) {
if (result.hasErrors()) {
return new Result().fail(result.getAllErrors().get(0).getDefaultMessage());
}
// ...
}
这条原则我也没异议——上一篇文章里,我也是这么推荐的:用 JSR 303 注解 + @Valid,"一行校验逻辑都不应该出现在 Controller 里"。
AI 给的方案,和我自己写的方案,几乎一模一样。
但这次,我对自己推荐的方案产生了怀疑。 因此我们把这段代码的业务逻辑拆开来看清楚——里面至少埋了 3 颗雷,上一篇文章里我没有看到。
第一颗雷:分步校验会丢。
同一个 VO,第一步(stepNo=1)只校验企业基本信息,第二步(stepNo=2)只校验联系人信息。JSR 303 虽然有分组校验(groups = {Step1.class}),但 AI 给的方案里完全没有提分组——它只是把所有 @NotNull 平铺在字段上。
如果按 AI 的方案改,用户提交第一步时,联系人的字段全是空的,@Valid 直接报错——第一步永远过不去。
第二颗雷:错误处理模式变了。
看现在的代码——它用 StringBuilder 收集所有错误,一次性返回:
sb.append("企业成立时间必填;");
sb.append("企业总资产(万元)必填;");
sb.append("企业负债率必填;");
// ... 所有错误攒齐 ...
throw new SystemException(sb.toString()); // 一次性抛给用户
用户提交一次,能看到所有填错的字段,一次改完。
而 @Valid + BindingResult 的标准做法是 result.getAllErrors().get(0)——只返回第一个错误。用户提交 → 看到第一个错 → 改 → 提交 → 看到第二个错 → 改 → 提交 → ……
20+ 个必填字段,用户可能要提交 20 次才能全部通过。 这是一个政府申报系统——用户可能是区县工信局的人,网络慢、浏览器老、耐心有限。你让他提交 20 次吗?要骂人的。
第三颗雷:注释掉的格式校验会被"好心"恢复。
代码里有大量被注释掉的格式校验:
if (StringUtils.isEmpty(baseinfo.getTotalAssets())) {
sb.append("企业总资产(万元)必填;");
//} else if (!ValidateUtils.isNumeric(baseinfo.getTotalAssets())) {
// sb.append("企业总资产(万元)格式不正确;");
}
这些注释说明原作者有意去掉了格式校验——可能是前端已经做了,后端不再重复;也可能是业务上放宽了限制。
但如果 AI "帮忙"重构,它看到"这里有校验逻辑被注释了",很可能在注解方案里"好心"地加上 @Pattern——把原作者故意关掉的校验又悄悄打开了。
结果:以前能提交的合法数据,突然被 "格式不正确" 挡回去了。用户一脸懵X,查了半天不知道哪里变了。
三颗雷任何一颗爆了,都是生产事故。
这条如果真用了:分步校验丢失 → 第一步永远过不去;错误一次只报一个 → 用户提交 20 次;格式校验被恢复 → 合法数据被拒。上线第一天就会有人投诉。
这就是那颗 "差点毁掉生产" 的炸弹。它之所以危险,不是因为建议本身错了,而是因为它太"标准"了——标准到你不会去细看它的实现,标准到你以为"框架都这么用"就不会有问题。
第 6 条看完,我直接把对话窗口关了
第 6 条建议是:用 DTO 替代 JSONObject 传参。
整个 Controller 大量使用
JSONObject作为方法参数,丢失了类型安全。应该定义强类型的 Request/Response 类。
原则完全正确。 但在这个项目里,JSONObject 不是某一个接口要偷懒,而是贯穿全链路的架构决策:
Controller (JSONObject)
→ Service (JSONObject)
→ Mapper (JSONObject)
→ XML (#{companyId})
只改 Controller?到了 Service 还是得转回 JSONObject——多了一层转换,反而更乱。
要真正去掉 JSONObject,需要同时改 Service 接口(18 个方法)、Service 实现(18 个类)、Mapper 接口(18 个文件)、Mapper XML(25 个文件)。这是 80 个文件的联动改造。
而且项目里有大量动态字段:
data.put(SysContants.SESSION_COLUMN_NAME_COMPANY_ID, companyId);
data.put(SysContants.SESSION_COLUMN_NAME_BATCH_NO, batchNo);
这些 key 是运行时从常量拼出来的,DTO 在编译期不知道有哪些字段。
看到这条,我倒回去把前 5 条又重新看了一遍。
前 4 条,每条都有道理,每条也都有"不动"的理由——但这些理由,都是我在理解业务之后才能看出来的。AI 看不到。
第 5 条,AI 不知道有分步校验、不知道错误要一次性返回、不知道格式校验是故意关掉的——它只看到"200 行 if-else = 该用框架"。
第 6 条,AI 不知道 JSONObject 贯穿全链路、不知道改了 Controller 还得改 80 个文件——它只看到"JSONObject = 没类型安全"。
6 条建议,没有一条是"技术上错误的"。但有 1 条会直接炸,有 5 条会引入不同程度的风险,而这个项目没有测试、没有文档、原作者离职两年——任何风险都是不可接受的。
所以我一条都没敢采用。
不是 AI 不行,是它缺了最关键的东西
冷静下来想,AI 的 6 条建议,本质上是一套 "代码形式问题的标准答案" 。
| AI 看到的 | AI 没看到的 |
|---|---|
| 5 次重复的 if-else | 映射值是政府申报标准,改错是合规事故 |
| 4 个结构相似的导出方法 | 差异部分是核心业务逻辑,不是参数配置 |
| Controller 里有 135 行样式代码 | Service 层太薄,下沉只是搬家 |
| paperid 的 if-else 硬编码 | 4 个分支是完全不同的操作 |
| 190 行校验方法 | 分步校验 + 一次性报错 + 故意关掉的格式校验 |
| JSONObject 满天飞 | 全链路 JSONObject,改动量 = 重写半个项目 |
它擅长识别代码的"形式问题"——重复、过长、耦合。但它看不到代码背后的业务决策、架构约束和历史妥协。
前 4 条我之所以能看出"有道理但不能动",是因为我读了代码、理解了业务。第 5 条之所以差点翻车,是因为它的"标准感"太强了——Validation 框架是 2020 年以后 Java 项目的标配,太"应该用了",以至于你不去细看它的实现细节。
第 6 条是转折点——它让我意识到,AI 对上下文的理解是零。 不是它"理解得不够深",是"完全没有理解"。一个建议要你改 80 个文件,但它不知道这个项目一共才多少个文件。
遗留系统重构的"三问法则"
经过这次分析,我总结了一个判断框架(方法论)。面对老系统的任何重构建议,先问三个问题:
第一问:有测试兜底吗?
重构的前提是你能证明改完之后行为和改之前一样。证明的方式只有一种:跑测试。
这个项目没有单元测试、没有集成测试。任何改动都是"盲改"——编译通过,但你不知道某个导出报表的某个字段是不是悄悄变了值。
没有测试的重构,不是重构,是赌博。
第二问:你真的理解业务吗?
AI 看到 5 次重复的 if-else,不知道这 7 个字符串是政府申报表上的标准选项。它看到 190 行校验,不知道分步校验是业务需求、一次性报错是用户体验、注释掉的格式校验是产品经理要求去掉的。
代码是业务决策的产物。 不理解业务就改代码,是耍流氓,等于在不了解承重墙的情况下砸墙装修。
第三问:改错了代价是什么?
| 改动 | 改对的收益 | 改错的代价 |
|---|---|---|
| 提取枚举 | 减少 50 行重复 | 映射值改了 → 5 个报表全错 → 合规问题 |
| 模板方法 | 减少 200 行重复 | 合并逻辑搞混 → 某个报表数据错 |
| 样式下沉 | Controller 变短 | 样式和表头算法脱钩 → 改一个忘一个 |
| 策略模式 | 消除 if-else | 增加间接层 → 调试变难 |
| Validation | 减少 150 行 | 分步校验丢失 + 报错模式变了 → 生产事故 |
| DTO 替代 | 类型安全 | 80 个文件联动 → 无法收场 |
当"改错的代价"远大于"改对的收益"时,不动就是最优解。
写在最后
回到题目。
上一篇文章(《Controller 写了1600行》)里,我给出了完整的重构方案:枚举提取、策略模式、Validation 框架、POI 下沉。改造后 Controller 从 1600 行缩到 200 行。那篇文章的结尾我写:"这才是 Controller 该有的样子。"
那篇文章没说错。但它有一个隐藏前提——你有测试、有文档、有团队维护。
今天这篇,是同一个文件、同样的方案,但视角完全不同:这个项目没有测试、没有文档、原作者离职。在这种条件下,那些"教科书级正确"的方案,每一条都可能是一颗定时炸弹。第 5 条(Validation)差点直接炸了。
两篇文章合在一起,才是完整的判断:先知道什么是好的代码,再知道好的代码不一定现在就能改。
前 4 条,我赌不起。第 5 条,差点赌了。第 6 条,让我看清了整副牌。
这就是资深工程师的价值所在:不是写出比 AI 更优雅的代码,而是知道哪些"优雅"现在碰不得。
给所有面对老系统重构的同行一句话:
代码的"丑"和"危险"是两回事。有些丑代码是定时炸弹,必须马上拆;有些丑代码是承重墙上的裂缝,看着难看,但你一动,整栋楼都可能塌。
分辨这两种"丑",是经验,是判断力,也是 AI 目前还替代不了的东西。
AI重构这个系列还没完。
下期预告:《AI 代码审查实测:给老项目挑 20 个坑,老炮只认 15 个》
下一篇换个玩法——我把整个项目扔给AI做全量代码审查。它能挑出多少真问题?和我这个18年老炮的审查结果比,命中率有多少?
下期见分晓。
如果这篇对你有用,请点个赞、转发给身边还在维护老项目的兄弟。你的支持,是我继续写下去的动力。
我是老炮,18年Java老兵,仍在一线。关注「Java老炮踩坑录」,少踩坑。