核心判断
内部审批系统里,工作流引擎只做一件事:按流程定义驱动节点流转。业务数据、组织角色、审批人计算、消息通知,全部留在业务系统。把这条边界划清,后面所有问题——流程怎么配、权限怎么控、通知发给谁——都会变得简单;划不清,就会出现流程定义随组织架构频繁改、业务字段塞满流程变量、越权审批无法追溯这三个典型烂尾。
落到具体做法上,有三个决定性的选择:数据用「业务表 + 关联表 + 引擎表」三层解耦;节点只配置审批人规则编码而不配置具体人;权限和通知的触发在引擎侧、执行在业务侧。以下分述。
数据分层:业务与流程怎么绑定
工作流引擎不适合承载业务字段。BPMN 的流程变量是为路由判断设计的,不是为报表和检索设计的——一旦把请假天数、采购清单、合同条款塞进流程变量,查询要连引擎表、改字段要重新部署流程定义、数据迁移几乎不可能。正确做法是按职责分三层存。
| 层级 | 归属 | 职责 | 关键内容 |
|---|---|---|---|
| 业务表 | 业务库 | 存完整表单数据,是唯一的业务事实来源 | 业务单号、申请人、部门、金额、明细、附件、当前状态 |
| 关联表 | 业务库 | 把业务单据和流程实例绑在一起 | 业务单号、业务类型、processInstanceId、流程定义 Key、当前节点、状态 |
| 引擎表 | 引擎自动生成 | 只存流程定义、实例、任务、历史 | 流程实例 ID、任务 ID、节点 ID、候选人、路由变量 |
流程变量遵循极简原则:只放路由判断必需的三类值——条件网关要用的数值(金额、天数)、动态匹配审批人要用的标识(部门 ID、发起人 ID)、全局标记(是否加急)。前端展示的二十个字段,一律查业务表,不从变量里取。
流转链路:一条单据的完整生命周期
整条链路遵守「业务先落库,流程后驱动」,任何一步都不允许跳过。顺序如下:
发起:保存业务单据,生成全局唯一业务单号,状态置为审批中;随后以业务单号为 businessKey 启动流程,写入关联表。
节点到达:引擎流转到某审批节点,触发任务创建监听器。
审批人计算:监听器读取节点配置的规则编码,调用业务权限服务算出实际审批人,回写给引擎任务。
通知:同一触发点调用业务通知服务,按节点配置的渠道发送待办提醒。
审批操作:接口层校验操作权限与单据状态,保存审批意见到业务表,再完成任务。
结束:流程结束或驳回监听器更新业务单据状态,并联动后续业务(生成合同、通知财务、回写台账)。
这里的关键取舍是:业务联动一律写在监听器里,不写在审批接口里。审批接口只负责校验与完成任务,节点状态变更带来的业务后果由监听器统一承接,这样正常流转、驳回、加签、管理员跳转都不会漏掉业务联动。
节点、任务与审批人分配:三个容易误解的点
前面提到「引擎流转到节点后触发监听器计算审批人」,这句话在实际落地时常被误解成两种错误做法。这一节把机制拆开:审批人由谁在什么时机分配、任务实例怎么产生、驳回重走时要注意什么。
分配发生在下游节点的 create 回调,不是上游节点主动推人
正确的顺序不是「上一个节点审批完成后,业务手动查人再塞给引擎」,而是引擎自己往前跑,新建出下一个用户任务,由这个新任务自己的回调完成分配:
当前审批人点同意,调用 taskService.complete 完成任务。
引擎内部自动流转,走到下一个用户任务节点(有网关时先跑完网关分支判断)。
引擎创建新任务,抛出 create 事件。
该节点上绑定的 TaskListener 被执行,读取节点预置的规则编码(如 DEPT_HEAD、ROLE_CODE)。
调用业务权限服务,按规则算出审批人列表。
通过 setAssignee 或 addCandidateUser 把结果交回引擎任务。
任务就绪进入待办,同一触发点再调用通知服务发提醒。
由此推出三条判断标准:
监听器属于下游节点自己,不属于上游节点;上游完成只是「推动引擎往前走」。
每个节点独立挂自己的 create 监听器,规则互不干扰,加签、跳转、管理员强制跳节点时依然走同一套分配逻辑。
不要在「上一节点的完成监听器」里预计算下一节点审批人:一旦遇到条件分支、子流程、驳回或跳转,就得自己推断下一节点是谁,容易写死,后续改流程基本等于重写。
一次抵达产生一批任务,多次抵达产生多批任务
节点是模板,任务是运行实例。同一个 userTask 节点定义,可以在流程运行中被多次激活,每次激活都生成新的任务实例。
第一次走到该节点,创建任务实例 A;驳回或管理员跳转后再次回到该节点,引擎重新触发,生成任务实例 B。两者共享同一份节点配置(同一 taskDefinitionKey),但 taskId 不同,是两条独立任务,不存在「复用同一个任务」。
会签节点则是一次抵达同时生成多条任务,属于同一次激活产生的一批实例。
每次重新抵达都会重新触发 create 监听器,重新按规则算一遍审批人。若业务要求驳回后仍由原审批人处理,必须在业务层打标记拦截动态计算、复用旧审批人,否则可能出现换人。
历史表会保留该节点的多条任务记录,正好用来留存多轮审批轨迹与意见。
由此有一个标识设计上的坑:不要用 taskId 当业务唯一标识。识别「第几次走到该节点审批」,应使用 businessKey + taskDefinitionKey + 业务侧审批记录序号,写入自己的审批记录表。
驳回、拿回、跳转走的都是同一条分配通道
| 场景 | 引擎侧行为 | 业务侧要处理的事 |
|---|---|---|
| 驳回到指定节点 | 创建目标节点的新任务,触发该节点 create 监听器,重跑该节点规则 | 按需拦截动态计算,决定沿用原审批人还是重新计算;同时记录「第几次重走」 |
| 拿回 / 撤回 | 流程退回发起或上一节点,同样生成新任务并触发创建事件 | 校验是否允许拿回、是否已越过不可撤回节点,并撤销原任务上的通知 |
| 管理员强制跳转 | 跳过中间节点直接激活目标节点任务 | 校验流程管理员权限,写审计日志,跳过的节点不补审批意见 |
监听器里能拿什么,不能拿什么
监听器拿到的 DelegateTask 可以提供 businessKey 与流程变量,这些足以作为算审批人的入参:发起人、部门 ID、金额区间、发起人自选的人。但原始业务数据仍然回业务表查,流程变量里不该出现表单明细。这条边界与第二节的极简原则一致:变量为路由和分配服务,业务事实只存业务表。
一句话收口:引擎负责流转与创建任务,节点负责声明规则,业务服务负责算人,监听器负责把两者接上。三次修订的都是同一件事——分配永远发生在任务诞生的那一刻,而不是在任务结束的那一刻。
单据与流程怎么绑定,流程怎么判定结束
审批人分配解决的是「谁来批」,还剩下两个问题同样决定这套架构能不能长期用下去:一张业务单据怎么对应到具体的流程定义,以及流程什么时候算结束、由谁来宣布结束。
绑定发生在流程定义 Key 上,不在实例上
bpmn.js 画出来的是流程模型,部署进引擎后得到一个固定标识 processDefinitionKey。同一个模板流程发起一百张申请,key 不变,只有每次发起的 processInstanceId 不同。所以绑定必须绑定在这个 key 上,实例 ID 是发起后才产生的,不能作为绑定源。
绑定关系放在配置层,两种载体分别服务两类审批:
| 绑定载体 | 存什么 | 适用 |
|---|---|---|
| 表单模板表 | templateId、表单 schema、process_def_key、business_type | 轻量审批;新增流程只配模板与流程,不写后端 |
| 业务类型映射 | business_type 到 process_def_key 的一行映射 | 独立业务单据,如付款、合同 |
发起时的顺序是固定的:选定模板或提交业务单据 → 按 templateId 或 business_type 查出 process_def_key → 落业务数据并生成全局唯一 business_no → 写关联记录(business_no、business_type、process_def_key)→ 以 business_no 为 businessKey 启动流程。这样换流程只改映射,业务代码不动;流程升级时新实例走新定义,在跑的旧实例继续走旧版本。
结束由两层各自判定:引擎判实例,业务判单据
这两个「结束」不是一件事,混在一起就会出现流程跑完了但单据状态还挂着审批中的脏数据。
| 层面 | 判定依据 | 负责什么 |
|---|---|---|
| 引擎 | 走到 EndEvent;并行或子流程等全部分支汇合;管理员强制终止同样由引擎标记 | 标记实例结束、停止产生新任务、归档流程历史 |
| 业务 | 结束事件监听器收到通知,按 businessKey 定位关联记录 | 写关联表与单据状态,触发后置动作,发结果通知 |
引擎只认模型,它不知道这张单是请假还是付款,它只知道流程走完了。单据算不算办完,是业务的事。两侧可能出现短暂不一致——引擎已结束但监听器执行异常,业务状态没更新。对策有两条:监听器与业务更新放在同一事务;再加一个补偿任务扫关联表,捞出「引擎已结束、关联状态仍是审批中」的记录,重发一次结束事件重试。
审批状态存在哪,后置动作怎么分发
流程一结束,就要回答两个问题:这张单的审批结果记在哪个字段,以及审批通过之后要触发的业务动作由谁执行。这两个问题的答案决定了流程模块能不能被所有业务复用。
不要在流程代码里按业务类型分支
最常见的写法是在结束监听器里判断 business_type,命中就调用对应业务 Service。这种写法能跑,但每新增一类审批都要改监听器,流程模块逐渐变成业务耦合的黑盒,也失去了「新增流程只需配置」的意义。替代方案是事件分发:
监听器只做三件事——按 businessKey 查关联记录、更新关联表状态、发布流程结束事件(携带 businessNo、businessType、结果)。
各业务模块自己订阅事件,判断是否属于自己负责的 business_type,再更新自己的业务表、跑后置动作。
新增一类审批只需新增订阅者,流程模块零改动;一条流程结束也可以有多个订阅者,分别处理消息推送、台账回写、财务对接。
跨服务或要求高可靠时,用消息队列替代本地事件,天然具备重试与补偿能力。
取舍很直接:单体应用、要同事务一致性,用本地事件最省事;拆成独立流程中心、要跨服务跨库,走 MQ 事件更稳。
能否只更新关联表,业务表不再存审批状态
可以,但要看单据的生命周期在哪里结束。如果这张单的存在意义就是走完审批,那么审批状态可以只存在于关联表,业务表不带任何审批字段,所有「是否通过」的判断都按业务单号去关联表查。BPM 侧因此彻底通用,也不用再担心两边状态不一致和补偿。
| 单据特征 | 建议 |
|---|---|
| 审批即终点,通过后只留存档(报备、请示、申领) | 业务表不存审批状态,统一读关联表;监听器只改关联表,业务按需订阅事件做后续动作 |
| 审批只是生命周期一环(采购、合同、报销) | 关联表存审批状态,业务表保留自己的生命周期状态,两者维度不同不冲突 |
代价也要提前认下三条:业务查询需要 join 关联表,量大的话要保证 business_no 的索引;一张单允许发起多次流程时(如初稿审完再审批变更),关联表会出现多条记录,业务侧必须定义取哪一条;将来这张单若脱离流程引擎运行,状态就无处可查。
一类流程就要一张业务表吗
公司内部审批动辄几十上百条流程,逐条建业务表、写接口、做页面,维护成本会失控。实际落地不是「每条流程一张表」,而是分层:轻量申请走通用表单,核心业务单据单独建表,两者共用同一张关联表和同一套引擎能力。
通用表单模式:新增流程只配置,不开发
建一张通用单据主表,字段包括表单模板 ID、业务单号、提交人、提交时间、摘要,以及一个 JSON 类型的表单数据字段;字段定义放在表单模板的 schema 里,前端据此渲染表单。新增一条审批只需两步:后台新建模板并配置字段,bpmn.js 画一套流程并绑定这个模板,后端一行代码都不用加。
它的代价也很明确:JSON 内字段难以建索引,按字段筛选、统计、对外对账都很吃力;字段之间无法做外键约束,强校验只能靠应用层;一旦某个表单变重,从 JSON 里迁移出来会很痛。
混合架构:轻量走通用表单,重量建独立表
| 类型 | 典型流程 | 存储 |
|---|---|---|
| 轻量申请 | 会议室预约、设备申领、外出报备、加班报备、一般请示 | 通用表单主表 + JSON 字段,模板驱动,零后端开发 |
| 核心业务单据 | 付款、采购、合同、报销 | 独立业务表,强校验、可索引、可对账、可统计 |
| 统一层 | 全部单据 | 同一张关联表、同一套待办与通知能力,business_type 区分前缀(form: 与 biz:) |
判断口径可以压成一句话:审批通过之后还要驱动别的业务、要按月统计、要对外推送的,单独建表;通过即结束、只留存档的,进通用表单。不确定时先放通用表单,但预留按 business_type 迁移成独立表的入口。钉钉审批、飞书审批这类产品的底层也是同一思路:自定义审批走通用表单,工资、报销等自营业务走独立业务表,共用一套流程绑定关系。
这也解释了前面的问题:bpmn.js 画出来的只是流程模板,业务表是否通用取决于你有没有做通用表单体系,而不是取决于流程怎么画。真正通用的只有关联表。
权限控制:业务系统全权负责
不要使用引擎自带的身份表(如 Flowable 的 ACT_ID 系列)维护用户和角色。你的系统里已有权威的组织架构,同步一份到引擎只会造成两套数据打架。引擎需要的只有一句话:这个任务交给谁。
节点配置规则,不配置人
节点扩展属性里存的是规则编码,运行时由业务服务解析。组织调整、人员变动、角色改名,只改业务逻辑,流程定义不用重新部署。
| 规则编码 | 含义 | 运行时解析 |
|---|---|---|
| DEPT_HEAD | 发起人所在部门负责人 | 按发起人部门 ID 查负责人,可带层级参数取上级 |
| ROLE_CODE | 指定角色 | 查拥有该角色的用户集合,转为任务候选人 |
| FIXED_USER | 固定岗位人员 | 直接取配置的用户,适用于法务、印章等固定岗 |
| FORM_SELECT | 发起人自选 | 读提交时写入的变量,灵活但需校验候选人合法性 |
| BEAN_CUSTOM | 自定义规则 | 调用指定 Spring Bean,按金额、部门、业务类型组合判断 |
三层权限校验
引擎原生的「任务分配给我」查询只能满足最基础场景,复杂审批必须由业务层加一层过滤和校验。三层各管一件事:
| 校验层 | 校验内容 | 落点 |
|---|---|---|
| 可见性 | 谁能看到哪些待办 | 不用引擎原生列表;自定义 SQL 关联引擎任务、关联表与业务表,叠加部门范围与业务类型过滤 |
| 操作权 | 谁有权批这一条 | 审批接口校验任务归属(处理人/候选人/代办人),并校验单据状态合法,防止重复提交与越权 |
| 管理权 | 谁能强制干预 | 跳转节点、终止流程、替换审批人单独授予流程管理员角色,全部写审计日志 |
此外还有一层容易被忽略的字段级权限:同一张表单在不同节点可见范围不同,例如经理节点只读、财务节点仅金额可改。做法是在节点扩展属性里配字段权限映射,前端按当前节点渲染,后端提交时再校验一次,防止绕过前端改数据。
通知:监听器触发,业务服务执行
通知的时机天然与流程节点绑定,所以用监听器做触发入口最合适,但监听器里只做一件事——把上下文交给业务通知服务,不写发送逻辑。这样加渠道、改文案、做免打扰都不必碰流程模型。
| 场景 | 触发点 | 通知对象 |
|---|---|---|
| 待办提醒 | 任务创建监听器 | 当前节点审批人,按节点配置渠道发送 |
| 结果通知 | 流程结束监听器 | 发起人,告知通过与驳回结论 |
| 超时催办 | 引擎定时任务 | 滞留任务的审批人及其上级 |
| 抄送知会 | 节点完成监听器 | 抄送人,只读不可批,抄送记录存业务表 |
选型与落地建议
引擎选型取决于两件事:是否需要业务人员在线画流程,以及能否接受引擎表数量与二次开发量。按国内内部审批的实际落地情况:
| 方案 | 适用场景 | 需要额外投入 |
|---|---|---|
| Flowable | BPMN 标准流程、会签或签、子流程、条件网关,生态与国内资料最成熟 | 任意驳回、拿回、自由跳转需二次开发;前端配 bpmn-js |
| Warm-Flow | 追求轻量、表结构少、仿钉钉审批体验,国产审批特性原生支持 | 复杂 BPMN 语义支持有限,跨系统集成能力需自建 |
| 成套脚手架 | RuoYi-Vue-Pro、JeecgBoot 一类,自带流程设计器、动态表单、多租户 | 低代码框架侵入较重,深度定制需先剥离冗余模块 |
架构上,单业务系统用嵌入式(SpringBoot 直接引 starter,同库同应用)最省事;多条业务线各自有系统、需要统一配置与监控时,才拆独立流程中心,通过接口提交与回调解耦。
成熟框架的默认做法
对照 RuoYi-Vue-Pro(芋道)、老版 RuoYi-Flowable 与 JeecgBoot,可以看出它们在关键机制上与本文方案一致,差别主要在状态存储的默认取舍。
| 框架 | 关联与状态载体 | 后置动作分发 | 审批人分配 |
|---|---|---|---|
| RuoYi-Vue-Pro | 独立扩展表 bpm_process_instance_ext 存 businessKey、businessType、流程结果与状态;业务表仍自带审批状态,属双份 | 结束监听器先更新扩展表,再发本地事件,业务模块订阅后回写自己的业务表 | 节点配规则编码,任务创建时解析,不同步引擎身份表 |
| RuoYi-Flowable | 多为把流程实例 ID 塞进业务表字段,缺少标准化扩展表 | 示例性质,常见在监听器里按业务类型硬分支,需二次改造 | 同左,节点配置驱动 |
| JeecgBoot | 低代码表单自动附加流程状态字段并回写 | 自定义表单推荐事件回调,低代码表单由框架统一回写 | 支持表达式解析审批人,运行时查表 |
共性很清楚:都不把业务角色同步进引擎身份表,都在任务创建时动态解析审批人,都靠事件而非硬分支承接后置动作。差异只在框架默认会给业务表冗余一份审批状态,方便业务列表直接查;如果要做到「审批状态只信关联表」,删掉业务表字段、改查询逻辑即可,属于自定义取舍,框架不会拦。
六条必须守住的约束
不把用户和角色同步进引擎身份表,组织数据的唯一来源是业务系统。
不把业务字段塞进流程变量,变量只为路由服务。
所有前端操作在后端二次校验权限与状态,前端隐藏按钮只是体验优化。
单据状态只能由流程驱动变更,业务接口不允许随意改状态。
管理员干预、驳回、跳转全部留审计日志,异常流程才可追溯。
监听器只做分发,业务类型分支不写进流程代码,新增审批类型只加订阅者。
(注:部分内容由豆包工作 AI 生成)
