bpm 域开发
适用端:后端 | 类型:参考
上游模块,但本项目的定制方式是「直接改上游类」
enlev-module-bpm 来自上游 yudao,底层是 Flowable。与 infra 那种「在旁边挂一整套自研子域」不同, bpm 的定制几乎全部落在上游原有的类里——改 SimpleModelUtils、改 BpmModelServiceImpl、 在基类里加一道守卫、给上游的 handler 补一个第三种实现。
实测规模:
| 指标 | 实测值 |
|---|---|
src/main/java 文件数 | 265 |
src/main/java 行数 | 25,173 |
其中 @author 走进新时代(本项目改过或新写) | 39 文件 / 14.7% |
controller/admin 子域 | 6(base comment definition oa portal task) |
| 自有表 | 12(11 个 DO + 1 张裸 SQL 的归档表) |
| Flowable 引擎表 | 39 张 ACT_* + 6 张 FLW_* |
| 错误码 | 65 |
读这一页的正确姿势:bpm 是 agent 模块的下游依赖(agent → bpm), 工作流引擎里的 hitl.bpm_approval 节点会把审批真的丢进 Flowable。 所以本页有一半内容在回答「agent 接过来的时候,哪些约定是硬的」。
定位:bpm 的模块边界、Flowable 版本口径、三种 formType 的数据落地、16 个候选人策略、 事件为什么是同步的、requiredApproveType 为什么必须硬拦、部署权限的自举死锁。
前置阅读:后端架构与模块依赖、 数据库约定与表总览、 权限体系总览(bpm 的流程资源投影进 sys_grant)、 Agent 工作流引擎(下游消费方)。
它解决什么问题
🔴 pom 的 <description> 说「基于 Flowable 6」,实际依赖是 8.0.0
enlev-module-bpm/pom.xml:20 逐字写着:
工作流基于 Flowable 6 实现,分成流程定义、流程表单、流程实例、流程任务等功能模块。而 enlev-dependencies/pom.xml:50 是:
<flowable.version>8.0.0</flowable.version>这句描述是上游留下的,没人改。它不影响编译,但影响查文档: 拿着「Flowable 6」去搜 API,搜到的是 6.x 的文档,而 8.x 的 ProcessEngineConfiguration / DelegateExecution 若干签名已经变了。
实际影响:判断版本一律看 enlev-dependencies/pom.xml,不看模块 pom 的描述文字。
代码在哪
6 个子域(controller/admin/ 下的一级目录,逐个 ls 数出来的):
| 子域 | 干什么 | 归属 |
|---|---|---|
definition | 流程模型、流程定义、表单、分类、用户组、监听器、表达式 | 上游,被改得最重 |
task | 审核中心:我的申请 / 待办 / 已办 / 抄送,任务加签转办委派 | 上游,归档逻辑是本项目加的 |
oa | 请假单——上游自带的演示业务,用来示范「业务表单怎么接流程」 | 上游演示 |
base | 公共基础:用户/部门/角色的下拉数据(给设计器用) | 上游 |
comment | 审批意见 | 上游 |
portal | 流程门户 | 上游 |
framework/ 下值得单独记住的三个位置:
| 路径 | 里面是什么 |
|---|---|
framework/flowable/core/util/SimpleModelUtils.java | Simple 模型 JSON → BPMN XML 的翻译器。requiredApproveType 静默失效的源头在这里 |
framework/flowable/core/candidate/ | 17 个候选人策略实现 + BpmCandidateSubjectTypes |
framework/flowable/core/listener/demo/exection/ | 上游的执行监听器示例(3 个文件) |
exection 是上游的拼写错误,照抄别改
framework/flowable/core/listener/demo/exection/(少一个 u)与同级 demo/task/ 各有 3 个示例类:
DemoDelegateClassExecutionListener DemoDelegateClassTaskListener
DemoDelegateExpressionExecutionListener DemoDelegateExpressionTaskListener
DemoSpringExpressionExecutionListener DemoSpringExpressionTaskListener目录名 exection 是上游 yudao 的笔误,本仓原样继承。 改它会连带改掉数据库里存量监听器配置的全限定类名(bpm_process_listener 存的是类名字符串), 属于纯亏的改动。写文档、抄示例时都按原样来。
核心机制
12 张自有表:11 个 DO + 1 张没有 DO 的
bpm_* 前缀的表实测 12 张,但只有 11 个 @TableName:
| 表 | DO | 说明 |
|---|---|---|
bpm_category | BpmCategoryDO | 流程分类 |
bpm_delegation | BpmDelegationDO | 委派规则 |
bpm_form | BpmFormDO | 流程表单(formType = NORMAL 用) |
bpm_instance_participant | BpmInstanceParticipantDO | 实例参与人(行权限的数据源) |
bpm_oa_leave | BpmOALeaveDO | 上游演示的请假单 |
bpm_process_definition_info | BpmProcessDefinitionInfoDO | 定义级扩展信息 |
bpm_process_expression | BpmProcessExpressionDO | 流程表达式 |
bpm_process_instance_copy | BpmProcessInstanceCopyDO | 抄送 |
bpm_process_listener | BpmProcessListenerDO | 监听器配置 |
bpm_task_candidate_snapshot | BpmTaskCandidateSnapshotDO | 候选人快照 |
bpm_user_group | BpmUserGroupDO | 用户组 |
bpm_instance_participant_archive | 无 | 🔴 裸 SQL,没有 DO |
最后那张表是本项目加的归档表,BpmInstanceParticipantArchiveService 里直接拼 SQL, 不走 MyBatis Plus。它因此绕过了租户拦截器(TenantDatabaseInterceptor 靠 TableInfoHelper.getTableInfo(tableName) 判断要不要拼 tenant_id,查不到 TableInfo 就 return true 忽略)。 这里是故意的:归档搬迁要跨租户一次性做完,不该被当前上下文的租户切一刀。 但如果你照这个写法再拼一张业务表,租户过滤也就跟着没了。
为什么 bpm_instance_participant 值得单独一张表
它是行权限的数据源:「这条单据谁能看」最终落到「谁是这个流程实例的参与人」。 system 域 · 跨模块通道讲的 BpmParticipantCommonApi 就是它的读出口。
⚠️ 那个接口返回空集合与返回 null 含义不同:null = 「这张单子不归 bpm 管」(调用方应按自己的规则处理), 空集合 = 「归 bpm 管,但确实没有参与人」(应当一行都看不到)。混淆两者的结果是越权或者全黑。
三种 formType,三个数据处理器
BpmModelFormTypeEnum:18-20 逐字:
NORMAL(10, "流程表单"),
CUSTOM(20, "业务表单"),
ONLINE(30, "在线表单");对应 BpmFormDataHandler 的三个实现,由 BpmFormDataHandlerFactory 按 formType 分派:
| formType | 实现类 | 数据落在哪 | 归属 |
|---|---|---|---|
NORMAL 10 | BpmNormalFormDataHandler | bpm_form 定义的表单,数据进 Flowable 变量 | 上游 |
CUSTOM 20 | BpmCustomFormDataHandler | 业务方自己的表(如 bpm_oa_leave),业务方自己写 Service | 上游 |
ONLINE 30 | BpmOnlineFormDataHandler | infra 表单设计绑定的物理表 | 本项目自研 |
第三种是低代码平台与工作流的接缝,也是最容易踩坑的一处。BpmOnlineFormDataHandler 的类注释逐字:
数据落到「表单设计」(infra_form_design)表绑定的物理表,读写全部委托
FormDesignApi—— 其内部走 FormDesignRuntimeService,方法已带@Transactional(REQUIRED), 会自动加入调用方的外层事务,从而保证「业务表写入」与「流程启动 / 任务完成」的原子性。这里不直接拼 SQL:表绑定的字段映射、主键策略、逻辑删除、系统字段填充等规则都在表单设计模块内, 绕过它自己写 SQL 必然与之不一致。
🔴 update() 是「全量覆盖」语义,必须先读后合并
BpmOnlineFormDataHandler#update 的注释逐字:
注意:FormDesignRuntimeService.update 是全量覆盖语义 —— 它遍历 fieldMapping 里的每个字段 并写入 data.get(field),缺失的键会被写成 null。因此这里必须先读出整行、合并后再写, 否则节点表单只提交部分字段时会把其它字段清空。
失效方向:静默数据损坏。节点表单只配了两个可写字段、审批人只填了这两个, 如果直接拿这两个字段去 update,同一行其它列全部变 null,而且没有任何报错。
写新的 BpmFormDataHandler 实现、或在别处调 FormDesignRuntimeService#update 时, 这条读-合并-写是必须复刻的,不是可选优化。
系统列走单独通道
process_instance_id 与 process_status 两列不在 fieldMapping 里, 所以 updateData 写不进去,必须走 updateSystemColumns。两个常量名是固定的:
public static final String COLUMN_PROCESS_INSTANCE_ID = "process_instance_id";
public static final String COLUMN_PROCESS_STATUS = "process_status";注释里给了两条理由,都值得记住:
COLUMN_PROCESS_INSTANCE_ID:「固定列名,不接受映射到别名」COLUMN_PROCESS_STATUS:「用process_status而非status—— 与业务表自己的status业务字段解耦,避免同表撞名」
infra 侧的白名单与之精确对齐,FormDesignRuntimeServiceImpl:160-167 逐字:
private static final Set<String> SYSTEM_WRITABLE_COLUMNS = Set.of(
"process_instance_id",
"process_status"
);这个白名单只有两项,而且不要扩充
FormDesignRuntimeServiceImpl 的注释明确警告:不要为了一时方便随意扩充, 否则它会退化成「绕过表绑定配置的通用写入口子」。 详见 infra 域开发 · 系统列白名单。
实际影响:在 bpm 里想给在线表单的业务表加一个「流程结束时间」列, 不能走系统列通道。要么进 fieldMapping(变成普通业务字段),要么另想办法。
updateProcessInstanceId() 为什么要回写一次
发起流程时,业务数据先写入才能拿到主键,而流程实例编号是在启动流程之后才产生的。 所以顺序必然是:写业务表 → 启动流程 → 回写 process_instance_id。 这个回写不是冗余,去掉它之后「从业务单据跳转到流程图」就断了。
这两列的类型也是平台校验的,而校验点离建表很远
updateSystemColumns 往 process_status 里写的是 BpmProcessInstanceStatusEnum 的状态码(整数), 往 process_instance_id 里写的是流程实例编号(字符串)。所以挂流程前 FormDesignApiImpl#resolveUnavailableReason 会查:process_instance_id 必须是字符串型且长度不小于 64 (常量 PROCESS_INSTANCE_ID_MIN_LENGTH),process_status 必须是整数型(tinyint / int / bigint); 不合格就把这张在线表单标成「不可用」,理由原样给到界面上(不合并成一句「不可用」, 用户要据此知道去补哪一列)。
🔴 类型不合格不违反任何 DDL 规则:建表、存表单草稿、发布全都成功,只有到挂流程这一步才被拒, 而建表不可逆(DDL 回滚不掉),事后只能手写 ALTER。所以同一条判据也被提前交了出去: DataModelApi#getTableConstraints() 的 flowColumns(列名 / 合格的受控类型 / 最小长度) 由 DataModelApiImpl 从上面那几个常量与 DataModelColumnTypeEnum 推出来, AI 侧的 DataModelCreateValidator 读它、拦在建表参数上(改表侧只判新增的与类型被改过的列, 免得历史上已经建错的表连无关改动都做不了)。判据只有这一份,两侧都读它—— 两边各抄一份的后果不是编译错误,是各自漂移。
在线表单不冻结快照
流程定义发布时会冻结绝大多数配置(表单字段、候选人、按钮),但在线表单的 formDesignCode 不冻结。 三处源码逐字确认:
| 位置 | 原文 |
|---|---|
BpmProcessDefinitionRespVO:44 | 「在线表单不冻结快照,formConf / formFields 由后端读取时按该编码实时补齐」 |
BpmOnlineFormEnricher:23 | 同上口径 |
BpmOnlineFormStatusListener:81 | 「最新定义的 formDesignCode 可能已经变了,会把状态写到另一张表去」 |
为什么这么设计:在线表单的价值就在于「表单改了、历史单据跟着改」—— 业务上加一个字段,之前提交的单子也要能看到这一列。冻结快照会把这个价值抹掉。
代价:改了表单设计,历史流程的读写目标会跟着变
BpmOnlineFormStatusListener:81 那句话描述的是一个真实风险: 状态监听器按 formDesignCode 找物理表,如果流程实例启动之后有人把模型上的 formDesignCode 改成了另一张表,那么这条老流程后续的 process_status 更新 会写到新表去,老表那一行的状态永远停在旧值。
失效方向:静默。没有报错,只有「这条单子的流程状态不对了」。
规避方式:formDesignCode 一旦被在跑的流程引用,就当它是不可变的。
候选人:16 个枚举值、17 个实现类、6 个主体类型
BpmTaskCandidateStrategyEnum(在 framework/flowable/core/enums/)实测 16 个值:
ROLE DEPT_MEMBER DEPT_LEADER MULTI_DEPT_LEADER MULTI_DEPT_LEADER_MULTI POST USER
APPROVE_USER_SELECT START_USER_SELECT START_USER START_USER_DEPT_LEADER
START_USER_DEPT_LEADER_MULTI USER_GROUP FORM_USER FORM_DEPT_LEADER EXPRESSION
ASSIGN_EMPTY实现类 17 个——比枚举多一个,因为 AbstractBpmTaskCandidateDeptLeaderStrategy 是三个「部门负责人」策略共用的抽象基类,本身不对应枚举值。
BpmCandidateSubjectTypes:6 个常量,一个没有 default 的 switch
public static final String USER = "USER";
public static final String ROLE = "ROLE";
public static final String DEPT = "DEPT";
public static final String POST = "POST";
public static final String GROUP = "GROUP";
public static final String DYNAMIC = "DYNAMIC";of(BpmTaskCandidateStrategyEnum) 是一个穷尽 switch 且没有 default 分支。
这是好事:加枚举值会编译不过
Java 的 switch 表达式在穷尽枚举且无 default 时,缺一个分支就编译失败。 所以往 BpmTaskCandidateStrategyEnum 里加第 17 个值时, 编译器会直接告诉你「你还没想清楚这个策略属于哪个主体类型」。
别为了消掉编译错误随手加 default——那会把一个编译期检查换成一个运行期的静默漏配。
recalculable(subjectType) 的实现只有一行:
return !DYNAMIC.equals(subjectType);背后的语义(类注释里的原话,讲的是离职交接):
张三因『研发部负责人』被选中 → 换了负责人就自动有解,不需要人工介入 张三因『被点名』被选中 → 没有任何规则能推出接手人,必须人工指定
也就是说 USER / ROLE / DEPT / POST / GROUP 这五类都能重算(换个人、换个部门负责人, 系统能自己算出新的候选人),只有 DYNAMIC 不能——它本来就是「运行时临时指定的那一个人」, 没有规则可以推出接手人。
⚠️ 类注释还专门写了一句:「主体类型不等于『授权主体』」。 BpmCandidateSubjectTypes 描述的是「这个候选人是怎么算出来的」, 而 权限体系里的「主体」描述的是「谁被授予了什么」。 两者都叫 subject,但不是一个概念,别混用。
事件是同步的:这一条决定了下游能不能安全接
BpmProcessInstanceEventPublisher:22 的全部实现:
public void sendProcessInstanceResultEvent(@Valid BpmProcessInstanceStatusEvent event) {
publisher.publishEvent(event);
}publishEvent 是 Spring 的同步派发。bpm 模块实测:
| 检查项 | 结果 |
|---|---|
@TransactionalEventListener | 0 处 |
@Order( | 0 处 |
@Async 在事件链上 | 无 |
🔴 审批人点「通过」的那一瞬间,所有监听器都跑在他的线程和事务里
后果有三条,每一条都能咬人:
- 租户上下文就是审批人的租户。
TenantContextHolder此时是审批人的值。 本平台 BPM 不跨租户审批,所以「审批人租户 == 流程发起人租户」通常成立, 但这是依赖业务约定,不是框架保证。 - 监听器抛异常会连累审批事务回滚。见下面基类守卫那一节。
- 监听器里做慢操作 = 审批接口变慢。没有异步兜底。
agent 侧的 AgentBpmApprovalListener 的类注释专门论证了第 1 条,并明确写了 「全程不用 executeIgnore(agent 架构红线)」——它直接以行身份(执行所有者)恢复工作流。
基类里的 businessKey 守卫
BpmProcessInstanceStatusEventListener#onApplicationEvent 是 final 的, 它先按 processDefinitionKey 过滤(一个业务配一个监听器),再执行这段守卫:
if (!NumberUtil.isLong(event.getBusinessKey())) {
log.debug("[onApplicationEvent][流程定义({}) 的实例({}) businessKey({}) 不是数字主键,"
+ "不派发给 {}(本基类只服务「businessKey = 业务实体数字主键」的监听器)]",
event.getProcessDefinitionKey(), event.getId(), event.getBusinessKey(),
getClass().getSimpleName());
return;
}
onEvent(event);它上面的注释是全模块最值得读的一段,逐字:
🔴 businessKey 不是数字主键就不派发。三个子类(
BpmOALeaveStatusListener、CrmContractStatusListener、CrmReceivableStatusListener)都在 onEvent 里无条件Long.parseLong(event.getBusinessKey()), 而 agent 模块的 hitl.bpm_approval 节点用agent-wf:<executionId>:<nodeId>这种命名空间键发起审批 (它必须携带 executionId + nodeId,才能把审批结果路由回正确的挂起节点,见AgentBpmApprovalService#resolveBpmApprovalEventKey)。于是只要作者把 bpmProcessDefKey 配成oa_leave(仓库自带的演示流程定义,也是最容易被选中的那个),审批人一点「通过」就抛 NumberFormatException,后果是两重的:① Spring 的
SimpleApplicationEventMulticaster默认没有 ErrorHandler,第一个抛异常的监听器 会中止整条链 ⟹AgentBpmApprovalListener压根没跑 ⟹ 工作流永远停在 INTERRUPTED;② Flowable 的命令上下文一起中止 ⟹ 审批操作返回 500 且事务回滚。
🔴 放在基类而不是逐个守卫子类:这里是唯一能一次覆盖全部子类的地方(onApplicationEvent 已经是 final、已经在做 processDefinitionKey 过滤),且「只派发数字键」不改变任何现有行为 —— 现有三个子类的 businessKey 无一例外都是数字主键,非数字的那些今天只会抛异常。
🔴 不要用
@Order让 agent 的监听器先跑:那只治 ①、不治 ②,而且更坏 —— 等于在审批结果即将 回滚时先唤醒工作流,制造「BPM 说没通过、agent 说通过了」的跨模块不一致,比一致地卡死更难查。ℹ️
log.debug而不是 warn:对 agent 流程这是正常情况(每次审批都会走到),warn 会刷屏; 但又不能全静默 —— 真出问题时(某个子类本该收到数字键却没收到)需要留一条可查的线索。
注释里点名的三个子类,本仓只有一个
CrmContractStatusListener 与 CrmReceivableStatusListener 是上游 CRM 模块的类, 本仓没有 enlev-module-crm。src/main 下真正继承这个基类的只有 BpmOALeaveStatusListener:17(它在 :29 做 Long.parseLong)。
这不是注释写错了——它写的是「这个基类的契约」,把上游其它模块的既有实现一并列出来, 是为了说明「所有既有子类的 businessKey 都是数字主键」这个前提。 如果哪天引入 CRM 模块,那两个类会原样进来,守卫照样保护它们。
agent 侧为什么不继承这个基类
AgentBpmApprovalListener 直接 implements ApplicationListener<BpmProcessInstanceStatusEvent>。 它的类注释给了理由:
那个基类按单个 processDefinitionKey 过滤(一个业务配一个监听器); 而 bpm_approval 是通用能力,要覆盖所有由 agent 工作流发起的审批(processDefinitionKey 各不相同)。 故照
BpmOnlineFormStatusListener的做法直接实现 ApplicationListener, 按 businessKey 前缀过滤——只有agent-wf:开头的才是本模块发起的。
它还刻意不 import bpm 的状态枚举,而是本地镜像了三个常量:
private static final int STATUS_APPROVE = 2;
private static final int STATUS_REJECT = 3;
private static final int STATUS_CANCEL = 4;理由(注释原文):那个枚举在 bpm.enums 包,而 agent 跨模块只能走 <module>.api.* (AgentArchitectureTest#testOnlyDependOnApiLayer 红线),bpm 的 api 层又没有暴露状态枚举。
本地镜像常量在这里是安全的,因为它是持久化契约
BpmProcessInstanceStatusEnum 的值写进了 bpm 流程实例的状态列,也写进了这个事件的 status 字段。 改它会破坏所有既有数据和所有既有消费方,所以它不会漂移。
但这条推理只对「已经落库的状态码」成立。如果你要镜像的是一个还会改的内部枚举, 本地常量就是定时炸弹。判断标准:这个值有没有被写进数据库或者跨进程的报文里。
幂等:靠一次 CAS 认领
审批完成事件理论上可能重发。AgentBpmApprovalListener 依赖下游 publishEvent → resume 里的 CAS:
UPDATE ... SET status = 'RUNNING' WHERE id = ? AND status = 'INTERRUPTED'已恢复的执行影响 0 行、被安静跳过,所以重复事件不会双跑工作流。 非终态事件(RUNNING)直接忽略——只在 APPROVE / REJECT / CANCEL 唤醒。
这条对你写新监听器的启示:事件是同步的、可能重发的、且没有框架级去重。 去重必须自己在业务侧做,CAS 是最省事的一种。
requiredApproveType:整套配置里唯一会静默失效的一条
BpmProcessConstraintsRespDTO 存在的唯一理由(类注释原文):
让规则只有一份 … 漂移的两个方向都很糟:放行了后端会拒的(白烧一轮模型调用), 或拦住了界面上明明能配的(AI 显得比人笨)。与
DataModelApi.getTableConstraints()是同一个套路。
其中 requiredApproveType 字段上的注释逐字:
🔴 这是整套流程配置里唯一会「静默失效」的一条:
SimpleModelUtils.buildBpmnUserTask在 approveType 不等于该值时提前 return, 于是候选人、审批方式、字段权限、按钮、超时、监听器全部被丢弃, 不报错、不告警、部署照样成功,只有真正跑流程时才发现「这个节点谁都审批不了」。所以校验器必须硬拦它,而不是给个警告。
源头在 SimpleModelUtils:430-439,逐字:
private UserTask buildBpmnUserTask(BpmSimpleModelNodeVO node) {
UserTask userTask = new UserTask();
userTask.setId(node.getId());
userTask.setName(node.getName());
// 如果不是审批人节点,则直接返回
addExtensionElement(userTask, USER_TASK_APPROVE_TYPE, node.getApproveType());
if (ObjectUtil.notEqual(node.getApproveType(), BpmUserTaskApproveTypeEnum.USER.getType())) {
return userTask;
}
...return userTask; 之后被丢掉的东西(按源码顺序):候选人元素、表单字段权限、节点独立表单编码、 按钮、多实例/审批方式、拒绝处理、发起人自选处理、空处理、超时、监听器、加签、意见、节点类型、跳过表达式。
这个约束定义在 bpm,消费在 agent
requiredApproveType 全仓只有一处定义(BpmProcessConstraintsRespDTO:33), 三处消费,全部在 agent 模块:
| 消费方 | 用途 |
|---|---|
ProcessNodeTreeExplainer:226 | 把约束讲给模型听 |
BpmProcessValidator:194 | 硬拦 |
DiagnoseNotWorkingTool:167 | 诊断「流程为什么跑不动」 |
这是 bpm 为 AI 专门开的一个口子:AI 生成流程配置时,最容易犯的就是 「approveType 填了个自动通过、但候选人也填了」这种矛盾组合,而后端会静默丢掉候选人。 所以必须把这条规则暴露出去,让 agent 在生成阶段就拦住。
子流程调起校验:刻意 fail-open,等着收紧
BpmSubProcessCallChecker 要解决的问题(类注释原文):
子流程有两种被发起的方式,现状是没人区分过:
人直接发起 → 该校验「这个人有没有该子流程的 START 权限」 流程调起 → 不该校验人的 START 权限(调起它的是引擎,不是人), 但【要校验主流程有没有被授权调用该子流程】不做第二条校验的后果:谁都能间接调起任何子流程。 只要在自己的流程里加一个 CallActivity 指向「薪资核定」, 就绕开了那个子流程上所有的 START 授权 —— 而且看上去完全正常。
它用的主体是 SYSTEM:bpm-engine(SYSTEM_SUBJECT_CODE),不是被记成发起人的那个人。 注释解释了为什么:那个人并没有「发起」子流程,是引擎调起的,用他校验会让合法的编排失败。
🔴 当前语义是「配了 DENY 才拦,没配一律放行」
canCall 的实现只调 hasExplicitDeny,源码里的注释逐字:
⚠️ 只拦【显式 DENY】,基线是放行。
这里【不能】用 hasResourceAction 判 ALLOW —— 流程资源的 default_effect 是 DENY(S5d §2.2),那样一来所有没补授权的存量父子编排会立刻全部调不起来。 那不是收紧权限,是把线上流程打死。
类注释里也写了收紧计划:「等管理员在 S8 的界面上把该配的配完,再由 S9 收紧成默认拒绝。」
失效方向:fail-open。在收紧之前,「没配」等于「允许」。 这是一个已知且有意的状态,不是 bug;但如果你在文档里承诺「子流程调起受权限控制」, 必须同时说明当前只拦显式 DENY。
另外还有一层 fail-open:PermissionCommonApi 用 ObjectProvider 注入, 拿不到时 return true,并用 AtomicBoolean missingWarned 保证 log.error 只打一次。 「只打一次」意味着:启动后日志里那一行 error 会被冲走,之后再也不出现。
部署权限的自举死锁
BpmModelServiceImpl#hasManagerPower 的注释记录了一个真实的死锁,逐字:
常规判据是流程资源上的 MANAGE 授权(S7b / TODO #30 摘掉了 managerUserIds 回落)。 但那条资源行(
bpm:process:<key>)是在deployModel成功之后 才由registerProcessResources写入的,于是模型从未部署过时:要部署 → 得先是管理员 → 得有资源行 → 得先部署成功这是个死锁,而且超管也解不开:
hasResourceAction直接调GrantDecisionService#hasPermission,那个方法刻意不对超管短路 (短路只加在清单侧,见其isSuperAdmin的注释),资源行不存在时一律return false。实测(2026-09-22,超管 userId=1):新建模型后 deploy 与 delete 双双报
MODEL_UPDATE_FAIL_NOT_MANAGER,模型变成既部署不了也删不掉的孤儿。 这不是 AI 特有的问题 —— 任何人在流程设计器里新建流程都部署不了。
解法是分两条判据:
private boolean hasManagerPower(Long userId, Model model) {
// 从未部署过:资源行还不存在,MANAGE 判定恒假,只能看 managerUserIds
if (StrUtil.isEmpty(model.getDeploymentId())) {
BpmModelMetaInfoVO metaInfo = BpmModelConvert.INSTANCE.parseMetaInfo(model);
return metaInfo != null && CollUtil.contains(metaInfo.getManagerUserIds(), userId);
}
// 已部署过:走重构后的资源判定。
// ⚠️ 用 model.getKey() 而不是 definitionId —— 资源标识本来就是按 key 取的
return processPermissionChecker.isManager(userId, model.getKey());
}MANAGE 与 DESIGN 是两次独立查询,sys_grant 里没有蕴含关系
validateModelPermission 的注释:
⚠️ MANAGE 与 DESIGN 是两次独立查询(sys_grant 里没有蕴含关系), 原因见
BpmProcessPermissionChecker#canDesign
而且 DESIGN 这一档额外要求 deploymentId 非空——因为资源行是部署之后才写的, 从未部署的模型在这一档天然为空,「不是漏了回落,是那个窗口里根本没有可以授 DESIGN 的对象」。
validateScopeWritable:挡住「设计权给自己发管理员」
startUserIds / startDeptIds / managerUserIds 看着是模型元数据,实际是授权数据: deployModel 会经 registerProcessResources → syncProcessAuthorization 把它们投影成 sys_grant 里的 START / MANAGE 行。注释里的攻击链逐字:
updateModel(managerUserIds=[自己]) → deployModel → 自己拿到 MANAGE 行
→ deleteModel / cleanModel 全部放行而且不必自己动手部署 —— 名单埋进 metaInfo 就行,等真正的管理员下次部署照样投影生效。 两档拆分的全部意义会在这一步被抹平,所以必须挡住。
两个实现细节值得抄:
- ⚠️ 不能改成「悄悄保留库里的旧值」:
managerUserIds在BpmModelMetaInfoVO上是@NotEmpty必填,前端每次保存都会带上它。「静默丢弃的结果是界面显示已保存、实际没保存,比报错更糟」。 所以这里选择抛MODEL_UPDATE_FAIL_NOT_MANAGER_FOR_SCOPE。 - 按集合比而不是按列表比:这三个字段是「主体集合」,前端重新渲染可能换顺序, 顺序变化不该被当成越权。实现是
sameIdSet,两边都new HashSet<>(CollUtil.emptyIfNull(...))。
参与者归档:INSERT IGNORE … SELECT + 一个人工恢复口子
BpmInstanceParticipantArchiveService 的搬迁 SQL 是这个形状:
INSERT IGNORE INTO bpm_instance_participant_archive (%s) SELECT %s
FROM bpm_instance_participant WHERE id IN (%s)然后 DELETE。待搬迁的行由一条批量查询挑出来:
WHERE end_time IS NOT NULL
AND (CASE WHEN retain_until IS NOT NULL THEN retain_until <= NOW() ELSE end_time <= ? END)
ORDER BY id LIMIT ?INSERT IGNORE 是为了让任务重跑安全:同一行被搬两次时第二次静默跳过,不会主键冲突。
restore(processInstanceId) 存在的理由(注释原文)
归档是按时间一刀切的,而「这条单子要复查」是按业务来的 —— 审计、纠纷、监管调档都会要求让某个人重新看到某条老单据。 没有这个口子的话,唯一的办法是手工写 SQL,而那件事没人敢做。
方法上是 @Transactional(rollbackFor = Exception.class) + @DataPermission(enable = false)。 后者是必须的:恢复归档数据时,当前用户很可能已经看不到那条实例了(行权限就是按参与人算的), 不关掉行权限就恢复不了自己的数据。
这是一个「关掉行权限是正当的」的样板:范围被 processInstanceId 死死限定, 且入口本身有权限校验。照抄之前先确认这两条都在。
任务侧 BpmInstanceParticipantArchiveJob:39 implements JobHandler,:17 的注释: 「处理器填 bpmInstanceParticipantArchiveJob,建议 cron 放在业务低谷」。
但本地 profile 里 Quartz 是被 exclude 掉的
application-local.yaml:6-11 排除了 QuartzAutoConfiguration, 所以这个归档任务在本地永远不会自己跑。手动「执行一次」是另一条路径。 详见 infra 域开发 · 定时任务。
数据模型
| 类别 | 数量 | 说明 |
|---|---|---|
bpm_* 自有表 | 12 | 11 个 DO + bpm_instance_participant_archive(无 DO) |
ACT_* Flowable 引擎表 | 39 | 流程定义 / 运行时 / 历史 / 身份。不要手改 |
FLW_* Flowable 扩展表 | 6 | 事件注册、通道等 |
| 错误码 | 65 | ErrorCodeConstants |
权限按钮(bpm:*) | 44 条,全部开启 | 与 ai 的 13 个禁用菜单形成对照 |
🔴 ACT_* / FLW_* 由 Flowable 自己管,版本升级会改表
这 45 张表的结构由 Flowable 8.0.0 决定。升级 Flowable 版本时, 引擎表会跟着变,而 sql/mysql/ 下没有任何一个脚本描述这个变更 (引擎建表走 Flowable 自己的 databaseSchemaUpdate)。
所以「Flowable 6 → 8」这种升级不能靠读 pom 的描述文字来判断影响面, 必须在测试库上真跑一次启动,让引擎自己把表升上去,再比对。
为什么 bpm 的 44 条权限按钮全开,而 ai 的 34 条也全开
两者性质不同:bpm 的菜单本身是开的,按钮开着是一致的; ai 的 13 个菜单全部 status = 1(禁用),但 34 条 ai:* 按钮全开 ⟹ 端点仍注册、仍可调。 菜单禁用是 fail-open 的。详见 ai 域开发 · 数据模型。
接口清单
| 维度 | 实测值 |
|---|---|
| Controller | 15(全部在 controller/admin,无 app 端) |
| 端点 | 82 |
.generated/api-bpm.md 曾经滞后,本轮已重跑对齐
写这一页时先量到源码是 15 / 82,而当时的生成清单记的是 14 / 79—— 少的那一个 Controller 与三个端点是多行注解导致的:gen-api.mjs 原来在遇到不以 @ 开头的续行时 会清空缓冲,把上面已经攒到的 @PostMapping 一起丢掉。脚本已修(改用「括号是否配平」判据), 本轮重跑后产物与源码一致。
教训:.generated/ 是一份快照,不是权威。要准确数字就跑脚本,或者直接数注解—— 而且数注解时要用行首锚定,见 system 域开发 · 接口清单。
流程资源投影到 sys_grant 的权限串格式是 bpm:process:<key> (BpmSubProcessCallChecker.PROCESS_RESOURCE_PREFIX = "bpm:process:"),动作有 START / MANAGE / DESIGN。 资源实例级权限串在多个模块里有字面量,改格式要全仓搜—— 详见 infra 域开发 · 接口清单。
怎么扩展
加一个候选人策略
- 往
BpmTaskCandidateStrategyEnum加枚举值。 - 编译器会立刻报错——
BpmCandidateSubjectTypes.of()是穷尽 switch 无 default, 新值没有分支就编译不过。这是设计好的强制项,去那里补上主体类型。 - 决定它
recalculable与否:能从规则推出接手人的(按角色、按部门负责人)归五类之一; 纯运行时点名的归DYNAMIC(不可重算,离职必须人工交接)。 - 写实现类,放到
framework/flowable/core/candidate/下。 - 如果是「部门负责人」类的变体,继承
AbstractBpmTaskCandidateDeptLeaderStrategy——它已经是三个策略共用的基类。
加一种 formType
BpmModelFormTypeEnum加值(现有 10 / 20 / 30)。- 实现
BpmFormDataHandler,注册进BpmFormDataHandlerFactory。 - 如果数据落在 infra 的物理表上,必须复刻
BpmOnlineFormDataHandler的三条纪律:- 读写全部委托
FormDesignApi,不自己拼 SQL(字段映射、主键策略、逻辑删除、系统字段填充都在那边) update先读整行、合并、再写(全量覆盖语义)- 系统列走
updateSystemColumns,不走 fieldMapping
- 读写全部委托
- 依赖
FormDesignRuntimeService方法上的@Transactional(REQUIRED)加入外层事务, 不要在 handler 上另开REQUIRES_NEW——那会打断「业务表写入」与「流程启动」的原子性。
接一个流程状态监听器
两条路,选错会有真实后果:
| 场景 | 做法 | 理由 |
|---|---|---|
| 一个业务对应一个固定的 processDefinitionKey | 继承 BpmProcessInstanceStatusEventListener,实现 getProcessDefinitionKey() + onEvent() | 基类已做 key 过滤 + businessKey 数字守卫,onEvent 里可以直接 Long.parseLong |
| 要覆盖多个 key(通用能力) | 直接 implements ApplicationListener<BpmProcessInstanceStatusEvent>,自己按 businessKey 前缀过滤 | 基类只能绑一个 key。参照 AgentBpmApprovalListener / BpmOnlineFormStatusListener |
无论哪条路:
- 不要用
@Order抢执行顺序(bpm 现在 0 处@Order)。基类注释里写了为什么这更坏: 它只治「链被中止」,不治「事务回滚」,还会制造跨模块不一致。 - 自己做幂等。事件是同步的、可能重发的,框架不去重。CAS 认领是最省事的一种。
- 别在里面做慢操作或远程调用,那会直接拖慢审批接口,而且异常会回滚审批。
- 状态码如果要跨模块用,检查 bpm 的 api 层有没有暴露;没有就本地镜像常量, 但只镜像已经落库的那些(持久化契约不会漂移)。
坑与注意
静默 / fail-open
- 🔴
requiredApproveType不匹配 →buildBpmnUserTask提前 return ⟹ 候选人、审批方式、字段权限、按钮、超时、监听器全部被丢弃,部署照样成功。 只有真正跑流程时才发现「这个节点谁都审批不了」。 - 🔴 在线表单
update是全量覆盖 ⟹ 节点表单只提交部分字段时,其它列被写成 null。 - 🔴
formDesignCode不冻结快照 ⟹ 改了表单设计后,老流程的状态更新会写到新表去, 老表那行永远停在旧值。 - 🔴
BpmSubProcessCallChecker当前只拦显式 DENY ⟹ 没配就是允许。 这是有意的存量回落,收紧计划写在源码注释里(S9 / TODO #30)。 PermissionCommonApi不可用时canCall返回 true,且log.error靠AtomicBoolean只打一次 ⟹ 启动日志被冲走之后就再也看不到。- 归档表
bpm_instance_participant_archive没有 DO ⟹ 绕过租户拦截器(故意的), 但这个写法抄到业务表上就会丢租户过滤。 - 本地 profile 排除
QuartzAutoConfiguration⟹ 参与者归档任务在本地永不自动执行。 - pom
<description>写「Flowable 6」,实际是 8.0.0 ⟹ 照描述查文档会查到错的 API。 .generated/是快照,不是权威 —— bpm 域曾量到清单记 14/79 而源码是 15/82(多行注解被脚本丢掉),本轮已修并重跑。demo/exection/目录名是上游拼写错误 ⟹ 「修正」它会改掉存量监听器配置的类名字符串。
fail-closed
BpmProcessInstanceStatusEventListener的 businessKey 数字守卫 → 非数字键不派发(log.debug+return)。 这挡掉的是NumberFormatException中止整条监听链 + 回滚审批事务。validateModelPermission→ 既无 MANAGE 又无 DESIGN(或 DESIGN 但从未部署)即抛MODEL_UPDATE_FAIL_NOT_MANAGER。validateScopeWritable→ 只持 DESIGN 的人动了startUserIds/startDeptIds/managerUserIds即抛MODEL_UPDATE_FAIL_NOT_MANAGER_FOR_SCOPE。这里刻意选择报错而不是静默保留旧值。BpmProcessConstraintsRespDTO的硬拦 → agent 生成流程配置时违规直接拒, 而不是给个警告让部署去静默丢配置。BpmCandidateSubjectTypes.of()穷尽 switch 无 default → 加枚举值编译失败。restore()的@DataPermission(enable = false)被processInstanceId限定死, 入口本身有权限校验(这是正当关闭行权限的样板,不是漏洞)。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 维度 | 实测值 |
|---|---|
| 测试文件 | 36 |
@Test 方法 | 194 |
@Disabled | 5 |
src/test/resources/application-unit-test.yaml | ✅ 存在 |
bpm 的测试密度在四个上游模块里排第三(system 83/1000、infra 123/1344、bpm 36/194、ai 43/187), 但 @Disabled 只有 5 个——比 ai 的 88 好得多。
但 Flowable 相关的行为基本测不到
BaseDbUnitTest 用的是 H2,Flowable 引擎表在 H2 上能建,但引擎行为(多实例、边界事件、 超时、异步续作)大量依赖真实数据库与 Job Executor。 本仓实测 0 处 async-executor 配置——也就是说异步执行器根本没被配起来过, 「超时自动处理」这类特性在测试里跑不出真实行为。
实际影响:改 SimpleModelUtils(JSON → BPMN 翻译)有测试兜底; 改「流程跑起来之后的行为」没有,只能在真环境点一遍。
未证实
读源码读到、但没跑起来验证的
- 「
requiredApproveType不匹配会丢掉全部节点配置」—— 我是把BpmProcessConstraintsRespDTO:24-33的注释与SimpleModelUtils:430-439的return userTask对着读通得出的,没有真的部署一个 approveType 错误的流程然后跑一遍。 注释本身是本项目作者写的、且与源码一致,可信度高,但仍属读码结论。 - 「部署权限的自举死锁」—— 注释里写了实测日期与现象(2026-09-22,超管 userId=1), 但那是修复前的实测。当前的
hasManagerPower回落逻辑我没有真的新建一个模型部署一次验证。 - 「改
formDesignCode会把老流程的状态写到新表去」—— 由BpmOnlineFormStatusListener:81的注释推出,没有真的改一次再跑一条老流程。 - 「
bpm_instance_participant_archive绕过租户拦截器」—— 由「没有 DO ⟹TableInfoHelper.getTableInfo返回 null ⟹computeIgnoreTable返回 true」这条链推出(TenantDatabaseInterceptor:68-81逐字读过), 没有开 SQL 日志看过实际语句。 - 44 条
bpm:*权限按钮的开关状态查的是本地库,不是生产库。
推理得出、没有取证的
- 「Flowable 6 → 8 升级会改
ACT_*表结构」—— 按 Flowable 的一般行为推的, 没有真的在两个版本间跑一次databaseSchemaUpdate比对。 而且本仓 pom 一直是 8.0.0,那句「Flowable 6」只是文字遗留,可能从来没有真的跑过 6。 - 「0 处
async-executor配置 ⟹ 异步执行器没被配起来」—— 我 grep 的是这个字面量。 Flowable 也可能通过别的配置名或 Java Config 打开它,我没有穷尽所有配法。 - 「
restore()入口本身有权限校验」—— 我看到方法上是@DataPermission(enable = false), 没有逐个读它的调用方确认每一处都做了鉴权。如果有 Controller 直接暴露它而没加@PreAuthorize, 这就是一个越权口子。 - 「
BpmCandidateSubjectTypes的穷尽 switch 会编译失败」—— 按 Java 语言规则推的, 没有真的加一个枚举值编译一次。如果那个 switch 其实是语句而非表达式、 或者带了 default(我读到的是没有),结论就不成立。
相关页面
- Agent 工作流引擎 ——
hitl.bpm_approval节点,bpm 的主要下游消费方 - Plan 预览与人工确认 —— HITL 的另一半
- Agent 安全边界 —— 跨模块依赖红线(
AgentArchitectureTest) - system 域开发 ——
sys_grant的持有者、PermissionCommonApi的实现方 - infra 域开发 —— 在线表单的另一端(
FormDesignApi/ 系统列白名单) - ai 域开发 —— 四个上游模块里测试网最薄的那个
- 权限体系总览 —— 资源、授权、动作词表
- 资源目录与授权判定 ——
hasExplicitDeny的语义 - 行级数据权限 ——
@DataPermission(enable = false)关掉的是什么 - 表单运行时与扩展点 ——
FormDesignRuntimeService的全量覆盖语义 - 数据库约定与表总览 ——
ACT_*/FLW_*在整库里的位置
