返利系统重构实践:从贫血模型到充血模型的渐进式改造
发布时间:2026/9/30 9:18:03来源:尧图网络
从联盟回调进来的每一笔订单最终都要在返利系统里走完“识别商品—计算佣金—判断结算状态—打款”这条链路。我刚接手这套系统时最崩溃的不是业务复杂而是所有逻辑都在一个叫OrderService的类里上万行代码里面塞了订单状态流转、返利计算、佣金分配、维权退款逻辑大部分方法都是先if (order.getStatus() 1)再套一层if (order.getPayAmount() 100)改一个分支就要顺着调用链摸十几个地方。这篇文章就是基于我们团队做的一次渐进式重构把贫血模型慢慢改造成充血领域模型全程用 TDD 兜底不推倒重来边重构边上线。如果你也在面对类似的遗留代码希望这篇能给你一套能直接照做的思路。1. 重构前先看清病根贫血模型在返利系统里的典型症状1.1 一眼识别贫血模型的四个“病理特征”我接手这个系统时代码结构大概是这样的实体类RebateOrderDO里只有一堆private字段和对应的 getter/setter一个业务方法都见不到。所有业务逻辑都被拆碎之后撒在了 Service 层和 Controller 层。这种风格就是典型的贫血模型——领域对象只是一个“数据袋子”真正的规则全在外面。具体到一个返利系统里贫血模型会带来四个特别明显的症状而且越往后越致命第一个症状同一个业务规则有多个实现副本。比如“订单确认收货后返利比例按最近一个佣金规则计算”这个逻辑我在OrderService、RebateTask、DataSyncHandler里见过三个版本而且三个版本的边界条件还不完全一样。有的地方判断了订单金额必须大于 0有的地方没判断有的地方考虑了退款状态有的地方直接忽略了。这种散落状态最坑的点是线上问题往往来自“你以为统一了其实没有”。第二个症状状态流转没有归属。订单状态从“已付款”到“已确认收货”再到“已结算”中间有大量判断。旧代码里这些判断散落在各处if (status 1 callbackType 2)这种魔法数字满天飞。你想梳理状态机得在每个 Service 方法里逐个去搜根本没法一次看清全貌。第三个症状实体之间没有协作能力。返利规则要结合商品类目、用户等级、活动档位、平台扣点一起算。这些数据分散在RebateRuleDO、MemberLevelDO、ActivityDO里。贫血模型下RebateOrderDO对这些依赖一无所知计算逻辑只能在 Service 层把几个对象取出来再手工拼装。一旦规则变化比如新增一个“大促期间佣金翻倍”你就要改 Service 层然后祈祷其他类没有同样的逻辑在等着你。第四个症状测试根本写不动。因为逻辑都在 Service 方法里且大量依赖 Spring 容器和数据库你为了测一个“退款后返利回滚”的逻辑得 mock 掉七八个依赖对象初始化一段完整订单数据。结果就是没人愿意写测试改了代码之后只能靠手工点界面回归回归一次要造一批订单数据痛苦指数极高。1.2 为什么“推倒重写”是绝大多数情况下的错误路径很多团队看到这种代码的第一反应是“重写吧反正也不复杂。”我劝你别冲动。返利系统看起来业务不复杂但坑都在细节里历史订单状态有脏数据、联盟回调有各种异常分支、佣金规则在不停变化、结算报表里全是历史包袱。如果你重写等于在没弄清这些隐性规则的前提下重新发明一遍业务。我当时核算过整套系统有 37 个接口依赖OrderService里的逻辑直接重写至少需要三个月而这三个月里业务还在跑、规则还在变重写出来的系统大概率照搬不了那些“约定俗成”的边界行为。与其赌重写不如走渐进式改造每次切一小块用测试锁住行为再搬到领域模型里改完立刻上线验证。这种做法虽然慢但每一步都安全风险完全可控。2. 领域边界怎么划从订单结算场景练手充血模型2.1 别急着画 UML先顺着“事件流”找聚合边界做充血模型改造最容易犯的错误是一上来就对着数据库表设计实体。数据库表是关系型思维的产物表和表之间可以任意 join但领域模型讲究的是“边界”和“不变量”。我的建议是先抛开表结构从业务事件出发找出一条能自洽的事件流。我当时没有用 Event Storming 那样的正式工作坊就拉了产品和资深的业务同学在白板上把返利订单的全生命周期画了一遍联盟平台推送“订单创建”事件用户完成付款订单状态变为“已付款”系统按商品类目和当前生效规则计算预估返利用户确认收货订单状态变为“已确认收货”返利从“预估”变为“待结算”过了售后期一般 15 天且无维权订单进入“可结算”状态系统执行打款订单状态变为“已结算”如果中间发生退款/维权返利状态要回滚或冻结这条链上最关键的一个不变量是同一笔订单的返利金额在确认收货前后不能随意变化除非触发维权退款等例外事件。这就是领域模型应该保护的“业务不变量”。基于这条事件流我圈定了两个最重要的聚合根RebateOrder返利订单和MemberRebateAccount用户返利账户。第一刀先切RebateOrder因为它是整个系统的核心订单状态、返利计算、结算判断都围绕它转。2.2 第一次改造的目标实体RebateOrder 的字段与方法设计重构不是把 Service 里的字段搬到实体里就行而是要把“行为”搬进去。我设计RebateOrder时遵循一个原则凡是只依赖订单自身和少量规则数据就能确定的逻辑全部沉到实体里凡是依赖外部系统或需要跨订单聚合的逻辑才留在 Service 层。最终落地的RebateOrder实体核心结构大概是这样的public class RebateOrder { private Long id; private Long memberId; private Long parentMemberId; // 上级推荐人 private OrderStatus status; // 枚举不再用 int private Amount payAmount; // 金额不再用 Double private RebateRule rule; // 当前生效的返利规则 private MemberLevel memberLevel; // 用户等级快照 private boolean riskControlled; // 是否命中风控标记 /** * 确认收货后触发把预估返利转为待结算返利 */ public Rebate confirmReceived() { if (status ! OrderStatus.PAID) { throw new IllegalStateException(只有已付款订单才能确认收货); } this.status OrderStatus.CONFIRMED; return calculateRebate(); } /** * 计算当前订单的返利 * 规则基础佣金 付款金额 * 类目佣金率 * 用户等级加成 * 若命中活动档位则使用活动佣金率覆盖 */ public Rebate calculateRebate() { if (payAmount.lessThan(rule.getMinimumAmount())) { return Rebate.zero(this); } Amount base payAmount.multiply(rule.getRate()) .multiply(memberLevel.getRebateFactor()) .subtract(platformDeduction()); // 活动档位覆盖逻辑 if (rule.containsActivity()) { base payAmount.multiply(rule.getActivityRate()) .multiply(memberLevel.getRebateFactor()) .subtract(platformDeduction()); } return Rebate.of(this, base); } /** * 判断是否满足平台扣点条件 */ private Amount platformDeduction() { // 扣点比例根据类目和平台政策浮动 return payAmount.multiply(rule.getPlatformDeductRate()); } }这段代码看起来简单但背后有几个关键决策第一状态从 int 改成枚举。这一步看起来只是类型替换实际上是给状态迁移加了约束。配合confirmReceived()方法状态流转不再由外部随意setStatus(2)来触发而是必须调用领域方法方法内部自己校验前置状态是否合法。第二金额用Amount值对象包裹。旧的OrderDO里payAmount是Double这在涉及“金额比较”时极其危险比如order.getPayAmount() 100这种判断浮点误差会导致有些订单边界判断出错。改造后我用Amount值对象内部用BigDecimal以“分”为单位存储所有比较和运算都走Amount的方法。第三规则对象直接内聚到实体里。rule不是简单的Long ruleId而是一个完整的RebateRule对象实体需要用它计算时就自己去读取不需要 Service 层临时拼接。这种设计下实体就有了自己的行为不再是“贫血”的。后面的 TDD 就是以这些领域方法为测试目标的。3. TDD 红绿重构实战三步把返利计算安全挪进实体3.1 第一步先写“特性测试”锁住现有行为别急着写新行为很多团队一提 TDD 就想着“先写测试再写实现”但在遗留代码上这个顺序要反过来。遗留代码里有一堆积累了多年的隐性行为可能有些行为连产品经理都说不清楚。如果直接按“期望的新行为”写测试很容易把老逻辑里的合理边界给改没了。所以我的做法是先写特性测试Characterization Test从现有代码的运行结果倒推断言把当前行为原样“拍照”下来。对于calculateRebate这个方法我做了这样一件事——挑历史真实订单做测试夹具。我从生产环境导出了一批典型订单数据覆盖各种场景有正常订单、全额退款订单、部分退款订单、命中活动档位订单、高风险风控订单、特殊类目订单等。然后写了一个参数化测试把每个订单喂给旧逻辑把计算结果和状态流转记录作为断言值固定下来。ParameterizedTest CsvSource({ ORDER_A, PAID, 199.00, CONFIRMED, 17.91, ORDER_B, CONFIRMED, 299.00, CONFIRMED, 26.91, ORDER_C, REFUNDED, 199.00, REFUNDED, 0.00 }) void 旧逻辑的返利计算结果特性快照(String orderId, String status, double amount, String expectedStatus, double expectedRebate) { OrderDO order orderRepository.find(orderId); BigDecimal rebate oldOrderService.calculateRebate(order); assertEquals(expectedRebate, rebate); }这一步不是为了追求完美而是为了建立“行为基线”。有了这套基线后续任何重构动作失败测试会立刻告诉你哪个行为和你预期的不一致。3.2 第二步红—绿—重构用一次真实迁移演示完整的 TDD 循环有了特性测试做底座我就开始正式用红绿循环来迁移逻辑了。拿“计算返利”举例当时OrderService.calculateRebate的实现有一堆判断核心流程是订单状态不是CONFIRMED且不是PAID返利为 0订单金额小于规则最低门槛返利为 0正常返利 金额 × 佣金率 × 用户等级加成命中活动规则则用活动佣金率替换基础佣金率红阶段我先把这些逻辑“翻译”成了针对RebateOrder实体的测试方法期望行为与旧逻辑保持一致但此时RebateOrder.calculateRebate()还不存在编译都过不了测试是红的Test void 确认收货后_返利按规则计算() { RebateOrder order sampleOrder() .withStatus(OrderStatus.CONFIRMED) .withPayAmount(Amount.of(199.00)) .withRule(ruleOf(手机数码, 0.15, 0.90)) .build(); Rebate rebate order.calculateRebate(); assertEquals(Amount.of(26.865), rebate.getAmount()); }绿阶段为了让测试变绿我把旧逻辑原样复制进RebateOrder.calculateRebate()不做任何优化只做行为和类型的移植。这一步最关键的要求是复制而不是重写。旧逻辑哪怕看起来不合理也要先原样搬进来等测试通过后再稳步优化。我见过不少人迁移时顺手“优化”了一下逻辑结果行为和旧系统对不上线上出问题这是大忌。// RebateOrder.java 初次实现原样搬运旧逻辑 public Rebate calculateRebate() { Amount result; if (status ! OrderStatus.CONFIRMED status ! OrderStatus.PAID) { result Amount.zero(); } else if (payAmount.lessThan(rule.getMinimumAmount())) { result Amount.zero(); } else { result payAmount.multiply(rule.getRate()).multiply(memberLevel.getRebateFactor()); if (rule.containsActivity()) { result payAmount.multiply(rule.getActivityRate()).multiply(memberLevel.getRebateFactor()); } result result.subtract(payAmount.multiply(rule.getPlatformDeductRate())); } return Rebate.of(this, result); }重构阶段测试变绿之后我再开始清理代码。比如把零返利的判断提取成一个isEligibleForRebate()方法把活动覆盖逻辑抽成独立方法让计算链更清晰。这个阶段因为有测试保护我可以大胆调整结构只要测试保持绿色行为就一定是对的。3.3 把这个循环复制到其他业务方法上的节奏感单个方法迁移跑通后剩下的就是重复这个循环把一个个业务方法从 Service 搬到实体里。但这里要特别注意节奏不能贪多。我的经验是一次只迁移一个“业务动作”迁移完立刻跑整套回归测试然后在上线前用对比对账确认线上行为一致。如果一个迁移涉及多个状态变更比如“确认收货”同时要推进返利状态、更新订单状态、写审计日志那就拆成三步来做先迁状态判断再迁返利计算最后迁日志记录。一个方法迁移的时间最好控制在半天到一天以内。如果超过一天还没变绿说明这个方法的逻辑边界比预想的大需要停下来重新拆解而不是硬扛。4. 渐进式改造的节奏把控保证每天都能“稳住线上”4.1 用“并行实现 结果比对”降低灰度期风险把逻辑从 Service 挪到实体最怕的是迁移后线上计算结果和原来不一致。虽然我有特性测试兜底但真实环境的边界条件比测试数据丰富得多。所以我在迁移期间用了一个额外的保险并行实现。具体做法是在迁移后的第一个发布周期内线上请求同时走旧逻辑和新逻辑但对外只返回旧逻辑的结果。系统会把新逻辑的计算结果记录下来和一个对账任务比对。如果发现同一笔订单新旧逻辑计算结果不一致就立刻告警人工介入分析。比如下面这个简化版的开关逻辑public Rebate calculateRebate(OrderDO order, RebateOrder rebateOrder) { // 旧逻辑走原来的 Service 实现 Rebate oldResult oldCalculator.calculateRebate(order); // 新逻辑走领域实体实现 Rebate newResult rebateOrder.calculateRebate(); if (!oldResult.equals(newResult)) { discrepancyRecorder.record(order.getId(), oldResult, newResult); } return oldResult; // 灰度期仍返回旧结果 }这个结果比对跑了整整两个星期发现了两处差异一处是历史脏数据导致的状态不一致另一处是活动规则里“叠加”和“覆盖”逻辑的边界差异。这两处都是单测很难覆盖到的但结果比对直接抓出来了。等比对结果完全一致后我才把新逻辑正式切到线上返回路径同时删掉旧实现。4.2 不搞大爆炸用“一次一个接口”的方式逐步切换调用方RebateOrder改造完了但系统里还有一堆外部调用方需要切换。我的切流策略是不搞大跳变而是每次只切一个调用方。比如系统里有三个地方调用了旧的计算逻辑一个是订单回调处理器一个是日结定时任务还有一个是报表模块。我按依赖层次排序报表模块只读不影响主流程先切订单回调处理器是主链路后切日结定时任务涉及资金最后切。每个调用方切换后都单独观察一两天确认线上无异常再切下一个。每次切换前我会更新对应的测试用例确保测试覆盖到新调用路径。切换后我会盯一版线上日志重点看是否有新的异常报错和计算结果告警。4.3 团队协作约定重构期间禁止“顺手改业务”渐进式重构有个特别容易踩的坑重构到一半业务同学提了新需求代码搬运时顺手就把业务逻辑改了。这样测试一旦失败你分不清是重构引入的问题还是新需求带来的变化。我们当时的约定是重构分支只做结构变化不做行为变化新需求必须走单独的需求分支合入前先解决冲突。这个约定让我们在回溯问题时省了很多力气。有一次线上对账发现差异我直接定位到是当天新需求分支改了一个规则配置加载逻辑和我们的重构分支产生了冲突。因为没有把新需求和重构混在一起问题十分钟就定位了。5. 重构过程中必须绕开的四个经典陷阱5.1 陷阱一实体里访问关联对象撞上“懒加载”异常把逻辑搬进实体后最常遇到的一个运行时异常是LazyInitializationException。旧逻辑在 Service 层通过Transactional保证会话打开实体方法执行时访问rule、memberLevel等关联对象都没问题。但实体方法被调用时如果事务还没开启或已经关闭懒加载就会直接报错。我踩过这个坑后来总结出的解决思路有两条。要么确保实体方法的调用点仍然处于事务上下文中像这样把事务边界保留在 Service 层Service public class RebateOrderApplicationService { Transactional public Rebate confirmAndCalculate(Long orderId) { RebateOrder order rebateOrderRepository.load(orderId); return order.confirmReceived(); } }要么在实体加载时就把需要的关联对象全部显式初始化JOIN FETCH避免在实体方法里触发懒加载。我个人更偏向第一种因为事务边界放在应用服务层是合理的实体内部不需要关心事务问题。5.2 陷阱二金额精度用 Double 导致边界判断失真旧的OrderDO把金额和佣金率都定义成Double这在计算“返利金额是否大于 0”“金额是否达到返利门槛”时存在精度隐患。比如 0.1 0.2 不等于 0.3 这种问题在订单金额判断上可能表现为某个 99.99 元的订单被错误地判定为不满足 100 元门槛虽然差得不多但对用户来说就是“明明返利了为什么没钱”。我迁移到Amount值对象时额外做了一步扫了一遍所有和金额比较相关的业务分支把边界判断统一改成compareTo语义比如payAmount.compareTo(minimumAmount) 0而不是payAmount 100。这样迁移完不是一个 BigDecimal 替换了 Double而是一整套金额语义都被理顺了。5.3 陷阱三重复回调把同一笔订单算了两遍返利联盟平台的订单回调不是一次性的经常会有重复通知尤其是网络抖动或平台重试时。旧代码里对于重复回调没有做严格的幂等控制状态判断的漏洞导致同一笔订单偶尔会被重复计算返利用户账户里多出钱来。我在重构confirmReceived()方法时把幂等判断直接做进了状态机内部public Rebate confirmReceived() { if (status OrderStatus.CONFIRMED || status OrderStatus.SETTLED) { // 重复回调直接返回当前返利不重复计算 return currentRebate(); } ... }同时我在订单表上加了(order_id, status)的唯一索引从数据库层面防止状态重复推进。这个操作是在并行实现阶段发现的线上隐患如果不是结果比对系统提示“同一订单被计算两次”我可能要在重构完成后才会撞上。5.4 陷阱四把关联对象的读取方式从依赖注入改成直接引用旧代码里返利规则是通过ruleService.getRule(order.getCategoryId())动态获取的。迁到实体后我一度想把规则对象作为方法参数传进去但后来发现这样会让实体方法显得啰嗦而且调用方容易漏传。最终方案是让实体内部持有规则对象在加载时通过仓储补全public class RebateOrder { ... public Rebate calculateRebate() { RebateRule effectiveRule rule ! null ? rule : ruleRepository.findEffectiveRuleByCategory(categoryId); ... } }这种做法降低了调用方的负担但依赖了仓储。说实话这是一种取舍实体可以依赖抽象接口仓储但最好别直接依赖具体的 Spring Bean。我在实现时用了一个RuleResolver接口注入测试时可以方便替换成桩实现线上的具体实现则由 Spring 管理。6. 重构后的收益与个人复盘6.1 数据层面的变化行为从 Service 真正回到了模型里重构持续了大约六周只动了订单结算这一条主链路。最终结果OrderService的代码量从 8200 行降到了 2400 行剩下的主要是对外接口编排和事务协调逻辑新增领域实体方法 18 个其中核心方法都有配套单元测试单元测试从原来基本为零增加到 126 个覆盖了订单状态流转、返利计算、结算判断等核心分支并行对账期间共发现并修复 7 处历史行为差异其中有 2 处会导致用户返利金额算错从重构开始到完全切换新逻辑线上零故障未发生一笔返利计算错误最直接的感受是改业务需求和修 bug 的效率明显提升了。以前改一个“活动佣金覆盖逻辑”要同时在 Service 和回调处理器里各改一遍现在只需要改RebateOrder里的一个方法测试跑一遍就知道有没有破坏其他分支。6.2 关于遗留代码重构的三条个人体会第一条不要试图一口气把所有领域模型都重建。只挑一个核心业务场景比如订单结算改造得足够好就已经能带来明显收益。剩下的是把这套手法复制到其他场景。第二条TDD 在重构中的价值不是“保证新功能正确”而是“保证旧行为不被破坏”。特性测试加并行对账这两道防线缺一不可。单测能守住逻辑单元对账能守住真实数据组合起来才敢每天合代码。第三条重构本身就是一种和团队、业务建立信任的过程。当你用数据告诉他们“重构不会出乱子还发现了几处历史 bug”后续推进其他模块改造就顺畅得多。我自己在第二个月的报表模块改造上就明显感觉到大家的配合度完全不一样了。最后再分享一个实操技巧每迁移一个方法我都在代码注释里留一个REFACTOR_TRACE标记写明“此方法自 OrderService#xxx 迁移迁移前行为基线见测试用例 xxx”。这样三个月后有人回看这段代码能顺着痕迹找到当时的决策背景不至于面对一堆“看起来没问题但不知道为什么要这么写”的新代码。这套打法不是我发明的它就是把 TDD、领域建模、灰度切流这些老工具组合到一起再加上一点耐心。面对遗留代码真正稀缺的不是技术而是“敢一点一点拆”的定力。
网站建设高端定制企业官网