老炮踩坑录 · A01· AI 时代系列·基于「企业融合平台」真实源码·关键词AI 重构 · 上下文缺失 · 重构的隐藏前提上一篇我花了三天把一个停更多年的老项目跑了起来。跑起来之后我做了件很多人会做的事把最扎眼的那个文件喂给了 AI让它给重构建议。这个文件是EnterpriseRegistController类总共1600 行是整个项目里最长的 Controller。打开它的第一眼任何有代码洁癖的人都会皱眉——Excel 导出、参数校验、业务逻辑、样式设置全糊在一个文件里同一段整数转中文的 if-else 复制粘贴了 5 遍。看过上一篇文章《Java Controller 写了1600行怎么办真实项目的胖Controller重构思路》的朋友可能还记得那篇文章我拆解了这个 Controller 的 5 个反模式给出了如果重来我会怎么改的重构方案——枚举提取、策略模式、Validation 框架一个不落。那篇写的是设计原则。今天这篇是另一个视角。我把同样的代码喂给 AI它给了 6 条建议——和我在上一篇文章里给出的方案几乎一模一样。前 4 条我一边看一边点头。第 5 条我看完差点冒冷汗。第 6 条看完我直接把对话窗口关了。同一个文件同样的方案为什么上一篇文章说应该这么改这一篇却说一条都没敢用这就是本文要聊的事。先看看这个文件长什么样在逐条分析之前我们得先知道这 1600 行到底都写了啥。我把它按功能拆成 5 块功能区域行数范围行数做了什么注册校验validateSubmitData167-357190分两步校验企业注册信息20 个字段企业信息导出backExportEnterprise543-704160遍历企业数据逐字段转中文导出 Excel诊断导出exportPlustekDiagnosis737-1101365动态二级表头 合并单元格 冻结列三个模型导出Platform/Factory/Cloud1110-1455345结构和上面类似但维度不同Excel 样式表头/内容/合并1463-1598135POI 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 (MapString, Object dataMap : applyInfoData) { item new HashMap(); item.put(enterpriseName, dataMap.get(enterpriseName)); // ... businessType 转换 ... // ... enterpriseTurnover 转换 ... // 分数 MapString, Object data xxxService.diagnoseData(rapplyid); // 按维度名称映射到字段 dataExcel.add(item); } elecExcelExportUtil.exportByTpl(response, N, 某某报表);AI 说“抽取抽象基类定义模板方法子类覆盖差异部分。”三个方法确实像三胞胎。但仔细看差异不在细枝末节而在核心数据PlatformFactoryCloud调用的 ServicereportAppServicereportCapabilityServicereportCloudService维度字段数5 个4 个10 个维度名称应用服务能力等生产现场优化等开发设计优化等导出模板appsRescxResdeRes如果强行抽取模板方法抽象方法会占子类 80% 的代码量——模板本身只剩 20% 的公共部分。如果新增一个导出类型时你还是要从头写核心逻辑模板能复用的只有遍历和导出调用。更别说第四个方法exportPlustekDiagnosis是个彻底的异类——365 行动态二级表头算法和其他三个完全没有公共部分。结论结构相似 ≠ 可以合并。差异部分是核心业务逻辑不是参数配置。不采纳不改。建议三Controller 里的 Excel 样式代码应该下沉到 ServicecreateHeadCellStyle、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 个分支各自在做什么paperid0365 行动态二级表头不传applyInfoDatapaperid1110 行5 个维度调 Service Apaperid2100 行4 个维度调 Service Bpaperid3120 行10 个维度调 Service C4 个策略实现的接口都不一样——第一个连入参都不同。你得定义一个足够宽泛的接口来容纳所有实现结果就是接口里全是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第一步stepNo1只校验企业基本信息第二步stepNo2只校验联系人信息。JSR 303 虽然有分组校验groups {Step1.class}但 AI 给的方案里完全没有提分组——它只是把所有NotNull平铺在字段上。如果按 AI 的方案改用户提交第一步时联系人的字段全是空的Valid直接报错——第一步永远过不去。第二颗雷错误处理模式变了。看现在的代码——它用StringBuilder收集所有错误一次性返回sb.append(企业成立时间必填;); sb.append(企业总资产万元必填;); sb.append(企业负债率必填;); // ... 所有错误攒齐 ... throw new SystemException(sb.toString()); // 一次性抛给用户用户提交一次能看到所有填错的字段一次改完。而ValidBindingResult的标准做法是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 XML25 个文件。这是 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老炮踩坑录」少踩坑。