审批流程状态机——从待审批到终态的核心模型

双人审批的核心,说白了就是一台状态机。谁提交、谁审批、什么时候超时、能不能撤回……这些东西要是不用状态机管起来,代码就会烂成一锅粥。我做过好几个审批系统,早期在某个项目里,老同事用一堆 if-else 硬堆,结果半年后谁都不敢改。后来我把状态机一重构,逻辑清爽多了。

五个核心状态的定义

先说说这五个状态到底长什么样。每个状态代表一个审批实例在生命周期里的“锚点”。

状态名含义典型触发条件
待审批工单创建完毕,等待审批人处理申请人提交,审批人未操作
通过审批人同意,流程正常结束审批人点击“通过”
驳回审批人拒绝,退回申请人审批人点击“驳回”
撤回申请人在审批前撤销工单申请人主动撤回
超时超过设定时限未审批,自动终结定时任务检测到期

要注意,撤回只在“待审批”状态下才能触发,审批人一旦操作了,撤回按钮就灰掉。我见过有团队把撤回做成任意状态都能点,结果用户撤回了一个已经通过的工单……嗯,那个 Bug 我修了整整一个下午。

状态转换规则与业务含义

状态不是随便跳的。每条转换背后都有业务约束。比如:

  • 待审批 → 通过:双人中的最后一人确认
  • 待审批 → 驳回:任意一位审批人否决即可
  • 待审批 → 撤回:申请人主动取消,且没有审批记录
  • 待审批 → 超时:超时后系统自动流转到终态,不可逆
  • 通过/驳回/撤回/超时 都是终态,不能再转换

你可能会问:“为什么驳回之后不能重新提交?” 这个看业务设计。有些系统允许申请人在驳回后修改再提交,那就需要多一个“已驳回可重新编辑”的中间态。但本章我们讲的是最简状态机,避免复杂化。我个人习惯是先做最简,再根据真实需求加状态。

状态机 SVG 图

下面这张图把五个状态和转换关系画清楚了。建议你多看两眼,因为我在项目中踩的坑有三分之一都跟状态转换没画对有关。

审批状态机核心转换 待审批 通过 驳回 撤回 超时 审批人通过 审批人驳回 申请人撤回 超时未审批 注意:终态(通过、驳回、撤回、超时)之间没有转换,一旦进入即锁定 撤回仅在待审批状态下可用,驳回后可配置重新提交,但本章先做最简模型

超时机制的设计与陷阱

超时是状态机里最容易出戏的环节。你以为配个 Cron 定时扫表就完了?

⚠ 常见坑:
  • 没有考虑高并发下的重复超时处理(需要用乐观锁或 CAS)
  • 超时时长统一写死,业务方常投诉“为什么我们部门要跟总部用同一个时限?”
  • 超时后没有通知相关人员,工单就无声无息烂在数据库里

我记得以前有个需求,要求审批超时后先发一次提醒,再等24小时才自动超时。一开始我们直接加了个“待提醒”状态,结果状态机多了一个分支,把所有判断逻辑全打乱了。后来改成用字段标记提醒时间,状态机不动,干净多了。

撤回与驳回的区别

这两者虽然看起来都是“没通过”,但业务含义完全不同:

  • 撤回:申请人反悔,主动结束。不在审批历史中留下“拒绝”记录。
  • 驳回:审批人认为材料有问题,带有否决性质。通常需要申请人重新修改后再次提交。

还有一种情况:撤回后能不能重新发起?我建议允许,因为撤回不等于否定,只是申请人自己不想等了。但驳回后是否允许重新提交,必须跟业务方确认清楚,否则状态机里又要多一条难看的转换线。

💡 小技巧: 设计状态枚举时,建议把“撤回”和“驳回”的取值设成相邻的数字,这样写条件判断时不容易漏。比如:PASS=1, REJECT=2, WITHDRAW=3, TIMEOUT=4。我习惯把终态放到较大范围,方便以后加中间态。

状态机实现要点

代码层面,状态机至少需要两个要素:当前状态 + 可执行的动作列表。我贴一段伪代码,不是让你抄,是让你理解结构:

// 状态转换表,key 为当前状态,value 为可转换的目标状态及对应动作
Map<Status, List<Transition>> transitions = new HashMap<>();

transitions.put(PENDING, Arrays.asList(
    new Transition(PASS, "审批通过"),
    new Transition(REJECT, "驳回"),
    new Transition(WITHDRAW, "撤回"),
    new Transition(TIMEOUT, "超时")
));

// 执行转换时校验
public boolean changeStatus(Order order, Status target) {
    List<Transition> allowed = transitions.get(order.getStatus());
    if (allowed == null) return false;
    for (Transition t : allowed) {
        if (t.getTarget() == target) {
            order.setStatus(target);  // 实际要加锁、加审计日志
            return true;
        }
    }
    return false;
}

实际生产里,状态转换最好做成独立 Service 或者用状态机框架(比如 Spring Statemachine),不要散落在各个 Controller 里。我最早手写状态机那次,就是因为不用框架,结果每个接口里都写了一段校验逻辑,后来光测试就写了八千行……你想想看,这多不划算。

避坑指南

讲了这么多,最后说说我在真实项目里遇到的几个典型问题:

🕳️ 坑1:状态与数据库字段分离

我曾经把一个工单的状态写成数据库一个字段,关联审批记录又在另一张表。结果有一次数据迁移,两张表对不齐了,系统里出现了“待审批但审批记录已通过”的脏数据。修复方法是加了唯一约束,并且在状态变更时强校验关联记录。

🕳️ 坑2:超时判定没有考虑租户时区

有个跨国客户,审批人在地球另一边,系统使用 UTC 时间计算超时。结果人家那边半夜被超时邮件炸醒。后来我们把超时判定改成基于审批人所属时区的工作时间,虽然复杂了点,但用户满意度回来了。

🕳️ 坑3:撤回操作没有做幂等

前端用户手抖点了两下撤回,后端接了两个请求。第一个撤回成功了,第二个再查状态发现已是撤回,直接抛异常。我当时把接口改成幂等的:撤回请求无论来几次,只要当前状态是待审批就成功,否则返回已撤回。这样用户体验更友好。

最后说一句:状态机不是画完图就完事的。每一根箭头背后都有业务场景,每一个状态都有对应的异常处理。双人审批就这五个状态,但把它们之间的所有边界条件都想清楚,代码就能少很多 Bug。