审批流程状态机——从待审批到终态的核心模型
双人审批的核心,说白了就是一台状态机。谁提交、谁审批、什么时候超时、能不能撤回……这些东西要是不用状态机管起来,代码就会烂成一锅粥。我做过好几个审批系统,早期在某个项目里,老同事用一堆 if-else 硬堆,结果半年后谁都不敢改。后来我把状态机一重构,逻辑清爽多了。
五个核心状态的定义
先说说这五个状态到底长什么样。每个状态代表一个审批实例在生命周期里的“锚点”。
| 状态名 | 含义 | 典型触发条件 |
|---|---|---|
| 待审批 | 工单创建完毕,等待审批人处理 | 申请人提交,审批人未操作 |
| 通过 | 审批人同意,流程正常结束 | 审批人点击“通过” |
| 驳回 | 审批人拒绝,退回申请人 | 审批人点击“驳回” |
| 撤回 | 申请人在审批前撤销工单 | 申请人主动撤回 |
| 超时 | 超过设定时限未审批,自动终结 | 定时任务检测到期 |
要注意,撤回只在“待审批”状态下才能触发,审批人一旦操作了,撤回按钮就灰掉。我见过有团队把撤回做成任意状态都能点,结果用户撤回了一个已经通过的工单……嗯,那个 Bug 我修了整整一个下午。
状态转换规则与业务含义
状态不是随便跳的。每条转换背后都有业务约束。比如:
- 待审批 → 通过:双人中的最后一人确认
- 待审批 → 驳回:任意一位审批人否决即可
- 待审批 → 撤回:申请人主动取消,且没有审批记录
- 待审批 → 超时:超时后系统自动流转到终态,不可逆
- 通过/驳回/撤回/超时 都是终态,不能再转换
你可能会问:“为什么驳回之后不能重新提交?” 这个看业务设计。有些系统允许申请人在驳回后修改再提交,那就需要多一个“已驳回可重新编辑”的中间态。但本章我们讲的是最简状态机,避免复杂化。我个人习惯是先做最简,再根据真实需求加状态。
状态机 SVG 图
下面这张图把五个状态和转换关系画清楚了。建议你多看两眼,因为我在项目中踩的坑有三分之一都跟状态转换没画对有关。
超时机制的设计与陷阱
超时是状态机里最容易出戏的环节。你以为配个 Cron 定时扫表就完了?
- 没有考虑高并发下的重复超时处理(需要用乐观锁或 CAS)
- 超时时长统一写死,业务方常投诉“为什么我们部门要跟总部用同一个时限?”
- 超时后没有通知相关人员,工单就无声无息烂在数据库里
我记得以前有个需求,要求审批超时后先发一次提醒,再等24小时才自动超时。一开始我们直接加了个“待提醒”状态,结果状态机多了一个分支,把所有判断逻辑全打乱了。后来改成用字段标记提醒时间,状态机不动,干净多了。
撤回与驳回的区别
这两者虽然看起来都是“没通过”,但业务含义完全不同:
- 撤回:申请人反悔,主动结束。不在审批历史中留下“拒绝”记录。
- 驳回:审批人认为材料有问题,带有否决性质。通常需要申请人重新修改后再次提交。
还有一种情况:撤回后能不能重新发起?我建议允许,因为撤回不等于否定,只是申请人自己不想等了。但驳回后是否允许重新提交,必须跟业务方确认清楚,否则状态机里又要多一条难看的转换线。
状态机实现要点
代码层面,状态机至少需要两个要素:当前状态 + 可执行的动作列表。我贴一段伪代码,不是让你抄,是让你理解结构:
// 状态转换表,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 里。我最早手写状态机那次,就是因为不用框架,结果每个接口里都写了一段校验逻辑,后来光测试就写了八千行……你想想看,这多不划算。
避坑指南
讲了这么多,最后说说我在真实项目里遇到的几个典型问题:
我曾经把一个工单的状态写成数据库一个字段,关联审批记录又在另一张表。结果有一次数据迁移,两张表对不齐了,系统里出现了“待审批但审批记录已通过”的脏数据。修复方法是加了唯一约束,并且在状态变更时强校验关联记录。
有个跨国客户,审批人在地球另一边,系统使用 UTC 时间计算超时。结果人家那边半夜被超时邮件炸醒。后来我们把超时判定改成基于审批人所属时区的工作时间,虽然复杂了点,但用户满意度回来了。
前端用户手抖点了两下撤回,后端接了两个请求。第一个撤回成功了,第二个再查状态发现已是撤回,直接抛异常。我当时把接口改成幂等的:撤回请求无论来几次,只要当前状态是待审批就成功,否则返回已撤回。这样用户体验更友好。
最后说一句:状态机不是画完图就完事的。每一根箭头背后都有业务场景,每一个状态都有对应的异常处理。双人审批就这五个状态,但把它们之间的所有边界条件都想清楚,代码就能少很多 Bug。