审批流不是接上引擎就完事:业务与 Flowable 的接入层

SailTrack
2026-07-11
点 赞
0
热 度
4
评 论
0
  1. 首页
  2. 后端开发
  3. 审批流不是接上引擎就完事:业务与 Flowable 的接入层

写在前面

系列之前写过 Flowable 的入门实战(状态机到 BPMN),也写过假期余额的冻结-扣减-幂等账务模型。这篇写两者相遇的地方:审批引擎和业务系统之间的接入层。

引擎解决的是"流程怎么走":节点、路由、审批人。但"请假申请被批准之后,假期余额怎么扣"这种事,引擎一无所知也不应该知道。接入层的职责就是把这半边补齐——它的设计质量,决定了审批功能是"能用"还是"可靠"。

一、通用表 + 明细表:审批单的多态设计

请假、补卡、通用申请,走的都是审批流,但它们的"内容"完全不同。我的建模是一张流程主表 + N 张业务明细表:

workflowRepository.insertWorkflowRequest(
    requestId, requestNo, LEAVE_BIZ_TYPE, leaveDetailId,
    applicantUserId, applicant.getPrimaryDeptId(), applicant.getPositionId(),
    title, templateVersion.id()
);
workflowRepository.insertLeaveDetail(leaveDetailId, requestId, request);
bindAttachments(requestId, applicantUserId, request.getAttachmentRefIds());

workflow_request 存所有申请共有的骨架:单号、类型(biz_type)、申请人、状态、模板版本。请假时长、补卡日期这些差异化的内容进 leave_detail、clock_correction_detail 各自的明细表,通过 biz_type + detail_id 多态关联。

这样分的好处是引擎侧的逻辑完全不知道业务细节:列表查询、状态流转、待办统计都只碰主表;只有审批完成那一刻,biz_type 决定路由到哪段业务逻辑。如果当初把"请假时长"塞进主表加个字段,每新增一种审批类型就要给主表加一列——三个月后它就变成一张谁也不敢动的宽表。

代价也如实说:查"请假单详情"要 JOIN 明细表,不能一把 SELECT。但详情查询是低频操作,主表的查询频率(列表、待办、状态)才是大头——按读模式分表,永远比按"省一次 JOIN"分表靠谱。

二、提交时序:先冻结,再进流程

请假提交的完整动作序列(都在一个事务里):

// 1. 业务校验:有直属上级才能走审批
if (applicant.getManagerUserId() == null) {
    throw new BizException(400, "当前员工没有直属上级,无法发起请假审批");
}
// 2. 取已发布的模板版本
WorkflowTemplateVersionRow templateVersion =
    workflowRepository.getPublishedTemplateVersion(LEAVE_PROCESS_CODE);
// 3. 建单 + 明细 + 附件
...
// 4. 提交时立即冻结余额,防止审批期间重复请假导致超领
leaveService.freezeForWorkflowSubmission(
    applicantUserId, request.getLeaveTypeId(),
    request.getDurationMinutes(), requestId, applicantUserId);
// 5. AI 预审或人工审批

第 4 步是接入层的灵魂。审批要三天,假期的余额在这三天里处于什么状态?如果"审批中不占额",员工可以同时提交三个三天年假申请,全部批准——超领 9 天。所以提交即冻结:余额没变,但可用额度先扣掉申请量,审批期间所有后续申请看到的是冻结后的余额。

这笔账的完整生命周期和幂等键设计,在假期余额那篇文章里细讲过,这里只强调时序结论:冻结必须发生在审批开始之前,而不是批准的时候——批准时才检查余额,等于让第一笔申请承担"同时有三笔申请"的风险,而且已经进了流程,退无可退。

第 1 步的"必须有直属上级"校验也值得一句:这是业务前置校验,放接入层而不是引擎里。Flowable 找不到审批人会在流程深处报一个引擎自己的异常,用户看到的是"系统错误";接入层提前拦下,给的是一句人话。

三、AI 预审:插在人工之前的一段

提交的最后有个分支:

// 有 AI 预审配置时先进入 AI 审核,审核通过后再流转到人工审批
Long agentTaskId = triggerAgentPreReview(requestId, LEAVE_PROCESS_CODE, ...);
if (agentTaskId != null) {
    workflowRepository.markPendingAgentPreReview(requestId, agentTaskId, applicantUserId);
    // 状态:PENDING_REVIEW
    return response;
}
String processInstanceId = startHumanApproval(...);
// 状态:IN_APPROVAL

配置了 AI 预审的模板,申请先进入 PENDING_REVIEW,由 AI 任务预审材料(比如检查请假事由是否规范、附件是否齐全);预审通过才启动 Flowable 流程实例进入人工审批。没有配置就直进人工——同一张表、同一个接口,通过"有没有 agentTaskId"自然分叉,审批链路的可视化对用户始终是连续的。

这个设计的可扩展性在于:AI 是流程里的一个"可插拔环节",不是一个并行的系统。它的超时收口(预审卡住怎么办)由 Agent 模块的超时扫描兜底,回调回来继续走人工——接缝在状态机上,不在调用关系上。

四、审批动作:三重校验一个都不能少

审批人点"通过"时,接入层在动业务账之前做了三层检查:

WorkflowRequestRow workflowRequest =
    workflowRepository.getRequestByTaskIdForUpdateOrThrow(taskId);  // 1. 行锁读取
Task task = taskService.createTaskQuery()
    .taskId(taskId)
    .taskAssignee(String.valueOf(approverUserId))
    .singleResult();
if (task == null) {
    throw new BizException(403, "当前用户不是该审批任务处理人");     // 2. 归属校验
}
if (!STATUS_IN_APPROVAL.equals(workflowRequest.status())) {
    throw new BizException(400, "当前申请状态不允许审批");           // 3. 状态校验
}

FOR UPDATE 读:两个管理员同时审批同一单,后者在行锁上排队,前者提交后后者看到的是已变更的状态——第 3 层校验顺势拦下。没有行锁,"读取→校验→扣账"的间隙就是双重扣减的窗口。

归属校验:任务必须属于当前登录的审批人。看起来多余(前端菜单只展示自己的待办),但接口是暴露的——带着别人的 taskId 直接调 API 是基本功测试。

状态校验:IN_APPROVAL 才能审批。撤销中的单、AI 预审中的单、已完成的单,到达这里一律 400。状态机的每条边都有守卫,审批操作才不会变成"什么状态都能点一下"的自由发挥。

五、审批完成后的联动清单

通过或驳回,引擎侧只是一次 taskService.complete(taskId),接入层的账本工作在前后铺开:

if ("APPROVE".equals(actionType)) {
    // 通过:请假扣减冻结余额(冻结 → 消费)
    leaveService.consumeFrozenForWorkflowApproval(...);
} else if ("REJECT".equals(actionType) && LEAVE_BIZ_TYPE.equals(...)) {
    // 驳回:释放冻结余额
    releaseFrozenLeaveIfNeeded(workflowRequest, afterStatus, approverUserId);
}
workflowRepository.insertTaskAction(...);   // 审批意见留痕
taskService.complete(taskId);               // 引擎推进
workflowRepository.finishRequest(...);      // 主表状态收口
workflowRepository.closePendingTodo(...);   // 待办关闭
// 请假通过后重新计算考勤结果
if (approvedLeaveDetail != null) {
    recalculateAttendanceForApprovedLeave(...);
}

读这个清单的顺序就是它的价值顺序:先动账(幂等的冻结消费/释放),再留痕(task_action),再推进引擎,再收口状态,最后触发下游重算。每一项失败都有不同的回滚语义,所以顺序不能乱——账没动就 complete 了引擎,审批通过但余额没扣,这种不一致是接入层最经典的翻车现场。

联动里还有个容易被漏的角落:撤销(withdraw)也要释放冻结。驳回会释放,这个大家记得;但员工自己撤回申请,冻结额度同样必须回来。把"所有终态都释放冻结"收敛成一个 releaseFrozenLeaveIfNeeded,按 biz_type 判断后统一调用——终态有四个,释放代码只有一处。

六、小结

三点体会:

  1. 接入层是引擎与业务之间的海关。 引擎只管流程推进,业务只管账务规则,接入层负责申报、查验和放行——两边都不越界,各自才能简单。
  2. 冻结的时序就是资金安全的时序。 提交冻结、通过消费、终态释放,三个动作接在状态机的对应边上,缺一个位置都是资金漏洞。
  3. 多态用"主表+明细表",不要用宽表。 biz_type 路由到明细表,引擎侧永远只认识主表——新增审批类型时,改的只有新增的那张明细表和一段完成联动。

审批通过后的那一秒里,系统做了扣账、留痕、推进、收口、重算五件事。用户只看到"已通过"三个字变绿——接入层的全部目标,就是让这三 个字背后的五件事,一件不落地发生。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数