资讯详情

策略模式+工厂模式:重构审核模块,终结if-else加节点恐惧

📅 2026/10/14 3:58:36 | 华诺云谱 👁 阅读
策略模式+工厂模式:重构审核模块,终结if-else加节点恐惧
接手过审核类系统的同学一定秒懂那种加节点恐惧症。业务方在群里轻飘飘来一句大额订单再加一道财务复核今天上线行吗你看着 controller 里三屏长的 if-else心里只冒出一个念头又要动那段谁都不敢碰的代码了。我当时负责的电商订单审核模块就是这样一步步被需求逼着从一个大接口堆逻辑重构成了策略模式工厂模式实现单接口适配多审核节点的架构。这篇文章就把完整思路、代码落地、以及后来踩过的坑都摊开讲给正在跟审核流死磕的朋友一个可直接抄作业的参考。1. 这个需求是怎么一步步逼出这套组合方案的1.1 先描述一个真实的审核场景做任何平台型业务审核链路都是绕不开的基建。以我当时负责的订单审核模块为例用户提交订单后进入前置风控审核机器规则先扫一遍过了之后转运营人工审核运营要看下单信息、库存、价格是否异常运营通过后进财务复核财务要校验金额计算逻辑金额超过一定阈值或者命中特殊条件的订单还得追加一道法务审核。这四个节点表面看都是审核实际处理逻辑完全不一样。风控节点调的是规则引擎返回的是一个机器判定结果根本没有人在页面上点按钮这回事运营节点是人审依赖的是操作员在界面上传回来的 action 参数财务节点要重新算一遍账核对订单金额和优惠分摊法务节点更多是查看证照、合同、合规信息。如果所有节点逻辑都塞进同一个方法里很快会变成一个巨型方法前面一段判断节点类型后面一段执行对应逻辑最后再根据结果决定走哪条分支。写的时候一时爽后面每次改动都要在几百行代码里找到对应分支心惊胆战。1.2 if-else 堆出来的接口有多痛我当时接手的代码就是这种状态。一个audit()方法里先是if (RISK_AUTOCHECK.equals(nodeType))然后是else if (OPERATION_REVIEW.equals(nodeType))再往下还有财务、法务、售后一层套一层。每个分支里还混着各种业务判断甚至出现运营审核里调了财务的金额计算逻辑这种串味的代码。这种写法有几个非常实际的痛点第一方法体膨胀到没法维护。审计类接口又是合规重点每次改动都要拉一堆人 review心理压力很大。第二分支之间互相影响。你只是改财务节点的一个校验结果运营节点也编译报错了——因为公共变量被某个分支顺手改掉了。第三测试用例越堆越多每个新节点都要把所有旧节点的回归敲一遍因为谁也不敢保证改动不会影响其他分支。第四最难受的是需求方不会因为代码不好维护就停止加需求节点只增不减。我后来评估过与其继续在那个大方法上打补丁不如彻底把节点这个概念抽出来让每个节点自己负责自己的逻辑。这就是策略模式的出发点。1.3 为什么选策略工厂而不是模板方法、责任链、状态机很多人一听审核流第一反应可能是责任链模式。我最初也考虑过但仔细分析后发现责任链适合一条链上多个处理器顺序执行每个处理器都有机会处理请求的场景比如过滤链、拦截器链。而我们的审核节点是按节点类型互斥分发——一个请求在某个时刻只会命中一个审核节点不存在多个节点依次处理同一个请求的过程。用责任链你得把节点串起来还得控制链条的终止条件反而把简单问题复杂化了。模板方法倒是可以用来抽取公共流程比如校验参数、执行审核、记录日志、推进状态这个骨架但模板方法解决不了按类型分发到不同实现的问题它更适合在同一个流程里固定骨架、变化步骤的场景。状态机模式我后来确实引入了但那是为了管理订单的整体状态流转跟节点逻辑分发不是一个层面的问题。策略模式解决的是不同节点不同逻辑怎么解耦工厂模式解决的是客户端怎么拿到对应策略状态机解决的是节点之间流转规则怎么管理。三者组合各管一段才是完整方案。所以在当时的场景下策略模式负责把每个节点封装成独立的执行单元工厂模式负责按 nodeType 分发两个一组合正好打在需求要害上。2. 动手之前先想清楚接口边界和职责划分2.1 单接口到底单到什么程度我们最终对外只暴露一个接口POST /api/audit/execute。不管你是风控节点、运营节点、财务节点还是法务节点全部复用这一个入口通过请求体里的nodeType字段区分当前要执行哪个节点的审核。这个设计直接带来了几个好处。首先是接入成本低业务方只需要对接一个接口不需要知道后台拆了多少个节点其次是权限认证、参数校验、限流、日志全都可以收敛在这一个入口做不用每个节点单独搞一套最重要的是接口文档稳定后面哪怕新增十个审核节点对调用方来说接口签名完全没变。但单接口也不是无脑追求。如果某个节点的入参跟其他节点差异非常大比如要传的文件列表、金额明细都不一样硬塞进一个请求体里就会出现一堆空字段代码可读性反而差。我们当时做的一个折中是请求体里保留一个extraData的 JSON 扩展字段各节点特有的参数都放里面由策略自己去解析。这样接口保持统一又不会因为字段差异把 Request 类撑得面目全非。2.2 策略接口的参数设计为什么用一个上下文对象策略接口的方法签名我纠结了一阵子。最朴素的做法是AuditResult execute(String bizId, String action, String operator, String comment, MapString, Object extraData)参数一堆看着就很累。再往后想如果下次要加一个来源渠道字段是不是又要改接口签名所有策略实现类都得跟着改违背了开闭原则。最终我选择用一个上下文对象AuditContext把所有入参和中间结果全部装进去。策略接口只暴露一个方法public interface AuditStrategy { String getNodeType(); AuditContext execute(AuditContext context); }getNodeType()用来告诉工厂我负责哪个节点execute()接收上下文、返回上下文。用同一个对象传进传出好处是策略内部产生的中间结果比如风控命中理由、财务校验明细可以写回 context最后由入口统一组装成响应体。这里有一个关键约束上下文对象只能在线程内部使用绝对不能跨线程共享。策略是单例 Bean上下文是每次请求 new 出来的两者配合才不会出并发问题。这一点后面讲踩坑的时候会展开。2.3 工厂的两种装配方式手动注册还是 Spring 自动收集工厂的实现方式有两种我两种都写过体验差别很大。第一种是手动注册把所有策略 new 出来手动 put 进 MapstrategyMap.put(RISK_AUTOCHECK, new RiskAutoCheckStrategy()); strategyMap.put(OPERATION_REVIEW, new OperationReviewStrategy());优点是直观缺点是每次新增策略都要来改工厂代码而且策略之间的依赖没法由容器统一管理还得自己 new 依赖的 service。这种写法只适合策略本身无依赖、节点数量极少的小项目。第二种是让 Spring 容器自动收集所有策略实现类在工厂构造函数里一次性注入Component public class AuditStrategyFactory { private final MapString, AuditStrategy strategyMap new ConcurrentHashMap(); public AuditStrategyFactory(ListAuditStrategy strategies) { for (AuditStrategy strategy : strategies) { strategyMap.put(strategy.getNodeType(), strategy); } } public AuditStrategy getStrategy(String nodeType) { AuditStrategy strategy strategyMap.get(nodeType); if (strategy null) { throw new IllegalArgumentException(未注册的审核节点: nodeType); } return strategy; } }核心是利用 Spring 的注入特性构造函数声明ListAuditStrategy容器启动时会把所有AuditStrategy实现类收集成一个列表传进来。新增节点时只需要写一个新策略类加上Component注解工厂代码一行都不用改。这个方案我强烈推荐因为它真正做到了零模板代码 天然满足开闭原则。3. 代码落地从接口定义到事务边界的一次完整实现3.1 先定模型请求体、上下文、响应体整个模块的模型可以精简为三块请求模型负责接收外部参数上下文负责内部流转响应模型负责输出结果。先看请求体public class AuditRequest { private String bizType; // 业务类型订单 / 退款 / 售后 private String bizId; // 业务单号 private String nodeType; // 当前审核节点 private String action; // PASS / REJECT private String operator; // 操作人 private String comment; // 审核意见 private MapString, Object extraData; // 各节点扩展参数 }action字段只有两个值PASS通过、REJECT驳回。有人会问自动审核节点也需要传 action 吗风控节点根本不依赖人工传入的 action执行结果由规则引擎自己生成所以风控策略内部会直接覆盖这个值外部传什么不重要。响应体我设计成下面这样public class AuditResult { private String auditId; // 本次审核记录ID排障全靠它 private boolean success; // 是否执行成功 private String nextNode; // 下一个待审核节点空则说明已到终态 private String finalStatus; // 终态时才有值APPROVED / REJECTED private String message; // 补充信息 }这里nextNode和finalStatus是互斥的如果审核通过且还有后续节点nextNode有值如果已经结束finalStatus有值。这个设计让调用方不用去理解完整的审核流只需要知道下一步是哪或最终结果是什么。3.2 策略接口与两个典型实现策略接口上文已经给出这里直接上具体实现。先看风控自动审核策略它的特点是整个审核过程没有人参与结果完全由规则引擎算出Component public class RiskAutoCheckStrategy implements AuditStrategy { Resource private RiskService riskService; Resource private AuditRecordService auditRecordService; Override public String getNodeType() { return RISK_AUTOCHECK; } Override public AuditContext execute(AuditContext context) { // 调用风控规则引擎 RiskResult result riskService.check(context.getBizId(), context.getExtraData()); if (result.isPass()) { context.setPassed(true); context.setNextNode(OPERATION_REVIEW); } else { context.setPassed(false); context.setFinalStatus(REJECTED); context.setDetailMessage(风控拦截: result.getReason()); } auditRecordService.record(context); return context; } }再看运营人工审核策略它的逻辑正好相反依赖的是页面传回的action和commentComponent public class OperationReviewStrategy implements AuditStrategy { Resource private AuditRecordService auditRecordService; Override public String getNodeType() { return OPERATION_REVIEW; } Override public AuditContext execute(AuditContext context) { if (PASS.equals(context.getAction())) { context.setPassed(true); context.setNextNode(FINANCE_CHECK); } else { context.setPassed(false); context.setFinalStatus(REJECTED); context.setDetailMessage(运营驳回: context.getComment()); } auditRecordService.record(context); return context; } }两个策略的差异已经很明显了风控策略不读action运营策略不调规则引擎。但它们有一个共同动作——调用auditRecordService.record(context)记录审核日志。这个公共逻辑我抽到了独立服务里策略只管各自的核心判断留痕的事交给公共服务避免每个策略重复写一遍。3.3 工厂与单接口 Controller把分发串起来工厂代码上文已经贴过核心就是注入ListAuditStrategy遍历构建 Map。这里有个细节要提醒Map 一定要用ConcurrentHashMap因为工厂本身是单例虽然初始化之后主要是读操作但 Spring 容器刷新或者动态注册策略时可能产生并发写用并发容器更稳。Controller 这一层保持精简职责只做三件事参数校验、获取策略、执行策略RestController RequestMapping(/api/audit) public class AuditController { Resource private AuditStrategyFactory strategyFactory; PostMapping(/execute) public AuditResult execute(RequestBody Valid AuditRequest request) { AuditContext context buildContext(request); AuditStrategy strategy strategyFactory.getStrategy(context.getNodeType()); AuditContext resultContext strategy.execute(context); return buildResult(resultContext); } }这里我给一个明确建议Controller 里不要写具体业务判断比如如果 nodeType 是财务就不允许驳回这种规则一律下沉到策略内部。入口只做调度判断属于哪个节点、怎么处理那是策略的事。对应到开头那个巨型 if-else 方法你会发现整个 controller 变得非常清爽新增节点完全不需要动它。3.4 事务、幂等与并发控制审核动作从来不是单独一个操作它至少包含三件事写入审核记录、更新业务单据状态、根据结果推进流程。这三件事必须处于同一个事务里否则会出现审核记录写了但状态没更新这种严重事故。我之前在策略方法上加Transactional时遇到过一个问题一开始担心策略是通过工厂取出来的事务注解会不会失效。实际验证下来只要策略是从容器中获取的 Spring Bean且Transactional加在 public 方法上事务就是生效的——因为容器返回的是代理对象工厂存进去的也是代理对象。会失效的只有一种场景后面踩坑部分细说。Transactional(rollbackFor Exception.class) Override public AuditContext execute(AuditContext context) { // 业务逻辑 auditRecordService.record(context); orderService.updateAuditStatus(context); return context; }幂等和并发控制同样不能省。审核接口是高频调用一个请求被重复提交或者两个并发请求同时审核同一单都会造成脏数据。我的处理方式是在入口处先拿分布式锁基于业务单号锁不成功直接返回该订单正在审核中同时在业务表上加了version乐观锁字段数据库层面再兜底一次。4. 这套方案在真实业务里还能怎么延伸4.1 新加一个审核节点到底要改几行方案落地以后我最直观的感受是加节点这件事不再让人头疼。假设现在要新增一个售后审核节点改动清单是在节点常量或枚举里加一个AFTER_SALE_REVIEW新增一个策略实现类AfterSaleReviewStrategy加上Component注解如果需要自动流转在流转配置里加上这个节点的下一条规则。就这三步。工厂零改动控制器零改动接口文档零改动。这就是策略工厂组合最核心的价值——需求变更的成本被压缩到了最小。我们后来半年内加了三个新节点每次都是一个小类加一个枚举值code review 的时间也从一小时缩短到十分钟。4.2 把节点流转规则从代码里抽出去最初版本里每个策略的nextNode是写死的比如运营审核通过永远是FINANCE_CHECK。但业务跑了一段时间后运营提了个新要求某些渠道的订单运营审核通过后要跳过财务直接进法务。这就意味着策略里的写死逻辑开始不够用了。我把流转规则挪到了配置表里规则大概长这样{ nodeType: OPERATION_REVIEW, passNext: FINANCE_CHECK, conditions: [ { channel: SPECIAL, passNext: LEGAL_REVIEW } ] }策略执行完核心判断后不再自己决定下一个节点而是调用一个流转服务查询配置根据条件和结果拿到真正的下一节点。这样策略的职责更纯粹只做这个节点的审核判断至于下一步去哪交给规则配置去管。这里要补一句不是所有项目都需要这一步。如果节点只有三四个流转关系也很固定写死反而更好理解。我当时加配置化是因为节点已经涨到七个分支条件也多了代码里写一堆 if 反而更乱。不要为了设计而设计配置化永远是在复杂度到了一定程度才值得做。4.3 统一日志与埋点审核系统必须可追溯审核系统有一条红线就是合规可追溯。任何一个操作谁在什么时间对哪一单做了什么决定必须留痕。策略模式拆分以后每个节点都有自己的实现如果不做统一埋点就很容易出现这个节点记了日志那个节点忘了记的情况。我的做法是用 AOP 对策略的execute方法做统一切面Around(annotation(com.example.audit.annotation.AuditLog)) public Object around(ProceedingJoinPoint pjp) throws Throwable { // 记录入参、节点类型、操作人 long start System.currentTimeMillis(); try { Object result pjp.proceed(); // 记录结果、耗时 return result; } catch (Exception e) { // 记录异常堆栈 throw e; } }配合在策略实现类上用AuditLog注解标记所有审核节点的操作日志格式就完全统一了。排查线上问题时只需要按auditId或bizId查一条日志链路就能看到整单经历了哪些节点、每个节点谁审的、审了什么结果、耗时多少。这条经验我觉得比策略模式本身更值钱——架构要服务于可观测性。5. 实战中踩过的坑和排查思路5.1 血泪教训策略类不能有任何共享的可变成员变量上线初期出过一次比较低级的线上事故运营反馈审核意见串单了两个运营同时审不同的单子A 填的备注显示到了 B 的单子上。排查下来问题出在某个策略类里定义了一个private MapString, Object tempMap用来临时存放审核过程中的中间数据。策略是单例 Bean所有请求共用一个实例。你在实例字段里放任何可变状态就相当于所有线程共享同一份数据并发场景下必然互相覆盖。这个问题的本质是把本该放在请求上下文里的状态放到了 Bean 的成员变量里。修复方案也很简单策略类只保留无状态的依赖注入service、mapper任何请求相关的中间数据一律放进AuditContext。从那以后我给自己定了一条规矩写策略实现类时看到类里有非 static 的成员变量就停下来想想这个变量是每个请求独立的吗如果是立刻挪到 context 里去。5.2 新策略没有注册成功接口报未注册的审核节点另一类高频问题是新加的节点调接口时报未注册的审核节点: xxx。第一次遇到的时候我第一反应是工厂代码没生效后来检查了一圈发现是策略类上忘了加Component注解。Spring 容器根本不知道这个类的存在自然也就不会自动收集进工厂。还有一次是因为getNodeType()返回值跟枚举里的 code 不一致一个用了大写一个用了小写直接匹配不上。所以排查这个问题时按照顺序做三件事确认策略类上有Component注解且在组件扫描路径里确认getNodeType()的返回值跟请求里的nodeType完全一致注意大小写和空格看启动日志里有没有新策略的加载记录。这三个查完基本能解决 90% 的注册问题。这个问题本质上反映了这套方案的约定大于配置——约定很重要一旦有人不按约定来就会在运行期暴露问题。为了缓解我后来在枚举里加了一层校验启动时用策略的getNodeType()跟所有枚举值做一次比对发现未匹配就启动失败把问题暴露时间从运行期提前到了启动期。5.3 事务不生效不要在同类内部绕来绕去有一个让我排查很久的问题策略里明明加了Transactional但审核记录没写进去异常回滚也没生效。最后定位到原因是策略的execute方法内部调了一个本类的 private 方法private 方法上也加了Transactional。问题出在 Spring 事务是通过 AOP 代理实现的private 方法不走代理事务注解自然就失效了。正确做法是需要事务的公共逻辑放到单独的 Service 类里策略通过注入的 Service 去调用。这也是我在设计时把auditRecordService.record()独立出来的原因之一一方面是复用另一方面是保证这些需要事务的方法都走 Spring 代理。如果你在代码里发现Transactional不生效按这个顺序排查第一方法是不是 public第二是不是通过当前对象的 this 调用的第三异常是不是被 try-catch 吞掉了第四事务管理器有没有配置对数据源。百分之九十的事务失效问题都出在这四个方面。5.4 问题速查表现象可能原因处理方式新节点报未注册的审核节点策略类缺少Component或getNodeType()返回值不一致检查组件扫描路径核对节点类型常量审核意见串单策略类里有可变的成员变量被多线程共享所有状态移入AuditContext策略保持无状态异常后审核记录一起回滚失败策略内部同类自调用事务代理没生效事务逻辑抽到独立 Service通过注入调用同一单并发审核状态被覆盖缺少分布式锁或乐观锁入口处加 Redis 锁业务表加 version 字段工厂注入的 List 为空策略实现类不在ComponentScan扫描范围调整扫描包路径或在配置类显式注册我在实际项目里测试下来这个方案在节点数量从四个涨到十几个的过程中代码改动量和维护成本一直保持得很平稳。最明显的体验是团队新同学接到加一个审核节点的需求不需要理解整个业务流程照着已有策略类的模板写一个新的就行了出错率大幅下降。最后再分享一个小技巧单接口设计时一定要在响应体里带上审计ID。前期图省事没加后来线上查问题全靠bizId去翻日志慢且容易漏数据。加了auditId之后每条审核记录在日志和数据库里都能一一对应排障效率完全不是一个量级。这些细节都是在真实业务里被磨过之后才明白的。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑