ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

状态机与策略模式实战:重构订单支付系统

2026/9/9 3:20:34 拓冰建站 浏览量
状态机与策略模式实战:重构订单支付系统 1. 第22天的学习规划为什么这个节点值得复盘今天是我给自己定下的百日进阶计划第22天按学习日记DAY22的记录来看正好跨过了五分之一的分水岭。很多人会把学习计划做成前三天打鸡血、后两周随缘的节奏但真正能拉开差距的恰恰是第20天到第30天这段平台期——新鲜感消退基础概念已经过了一遍但又还没到能自如输出的程度。我刻意把这一天的主题定为状态机与策略模式在真实业务中的落地,而不是继续堆新知识就是想在这个节点把前面零散学的东西串成体系。先说结论第22天最适合做三件事——横向串联旧知识点、挑一个真实场景做深度实战、把踩过的坑固化成checklist。知识增长不是线性的前21天我分别学了Java集合源码、MySQL索引原理、Redis缓存策略、Spring Bean生命周期等等单看每一块都懂但合在一起就发现根本不会用。这就像学了一堆食材的处理方法却没正儿八经做过一道菜。所以DAY22的内容我不再开新坑而是围绕如何用策略模式重构一个多支付渠道订单系统这个综合案例把集合、设计模式、Spring依赖注入、单元测试全部揉进去。这个思路适合谁如果你也在做长期自学计划尤其是走技术路线但发现自己学得快忘得更快那这篇学习日记里梳理的方法论和实战过程可以直接抄作业。哪怕你不写代码里面关于刻意练习和复盘机制的部分对学任何技能都有参考价值。2. 状态机与策略模式两个容易混淆的思路辨析2.1 从需求出发倒推技术选型DAY22的实战案例背景是我自己搭的一个简化版电商订单系统支付环节对接了微信支付、支付宝和模拟余额三种渠道。最初的实现很粗暴就是一个大service类里面塞满if-else判断渠道类型再根据订单状态status字段决定下一步动作。代码大概长这样public PayResult pay(Order order, String channel) { if (wechat.equals(channel)) { // 微信支付逻辑 if (order.getStatus() 1) { // 调微信API } else if (order.getStatus() 2) { // 处理退款 } } else if (alipay.equals(channel)) { // 支付宝支付逻辑 } else if (balance.equals(channel)) { // 余额支付逻辑 } // 后面还有更多渠道和状态分支... }这种写法在渠道只有两三个、状态流转只有两三种的时候还能勉强维护。但一旦加入待支付、已支付、已退款、退款中、已关闭五个状态再叠加支付成功回调、退款申请、超时关闭等多个动作if-else的组合爆炸会让方法体膨胀到几百行。更麻烦的是每加一个新渠道就要在所有涉及状态判断的地方各加一段分支漏掉一处就是线上事故。这时候我意识到按照渠道把逻辑切成多份和按照状态把流转理清楚其实是两个维度的需求正确做法是把它们拆开。渠道差异适合用策略模式解决订单状态流转适合用状态机思路管理。两者并不冲突策略模式管的是同一个动作在不同渠道下怎么实现状态机管的是什么状态下允许执行什么动作、动作完成后迁移到什么状态。2.2 策略模式的核心价值替换与隔离策略模式不是新鲜东西教科书里的UML图大家都会画但真正落地时很多人搞不清它解决了什么问题。我用大白话解释策略模式就是把做什么和怎么做分开。上层调用方只关心我要支付这笔订单至于微信怎么签名、支付宝怎么加密、余额怎么扣减那是各自策略内部的事。这样一来有三个直接好处。第一新增渠道时调用方代码零改动只需要新增一个实现类并在配置里注册第二每个渠道的代码独立成类微信支付的sign逻辑再复杂也不会污染支付宝的代码第三单元测试变得好写我可以单独测微信策略不用启动整个Spring容器。2.3 状态机不是非要引入框架说到状态机很多人第一反应是引入Spring StateMachine或者阿里的COLA状态机组件。但DAY22我做了一个刻意的选择不引入任何状态机框架用手写的方式实现核心逻辑。原因有二一是这个订单状态流转并不算极其复杂引入框架反而要学习框架的DSL语法成本大于收益二是手写一遍能让我彻底理解状态机的本质——它就是一张状态 事件 - 新状态的映射表。用更直白的方式说状态机就是一张二维表行是当前状态列是触发事件交叉点是下一个状态。如果交叉点是空的就表示该状态下不允许这个事件发生。这张表在代码里最自然的映射就是HashMap嵌套外层key是当前状态内层key是事件value是目标状态。3. 订单支付场景的策略模式落地实操3.1 定义策略接口与统一上下文DAY22的第一步我把之前的支付逻辑重构为策略模式。先定义一个顶层接口让所有渠道实现它public interface PaymentStrategy { // 渠道标识例如 wechat、alipay、balance String getChannel(); // 执行支付context里封装订单信息和扩展参数 PayResult pay(PayContext context); // 渠道特有的回调验签处理 boolean verifyCallback(CallbackRequest request); }这个接口设计的细节值得说一下。第一getChannel()是必须的它用来在运行时定位具体策略相当于给每个实现类贴了个标签第二pay方法的参数我特意封装成PayContext而不是直接传Order对象因为后续很可能要加IP、设备号、优惠券ID等参数直接传Order会导致方法签名频繁变动第三verifyCallback单独抽出来因为支付回调的处理逻辑和主动支付差别很大合并到一个方法里会显得臃肿。PayContext我用一个简单的POJO承载包含订单号、金额、用户ID和Map类型的扩展字段。有人会问既然有扩展字段了为什么不直接把Order也放进去我的经验是PayContext是本次支付这个动作的上下文Order是订单这个业务实体的持久化对象两者生命周期和关注点都不同混在一起容易让策略实现类不知不觉就去操作订单的其他字段破坏封装性。3.2 三种渠道策略的具体实现微信支付策略类的核心逻辑长这样我抽了关键代码出来Component public class WechatPaymentStrategy implements PaymentStrategy { Override public String getChannel() { return wechat; } Override public PayResult pay(PayContext context) { // 1. 构建微信统一下单请求参数 MapString, String params new HashMap(); params.put(out_trade_no, context.getOrderNo()); params.put(total_fee, String.valueOf(context.getAmount().multiply(new BigDecimal(100)).intValue())); params.put(body, context.getSubject()); // 2. 生成签名微信用MD5或HMAC-SHA256这里省略具体实现 String sign WechatSignUtil.sign(params, wechatApiKey); params.put(sign, sign); // 3. 调用微信API // 4. 解析返回结果并封装为统一PayResult return new PayResult(success, prepayId, rawResponse); } }这里有几个埋在细节里的坑。金额计算我用了BigDecimal并且转成分微信支付以分为单位时通过multiply(new BigDecimal(100)).intValue()避免了double运算的精度问题签名参数放进Map里之后必须先按key排序再拼接成字符串微信对这个顺序有严格要求我第一天联调时就栽在这里每次调微信接口前都要先查一遍订单当前状态防止重复下单这个检查放在策略内部还是外部当时纠结了很久最后决定放在策略内部因为不同渠道对重复支付的容忍度不一样余额支付只要幂等校验就够了微信支付还需要考虑防重回调。支付宝策略类的支付流程本质相同区别在于签名算法用的是RSA2参数格式是表单拼接回调验签需要把异步通知的所有参数除去sign字段重新排序签名再比对。余额支付则完全在本地完成先检查余额充足再扣减账户余额并更新订单状态。三种策略放到一起对比差异点一目了然这正是策略模式最直观的价值。3.3 策略工厂与Spring整合有了策略实现类剩下的关键问题是如何根据前端传来的字符串渠道类型找到对应的策略对象。最原始做法是写个工厂类里面塞if-else判断来new对象但那等于把原来的分支逻辑换了个地方写没有任何进步。既然项目用了Spring就应该利用Spring的容器管理能力。我的方案是这样的在Spring配置类里把所有PaymentStrategy的实现类注入到一个Map中key就是getChannel()的返回值value就是策略对象本身Configuration public class PaymentStrategyConfig { private final MapString, PaymentStrategy strategyMap new HashMap(); public PaymentStrategyConfig(ListPaymentStrategy strategies) { for (PaymentStrategy strategy : strategies) { strategyMap.put(strategy.getChannel(), strategy); } } public PaymentStrategy getStrategy(String channel) { PaymentStrategy strategy strategyMap.get(channel); if (strategy null) { throw new IllegalArgumentException(不支持的支付渠道: channel); } return strategy; } }Spring会自动把容器里所有PaymentStrategy接口的实现类收集成一个List注入进来我再遍历放到Map里。这样以后新增渠道只要实现接口并标注Component策略自动注册无需改动任何现有代码。我实测过这个方案的扩展性非常好唯一要注意的是getChannel()绝对不能返回null或重复否则会出现策略覆盖排查起来比较隐蔽。4. 订单状态机的建模与实现细节4.1 状态定义与状态转移表策略模式解决的是渠道差异接下来是把订单生命周期的流转规则理清楚。我先穷举了订单在这一阶段可能涉及的所有状态和事件当前状态触发事件目标状态允许的操作待支付支付成功回调已支付修改库存、发送通知待支付用户取消已关闭释放预占库存待支付超时未付已关闭定时任务批量处理已支付申请退款退款中调用支付渠道退款接口退款中退款成功回调已退款记录退款流水退款中退款失败已支付还原订单状态并告警已支付发货已完成填写物流单号这个表不只是拿来画图的而是要直接作为代码里的配置存在。我把它翻译成了Java里的结构public enum OrderState { PENDING_PAYMENT, PAID, REFUNDING, REFUNDED, CLOSED, COMPLETED } public enum OrderEvent { PAY_SUCCESS, USER_CANCEL, TIMEOUT, REFUND_APPLY, REFUND_SUCCESS, REFUND_FAILURE, SHIP } // 状态机核心配置State - Event - TargetState MapOrderState, MapOrderEvent, OrderState stateMachine new HashMap();状态从PAID迁到REFUNDING时我不仅更新了状态值还额外做了一件事——把退款单号、退款原因、操作人ID这些业务字段一并写入订单扩展表。这其实是很多状态机实现容易忽略的点状态迁移不是单纯的枚举值替换它往往伴随一系列业务动作。所以我的建议是不要在状态机引擎里直接写业务逻辑而是在迁移成功的事件回调里做保持状态流转规则的纯粹性。4.2 状态迁移的执行与校验逻辑状态机的执行入口我设计成一个统一的方法接收当前状态、事件和业务上下文返回目标状态public OrderState transition(OrderState currentState, OrderEvent event) { // 查表获取目标状态 MapOrderEvent, OrderState stateTransitions STATE_TRANSITIONS.get(currentState); if (stateTransitions null) { throw new IllegalStateException(非法状态: currentState); } OrderState targetState stateTransitions.get(event); if (targetState null) { throw new IllegalStateException(状态[ currentState ]不允许触发事件[ event ]); } return targetState; }这个实现看着简单但真正难的是要保证校验—迁移—落库的原子性。我遇到的第一个问题是并发场景下的状态覆盖用户和支付回调同时操作一笔订单用户点取消回调说支付成功两个请求同时读到待支付状态各自执行自己的迁移逻辑后写入的覆盖先写入的订单状态就乱了。解决方案是给订单表的状态更新加上乐观锁UPDATE t_order SET order_status #{newStatus}, version version 1 WHERE order_no #{orderNo} AND order_status #{oldStatus} AND version #{version}如果更新的行数为0说明当前状态已被其他请求修改这时候要抛异常或触发重试机制。DAY22的实战里我专门写了一个并发测试类用线程池同时模拟取消订单和支付成功回调两个操作跑了几十轮确认最终状态完全由数据库的行锁和乐观锁控制才敢把它作为结论写进学习笔记。4.3 表驱动配置与硬编码的取舍状态机的映射表我用的是内存里的static final常量没有存数据库。有人会建议状态流转规则应该做成可配置的放数据库里方便运营调整。DAY22我的判断是订单状态流转是强业务规则正常情况下不允许运营随意修改已支付能不能直接改已完成之类的问题放数据库反而增加了出问题的可能性。真正需要动态配置的比如支付渠道的开关、费率的阈值那是另一类配置不该和状态机混在一起。就好比交通信号灯的红绿切换顺序可以写死在程序里但某个路口高峰期延长绿灯时长这是参数调节不是规则变更两者要区分开。当然如果你做的是审批流引擎这类需求极多变的场景状态机规则外置到数据库或配置文件是合理的。取舍的关键在于规则变化的频率和规则变化由谁发起。技术人最容易犯的错就是看到一个模式好用就直接套不管业务场景是否匹配。我前几天的学习笔记里就吐槽过自己为了用设计模式而用设计模式最后写出来的代码比原来的if-else还绕。5. 实战中的典型报错与排查过程5.1 策略Map注入为空的问题第一次把策略配置类写好启动项目时getStrategy(wechat)直接抛了空指针原因是strategyMap是空的。排查思路先确认WechatPaymentStrategy类上的Component是否正确标注再确认Spring的包扫描路径是否覆盖到了这个类。当时我的配置类是放在com.example.config包而策略实现在com.example.payment.strategy包主启动类在com.example理论上都能扫到。但你猜真正的问题是什么我在PaymentStrategyConfig的构造方法里直接使用List 注入和Configuration配合没问题问题出在我同时在strategyMap的put操作里调用了getChannel()而这个方法在构造阶段依赖的某个配置属性还没初始化完成。解决方法也很简单把初始化从构造方法改为PostConstruct或者改用ApplicationContextAware的afterPropertiesSet回调。这让我意识到Spring Bean的初始化顺序是个总被忽视的坑构造方法里能别做复杂操作就别做。我把这个point写进了DAY22的踩坑记录里顺手复习了一遍Spring Bean的生命周期。5.2 状态机迁移丢事件的问题联调阶段发现一个诡异问题订单从已支付申请退款后状态变成了已退款但并没有调用渠道的退款接口。追了很久发现是我在测试数据里手动把订单状态改成了退款中而代码里申请退款这个事件执行时会先检查当前状态是否为已支付只有已支付才能发起退款。可我手改的状态是退款中等于跳过了前置校验直接进入了一个非法状态。状态机表里退款中状态下并没有定义申请退款这个事件按逻辑应该抛异常才对但实际却执行成功了。问题根源在于我的service层调用状态机之前还自己写了一段状态判断的代码这段判断和状态机里表的规则不一致出现了两套标准。后来我把所有状态变更的入口全部收敛到状态机的统一方法里删掉了service层手动判断状态的代码这类问题就再没出现过。这个教训很重要状态机最忌讳的是一处规则多处实现必须保证全系统只有状态机引擎这一个地方能改状态值不然迟早会出线上事故。5.3 金额精度与浮点运算的坑写余额支付策略时我一开始用的是double类型存金额测试时发现余额从100块扣了33.33再充进66.67最后余额变成了100.00000000000001。这就是经典的浮点数二进制表示误差。虽然线上订单表最终存库用的是Decimal类型但代码里一旦用double走了一圈误差就可能带进别的计算逻辑。我在DAY22的学习笔记里特别强调涉及金额一律用BigDecimal且所有运算走String参数的构造器或valueOf不要直接new BigDecimal(double)。还有一个小坑是BigDecimal的equals和compareTo的区别equals会比较精度所以new BigDecimal(1.0)不等于new BigDecimal(1.00)而compareTo只比较数值这两者搞混了会在金额比较时出现莫名其妙的false。当时测试用例里就因为这个踩了一脚。测试代码里金额断言一律用compareTo再结合setScale统一小数位这个问题就没再犯过。6. 针对DAY22学习的复盘与后续优化方向把整个实战完整跑通后我做了一次系统性的复盘发现虽然整体思路是对的但代码的组织还有几处可以优化。比如策略工厂里我只是暂存了策略Map后面应该结合Spring的Qualifier做更灵活的命名注入状态机的映射目前是硬编码在内存里虽然不推荐放数据库但可以考虑用枚举表驱动的方式进一步消除重复代码。另外我发现策略模式和状态机的组合其实可以继续往深挖支付的发起支付和支付回调本质上也是两个不同的事件它们分别触发不同的状态迁移但都依赖同一个渠道策略。如果后续要接新的聚合支付平台比如PayPal或者国际信用卡渠道策略实现类的增长会让工厂方法越来越长。到时候可以考虑把策略注册改为Spring的自动装配通过自定义注解BeanPostProcessor来收集策略代码会更加优雅。我在DAY22的回家路上还想到一个点子给每个策略实现类加一个getRetryTimes()方法表示该渠道在支付超时时的自动重试次数。这样不同的渠道可以根据自身接口特点设置不同的重试策略微信可以重试3次余额支付则完全不重试因为它一旦扣款成功就不存在超时问题。这样把策略模式的使用场景又扩大了一层。关于学习的方法论DAY22给我的最大触动是输出倒逼输入。前21天我都是被动地看视频、做笔记、敲示例代码看似很努力其实都是舒适区里的重复。今天通过这个综合实战我被迫去查了Spring源码里List注入的实现原理、BigDecimal的底层存储方式、数据库乐观锁的生效条件——这些都是以前知道一点但从没深究的东西。如果你也在做类似的学习计划我强烈建议把第22天这样的时间点用来做一次综合实战或项目复盘而不是继续一本正经地学新章节。只有当你需要用到那些分散的知识并把它们组装起来时那些知识才真正从知道变成掌握。我个人的习惯是每10天一个周期总结一次DAY22正好踩在这个节奏上。下一步我打算把状态机这部分封装成一个小工具包腾出更多精力研究订单超时取消的延迟队列实现到时再继续更新学习日记。