审核流程这玩意儿,刚接手的时候觉得就一个状态字段的事,真等业务方说"我们要加一个审核节点"的时候,才发现原来的代码像一锅粥——一个接口方法里塞了十来个if (type == ...),每加一个节点就要动老代码,线上出问题还得从一堆分支里翻炸的是哪一段。
我之前在某个中台项目里做统一审核服务,最初的需求其实很简单:所有审核动作都走同一个入口,但内部要根据业务类型和节点编号,路由到不同的审核处理器——有的节点要做内容安全校验,有的节点要做金额复核,有的节点是人工审批以后回写状态。业务方明确说了,后续还会有新的审核节点陆续接入。这种"入口固定、执行逻辑经常变"的场景,用策略模式加工厂模式做一个单接口适配多审核节点的骨架,是最稳、扩展成本最低的做法。
这篇文章把当时的完整设计、核心代码和踩过的坑整理出来,适合正在做审批流、审核中台、风控回调这类系统的后端开发参考,也适合觉得if-else越写越难受、想找重构思路的人。
1. 审核系统的"多节点困境":从一坨if-else说起
1.1 一个非常典型的业务场景
先还原一下当时的处境。系统里所有审核动作都从前端一个按钮触发,请求打到后端同一个接口,带上业务单号、当前节点编号、审核动作(通过/驳回)、审核意见这些参数。后端要根据"现在走到哪个节点"来决定接下来执行什么逻辑。
当时接进来的审核节点大概有这么几个:
- 内容初审节点:校验提交材料是否齐全,调用外部风控服务判断有没有违规风险,结果回写业务单。
- 金额复核节点:把订单金额、已用优惠券、账户可抵扣余额重新算一遍,校验财务数据一致性。
- 负责人终审节点:金额超过阈值时需要负责人审批,审批通过后落库并触发生成后续单据。
第一版实现大家都会这样写:在 Service 方法里按节点编号堆分支。
if ("CONTENT_SAFETY".equals(nodeCode)) { // 内容安全校验一堆逻辑 } else if ("AMOUNT_REVIEW".equals(nodeCode)) { // 金额核对一堆逻辑 } else if ("BOSS_REVIEW".equals(nodeCode)) { // 负责人审批逻辑 }前两个节点确实跑得挺顺。等到第三个节点上线,方法开始变长。等第四、第五个节点提需求时,整个方法已经四百多行,光看一遍就觉得头疼——因为每个节点内还有自己的前置校验、调用服务和回写逻辑,全部挤在同一个方法里。
1.2 分支堆叠方案的三个致命问题
把审核节点用if-else串起来,问题不是"代码难看"这么简单,而是会实打实影响交付质量和排期。我把踩过的问题总结成一张表:
| 问题 | 具体表现 | 扩展代价 |
|---|---|---|
| 分支爆炸 | 每新增一个节点都要打开核心 Service 继续往后面加 else if | 每次都在改老代码,回归风险高 |
| 依赖混乱 | 风控客户端、财务核对服务、审批回调服务全部注入到一个类 | 类加载时初始化一堆无关 Bean,改一个节点影响整条链路 |
| 测试困难 | 单测要走通完整方法,要为所有依赖做 mock | mock 逻辑比业务逻辑还长,跑一次全量回归非常痛苦 |
更根本的问题是它违反了开闭原则——系统对扩展不是开放的,每次扩展都必须修改已经稳定运行的代码。审核类业务恰恰是"节点只增不减"的典型领域,线上跑个半年,大概率会有新节点冒出来,到时候谁接手谁头疼。
我当时的结论很明确:要做一个"入口固定、实现隔离"的骨架,新节点来了只加新类,不碰老逻辑。这就把问题指向了策略模式和工厂模式的组合。
2. 策略+工厂组合拳的基本盘:谁负责路由,谁负责干活
2.1 策略模式在这里解决什么问题
策略模式解决的是"同一种动作、多种实现方案"的封装问题。放到审核场景里,所有审核节点本质上都在做同一件事:拿到审核上下文,执行判断,返回审核结果。只是每个节点的具体判断规则不同。
用一句话概括:把每个审核节点的核心逻辑封装成独立的策略类,类与类之间互不感知,调用方只面向统一接口编程。
这跟餐厅点菜很像。菜单上的菜都是"可点的菜",后厨每个菜有单独的厨师负责,客人不会跑到后厨去指挥厨师,只需要跟服务员说要什么菜就行。新增一道菜,只需要后厨多一个负责的人,菜单多一行字,前厅流程完全不动。
把策略接口定义好,让"初审""金额复核""终审"变成三个互不依赖的类,这是第一步。
2.2 工厂模式在这里解决什么问题
策略模式只解决了"每个节点怎么做",但还没有解决"来一个请求,系统怎么知道该用哪个策略"。如果把这个判断逻辑继续散落在 Controller 或 Service 里,你只是把分支从 Service 挪了个地方而已。
工厂模式解决的就是"谁来做选择"。工厂内部维护一张节点编号到策略实例的映射表,根据请求里的节点编号返回对应的策略对象。调用方不需要知道策略的构造函数、依赖哪些组件、是不是单例,只需要跟工厂要一个能干活的东西。
还是拿餐厅举例,工厂就是前厅的领班:客人点完菜,领班根据菜名分配到对应的厨师组,客人不需要认识后厨任何一个人。
2.3 为什么必须两个一起用,而不是二选一
只上策略模式,结果是客户端代码依然要写一堆判断来挑选策略,判断逻辑本身还是散的,只是把if-else里的业务代码换成了new各种策略对象。
只上工厂模式,把策略实例的创建集中到了工厂,但如果工厂内部还是用一堆if-else来选择创建哪个对象,等于把 Service 里的分支原封不动搬到了工厂里,问题没解决,只是换了个房间。
策略模式和工厂模式是天然的搭档:
- 策略模式负责"做事"——每个节点一个类,逻辑互不干扰。
- 工厂模式负责"找人"——按请求参数返回正确的策略实例。
只有两个一起用,才能同时把"执行逻辑"和"选择逻辑"隔离出去。调用入口只剩下三件事:构建上下文、问工厂要策略、执行策略。
3. 单接口适配多审核节点的实战落地:接口、工厂、入口三段式拆解
3.1 先定策略接口,别急着写实现
动手写代码的第一步,是把所有审核节点抽象出一个共同接口。这个接口我建议至少包含三样东西:节点标识、可选匹配条件、执行方法。
public interface AuditNodeStrategy { // 当前策略支持的节点编号 String supportNode(); // 二次匹配:同一个节点在特殊业务场景下可能要走不同实现 default boolean match(AuditContext ctx) { return true; } // 执行审核逻辑 AuditResult execute(AuditContext ctx); }supportNode()是工厂路由的主键,返回的是节点编号字符串,比如"CONTENT_SAFETY"。match()是给"同节点多实现"留的扩展位,比如同为"BOSS_REVIEW",普通订单和加急订单可能走不同审批策略,这时候除了节点编号,还要额外判断上下文里的业务标记。execute()是策略的核心处理逻辑,入参统一是AuditContext,出参统一是AuditResult。
AuditContext我建议做成一个相对通用的载体,里面放业务单号、节点编号、操作人、审核动作、审核意见、业务扩展字段,这样新策略加入时不需要频繁修改上下文类的结构。AuditResult包含是否通过、结果码、提示信息、下一节点编号,方便后续做节点链式流转。
3.2 两个具体审核节点的实现长什么样
接口定完,实现类就简单了。每个节点独立一个类,只关心自己的规则。比如内容初审节点:
@Component public class ContentSafetyAuditNode implements AuditNodeStrategy { @Override public String supportNode() { return "CONTENT_SAFETY"; } @Override public AuditResult execute(AuditContext ctx) { // 1. 校验提交材料完整性 contentValidator.validate(ctx.getPayload()); // 2. 调用风控服务判断内容风险 RiskCheckResult risk = riskControlService.check( ctx.getBizId(), ctx.getPayload()); // 3. 记录审核流水 auditLogService.record(ctx, risk.isPassed()); // 4. 返回结果并给出下一节点 if (!risk.isPassed()) { return AuditResult.reject("RISK_DETECTED", "内容存在风险"); } return AuditResult.passWithNext("AMOUNT_REVIEW"); } }金额复核节点的结构也类似,只是内部逻辑换成了财务核对。节点之间是完全解耦的:内容初审节点不知道"下次要走到哪",它只负责告诉调用方"我这边通过了,建议去金额复核"。至于到底要不要继续往下走,那是编排器的事,后面会讲到。
3.3 工厂注册表:从if-else到Map的一步替换
策略类写好了,工厂的核心就是一张映射表。第一版不强依赖 Spring 容器的话,手动注册也完全够用:
public class AuditNodeFactory { private final Map<String, AuditNodeStrategy> strategyMap = new HashMap<>(); private final AuditNodeStrategy defaultStrategy = new DefaultAuditNodeStrategy(); public AuditNodeFactory() { List<AuditNodeStrategy> strategies = List.of( new ContentSafetyAuditNode(), new AmountReviewAuditNode(), new BossAuditNode() ); for (AuditNodeStrategy strategy : strategies) { strategyMap.put(strategy.supportNode(), strategy); } } public AuditNodeStrategy getStrategy(String nodeCode) { return strategyMap.getOrDefault(nodeCode, defaultStrategy); } }注意最后一行,我用了getOrDefault而不是直接get。工厂要处理的不仅是"能匹配到的节点",还包括"没匹配到的节点",一个稳健的工厂必须对未知节点给出兜底结果,而不是抛空指针让调用方猜原因。
3.4 单接口入口:Controller只做三件事
入口层保持单薄,Controller 的核心逻辑收敛成三行——构建上下文、取策略、执行:
@RestController @RequestMapping("/api/audit") public class AuditController { private final AuditNodeFactory auditNodeFactory; public AuditController(AuditNodeFactory auditNodeFactory) { this.auditNodeFactory = auditNodeFactory; } @PostMapping("/node/execute") public AuditResult execute(@Valid @RequestBody AuditRequest request) { AuditContext ctx = AuditContextBuilder.build(request); AuditNodeStrategy strategy = auditNodeFactory.getStrategy(request.getNodeCode()); return strategy.execute(ctx); } }到这里,单接口适配多审核节点的雏形就出来了。业务方要新增一个节点时,核心 Service、Controller、工厂代码一行都不用改,只需要新增一个策略实现类并注册进工厂,这就是开闭原则落地后的真实手感。
4. 工程化进阶:Spring容器接住策略、节点链式编排与兜底设计
4.1 手工注册策略的时代该结束了
上一节的工厂里手写了一个策略列表,节点少的时候没问题,节点到了十几个以后,"忘记把新策略放进注册列表"就会成为一个新的低级事故来源。
用 Spring 容器后,可以让容器自动收集所有策略 Bean,工厂只做一次转发。这是目前最推荐的写法:
@Service public class AuditNodeFactory { private final Map<String, AuditNodeStrategy> strategyMap; public AuditNodeFactory(List<AuditNodeStrategy> strategies) { strategyMap = strategies.stream() .collect(Collectors.toMap( AuditNodeStrategy::supportNode, strategy -> strategy, (existing, replacement) -> replacement )); } public AuditNodeStrategy getStrategy(String nodeCode) { return strategyMap.getOrDefault( nodeCode, new DefaultAuditNodeStrategy()); } }新增节点时,只需要写一个实现类并打上@Component注解,Spring 会自动把它收进List<AuditNodeStrategy>,工厂自然就能路由到它。toMap的第三个参数是合并函数,用来处理两个策略返回同一个节点编号的冲突情况——这种情况应该越早暴露越好,我建议在这个位置打印一条启动警告日志,而不是静默覆盖。
Spring 里还有一种更省事的注入方式:直接@Autowired Map<String, AuditNodeStrategy> strategyMap,Map 的 key 是 Bean 名称。这个方案的缺点是路由键和类名绑定,一旦有人改了类名或者@Component的名称,路由就悄悄失效了。我还是更喜欢在策略接口里显式声明supportNode(),让路由规则跟着代码走,重构时编译器能帮忙把问题揪出来。
4.2 从一个节点到一条审核链路:编排器和策略的分工
单接口适配多审核节点,除了"一次请求只路由一个节点"之外,更常见的场景是"一次请求要流转完整条审核链路"——初审通过后自动进入金额复核,金额复核通过后再进入终审。
很多人在这一步会纠结:要不要在策略内部调用下一个策略?千万别。策略之间互相调用,很快又会出现耦合。正确做法是单独抽一个编排器,让策略只回答两件事:当前节点是否通过,建议的下一节点是谁。链路怎么走,由编排器和上下文状态一起决定。
@Component public class AuditFlowDispatcher { private final AuditNodeFactory auditNodeFactory; public AuditFlowDispatcher(AuditNodeFactory auditNodeFactory) { this.auditNodeFactory = auditNodeFactory; } public AuditResult dispatch(AuditContext ctx) { while (ctx.hasNextNode()) { String currentNodeCode = ctx.currentNode(); AuditNodeStrategy strategy = auditNodeFactory.getStrategy(currentNodeCode); AuditResult result = strategy.execute(ctx); if (result.isRejected()) { return result; } ctx.moveToNext(result.getNextNode()); } return AuditResult.finish(); } }这里有个容易被忽略的细节:审核流程如果跨了很长时间(比如人工终审要等负责人第二天才点通过),节点的流转状态不能只存在内存里,一定要落库。上下文里持有的应该是从数据库恢复出来的流程记录,而不是一个只活在请求线程里的临时对象。
把编排逻辑放在单独组件里,好处是策略类始终只关心自己的业务规则,将来就算要引入更复杂的并行审核、会签审核,也只是在编排器层面扩展,策略实现不需要跟着动。
4.3 兜底策略和异常边界
工厂路由不到策略时,我前面已经说了要走兜底策略。这个兜底不能只是随便返回一个失败结果,它还需要把问题暴露出来,方便监控发现:
@Component public class DefaultAuditNodeStrategy implements AuditNodeStrategy { @Override public String supportNode() { return "__DEFAULT__"; } @Override public AuditResult execute(AuditContext ctx) { log.warn("未注册的审核节点: {}, 业务单号: {}", ctx.getNodeCode(), ctx.getBizId()); return AuditResult.reject("UNSUPPORTED_NODE", "该审核节点尚未注册"); } }除了兜底,还要在入口层加上全局异常捕获。策略执行过程中可能抛出各种运行时异常,比如外部风控服务超时、数据库连接断开。如果这些异常直接抛给前端,前端无法区分"业务上不允许"和"系统内部故障"这两种完全不同的情况。我的做法是统一转成标准结果结构,并让监控系统从日志里抓异常堆栈告警。
5. 真实踩过的坑:单例状态污染、校验散落和空指针兜底
5.1 策略实例里的共享状态:一个让我排查了三小时的bug
这是我在这套架构里踩得最狠的一个坑。Spring 管理的 Bean 默认是单例,也就是所有请求线程共用同一个策略实例。有人写代码的时候图省事,把当前请求的上下文放到了策略的成员变量里:
@Component public class BossAuditNode implements AuditNodeStrategy { private AuditContext currentCtx; // 反面教材:共享可变状态 @Override public AuditResult execute(AuditContext ctx) { this.currentCtx = ctx; // 线程A写进去 // 执行中间某一步时,线程B覆盖了currentCtx // 后续读取到的数据就是别人的单子 } }并发一上来,不同请求互相覆盖字段,审核结果张冠李戴。这种问题非常难排查,因为单测不并发是测不出来的,只有线上流量大了才会偶发出现。经验教训就一条:策略类必须是无状态的,所有数据都通过方法参数传递。如果实在有"每个请求独占一份数据"的需求,用ThreadLocal或者把 Bean 的作用域改成RequestScope,但最稳妥的做法还是老老实实传参数。
5.2 参数校验和上下文构建别散落到每个策略里
第二个容易踩的坑是校验逻辑越写越散。一开始每个策略为了省事,都自己校验参数的合法性,结果同一个"业务单号不存在"的错误,三个策略返回了三种不同的错误码,前端对接的人快疯了。
后来我把校验分成两层:
| 校验层 | 职责 | 放置位置 |
|---|---|---|
| 基础校验 | 节点编号非空、业务单号存在、操作人有权限 | Controller 前的统一校验器 |
| 业务校验 | 金额是否超过阈值、材料是否齐全 | 各策略内部 |
基础校验放在AuditContextBuilder里统一执行,只有基础校验通过才允许进入工厂路由。策略内部只做业务规则判断,这样每个策略的代码量都很薄,错误码也统一可控。
5.3 拿不到策略的时候别直接NPE
接口刚上线那阵子,生产环境出现过一次空指针。原因是新版配置里新增了一个节点编号,但策略类还没部署上去,请求进来后工厂返回了 null,Controller 调用strategy.execute()直接炸。从那以后我定了条规矩:工厂的getStrategy永远不许返回 null,要么返回匹配到的策略,要么返回兜底策略。宁可让业务方看到"节点未注册"的明确错误,也不能让监控里出现无头无尾的空指针堆栈。
单元测试也要把这条规矩固化下来:
@Test void factoryShouldRouteToTargetStrategy() { AuditNodeFactory factory = new AuditNodeFactory( List.of(new ContentSafetyAuditNode())); assertTrue(factory.getStrategy("CONTENT_SAFETY") instanceof ContentSafetyAuditNode); } @Test void unknownNodeShouldFallbackToDefault() { AuditNodeFactory factory = new AuditNodeFactory(List.of()); assertNotNull(factory.getStrategy("NOT_EXIST_NODE")); assertEquals("UNSUPPORTED_NODE", factory.getStrategy("NOT_EXIST_NODE").execute(buildCtx()).getCode()); }6. 这套方案的边界:什么时候不该硬套
策略加工厂解决的是"按条件找实现"这一类问题,但它不是万能的。我后来在项目复盘里专门列过它的适用边界。
如果系统里只有两三个固定的审核节点,而且没有明确的扩展计划,那直接用if-else反而更简单,硬套模式只会让结构变复杂,收益为零。模式是为了应对变化,不是为了好看。
如果审核节点之间的流转关系非常复杂,比如存在并行会签、驳回重新流转、节点之间有回退依赖,那光是策略加工厂就不够了,需要引入状态机或者更完整的流程引擎来做编排。策略加工厂解决的是"定位到某个节点的处理器",它不负责管理整个流程的生命周期。
如果各节点需要的请求参数差异极大,一个通用上下文要想承载全部数据,很可能会变成塞满各种字段的"万能对象"。这种情况下,我建议上下文内部用一个灵活的数据载体,或者按节点类型分别定义自己的输入模型,工厂路由后再做一次数据适配。说到底,单接口的核心价值是"统一入口、分布式实现",而不是"一个 DTO 扛下所有"。
最后分享一个实际操作中的体会:这套骨架搭完以后,后面每一次新增审核节点,流程基本就是三步——写一个策略类、打上组件注解、在流程配置表里加一条记录。主流程代码已经好几个月没动过了,业务方再来提新节点需求的时候,排期基本都能控制在半天以内。做审核类的后端服务,追求的就是这种"加需求不慌"的感觉。