☰
返利系统重构实践:从贫血模型到充血模型的渐进式改造
2026/9/30 9:18:02 网站建设 项目流程

从联盟回调进来的每一笔订单,最终都要在返利系统里走完“识别商品—计算佣金—判断结算状态—打款”这条链路。我刚接手这套系统时,最崩溃的不是业务复杂,而是所有逻辑都在一个叫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 那样的正式工作坊,就拉了产品和资深的业务同学,在白板上把返利订单的全生命周期画了一遍:

  1. 联盟平台推送“订单创建”事件
  2. 用户完成付款,订单状态变为“已付款”
  3. 系统按商品类目和当前生效规则计算预估返利
  4. 用户确认收货,订单状态变为“已确认收货”,返利从“预估”变为“待结算”
  5. 过了售后期(一般 15 天)且无维权,订单进入“可结算”状态
  6. 系统执行打款,订单状态变为“已结算”
  7. 如果中间发生退款/维权,返利状态要回滚或冻结

这条链上最关键的一个不变量是:同一笔订单的返利金额在确认收货前后不能随意变化,除非触发维权退款等例外事件。这就是领域模型应该保护的“业务不变量”。

基于这条事件流,我圈定了两个最重要的聚合根: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的实现有一堆判断,核心流程是:

  1. 订单状态不是CONFIRMED且不是PAID,返利为 0
  2. 订单金额小于规则最低门槛,返利为 0
  3. 正常返利 = 金额 × 佣金率 × 用户等级加成
  4. 命中活动规则,则用活动佣金率替换基础佣金率

红阶段:我先把这些逻辑“翻译”成了针对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、领域建模、灰度切流这些老工具组合到一起,再加上一点耐心。面对遗留代码,真正稀缺的不是技术,而是“敢一点一点拆”的定力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询