Skip to content

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 是:

xml
<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.javaSimple 模型 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_categoryBpmCategoryDO流程分类
bpm_delegationBpmDelegationDO委派规则
bpm_formBpmFormDO流程表单(formType = NORMAL 用)
bpm_instance_participantBpmInstanceParticipantDO实例参与人(行权限的数据源)
bpm_oa_leaveBpmOALeaveDO上游演示的请假单
bpm_process_definition_infoBpmProcessDefinitionInfoDO定义级扩展信息
bpm_process_expressionBpmProcessExpressionDO流程表达式
bpm_process_instance_copyBpmProcessInstanceCopyDO抄送
bpm_process_listenerBpmProcessListenerDO监听器配置
bpm_task_candidate_snapshotBpmTaskCandidateSnapshotDO候选人快照
bpm_user_groupBpmUserGroupDO用户组
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 逐字:

java
NORMAL(10, "流程表单"),
CUSTOM(20, "业务表单"),
ONLINE(30, "在线表单");

对应 BpmFormDataHandler 的三个实现,由 BpmFormDataHandlerFactory 按 formType 分派:

formType实现类数据落在哪归属
NORMAL 10BpmNormalFormDataHandlerbpm_form 定义的表单,数据进 Flowable 变量上游
CUSTOM 20BpmCustomFormDataHandler业务方自己的表(如 bpm_oa_leave),业务方自己写 Service上游
ONLINE 30BpmOnlineFormDataHandlerinfra 表单设计绑定的物理表本项目自研

第三种是低代码平台与工作流的接缝,也是最容易踩坑的一处。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。两个常量名是固定的:

java
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 逐字:

java
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 ​

java
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) 的实现只有一行:

java
return !DYNAMIC.equals(subjectType);

背后的语义(类注释里的原话,讲的是离职交接):

张三因『研发部负责人』被选中 → 换了负责人就自动有解,不需要人工介入 张三因『被点名』被选中 → 没有任何规则能推出接手人,必须人工指定

也就是说 USER / ROLE / DEPT / POST / GROUP 这五类都能重算(换个人、换个部门负责人, 系统能自己算出新的候选人),只有 DYNAMIC 不能——它本来就是「运行时临时指定的那一个人」, 没有规则可以推出接手人。

⚠️ 类注释还专门写了一句:「主体类型不等于『授权主体』」。 BpmCandidateSubjectTypes 描述的是「这个候选人是怎么算出来的」, 而 权限体系里的「主体」描述的是「谁被授予了什么」。 两者都叫 subject,但不是一个概念,别混用。

事件是同步的:这一条决定了下游能不能安全接 ​

BpmProcessInstanceEventPublisher:22 的全部实现:

java
public void sendProcessInstanceResultEvent(@Valid BpmProcessInstanceStatusEvent event) {
    publisher.publishEvent(event);
}

publishEvent 是 Spring 的同步派发。bpm 模块实测:

检查项结果
@TransactionalEventListener0 处
@Order(0 处
@Async 在事件链上无

🔴 审批人点「通过」的那一瞬间,所有监听器都跑在他的线程和事务里

后果有三条,每一条都能咬人:

  1. 租户上下文就是审批人的租户。TenantContextHolder 此时是审批人的值。 本平台 BPM 不跨租户审批,所以「审批人租户 == 流程发起人租户」通常成立, 但这是依赖业务约定,不是框架保证。
  2. 监听器抛异常会连累审批事务回滚。见下面基类守卫那一节。
  3. 监听器里做慢操作 = 审批接口变慢。没有异步兜底。

agent 侧的 AgentBpmApprovalListener 的类注释专门论证了第 1 条,并明确写了 「全程不用 executeIgnore(agent 架构红线)」——它直接以行身份(执行所有者)恢复工作流。

基类里的 businessKey 守卫 ​

BpmProcessInstanceStatusEventListener#onApplicationEvent 是 final 的, 它先按 processDefinitionKey 过滤(一个业务配一个监听器),再执行这段守卫:

java
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 的状态枚举,而是本地镜像了三个常量:

java
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:

sql
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,逐字:

java
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 特有的问题 —— 任何人在流程设计器里新建流程都部署不了。

解法是分两条判据:

java
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 是这个形状:

sql
INSERT IGNORE INTO bpm_instance_participant_archive (%s) SELECT %s
  FROM bpm_instance_participant WHERE id IN (%s)

然后 DELETE。待搬迁的行由一条批量查询挑出来:

sql
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_* 自有表1211 个 DO + bpm_instance_participant_archive(无 DO)
ACT_* Flowable 引擎表39流程定义 / 运行时 / 历史 / 身份。不要手改
FLW_* Flowable 扩展表6事件注册、通道等
错误码65ErrorCodeConstants
权限按钮(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 域开发 · 数据模型。

接口清单 ​

维度实测值
Controller15(全部在 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 域开发 · 接口清单。

怎么扩展 ​

加一个候选人策略 ​

  1. 往 BpmTaskCandidateStrategyEnum 加枚举值。
  2. 编译器会立刻报错——BpmCandidateSubjectTypes.of() 是穷尽 switch 无 default, 新值没有分支就编译不过。这是设计好的强制项,去那里补上主体类型。
  3. 决定它 recalculable 与否:能从规则推出接手人的(按角色、按部门负责人)归五类之一; 纯运行时点名的归 DYNAMIC(不可重算,离职必须人工交接)。
  4. 写实现类,放到 framework/flowable/core/candidate/ 下。
  5. 如果是「部门负责人」类的变体,继承 AbstractBpmTaskCandidateDeptLeaderStrategy ——它已经是三个策略共用的基类。

加一种 formType ​

  1. BpmModelFormTypeEnum 加值(现有 10 / 20 / 30)。
  2. 实现 BpmFormDataHandler,注册进 BpmFormDataHandlerFactory。
  3. 如果数据落在 infra 的物理表上,必须复刻 BpmOnlineFormDataHandler 的三条纪律:
    • 读写全部委托 FormDesignApi,不自己拼 SQL(字段映射、主键策略、逻辑删除、系统字段填充都在那边)
    • update 先读整行、合并、再写(全量覆盖语义)
    • 系统列走 updateSystemColumns,不走 fieldMapping
  4. 依赖 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

  1. 🔴 requiredApproveType 不匹配 → buildBpmnUserTask 提前 return ⟹ 候选人、审批方式、字段权限、按钮、超时、监听器全部被丢弃,部署照样成功。 只有真正跑流程时才发现「这个节点谁都审批不了」。
  2. 🔴 在线表单 update 是全量覆盖 ⟹ 节点表单只提交部分字段时,其它列被写成 null。
  3. 🔴 formDesignCode 不冻结快照 ⟹ 改了表单设计后,老流程的状态更新会写到新表去, 老表那行永远停在旧值。
  4. 🔴 BpmSubProcessCallChecker 当前只拦显式 DENY ⟹ 没配就是允许。 这是有意的存量回落,收紧计划写在源码注释里(S9 / TODO #30)。
  5. PermissionCommonApi 不可用时 canCall 返回 true,且 log.error 靠 AtomicBoolean只打一次 ⟹ 启动日志被冲走之后就再也看不到。
  6. 归档表 bpm_instance_participant_archive 没有 DO ⟹ 绕过租户拦截器(故意的), 但这个写法抄到业务表上就会丢租户过滤。
  7. 本地 profile 排除 QuartzAutoConfiguration ⟹ 参与者归档任务在本地永不自动执行。
  8. pom <description> 写「Flowable 6」,实际是 8.0.0 ⟹ 照描述查文档会查到错的 API。
  9. .generated/ 是快照,不是权威 —— bpm 域曾量到清单记 14/79 而源码是 15/82(多行注解被脚本丢掉),本轮已修并重跑。
  10. demo/exection/ 目录名是上游拼写错误 ⟹ 「修正」它会改掉存量监听器配置的类名字符串。

fail-closed

  1. BpmProcessInstanceStatusEventListener 的 businessKey 数字守卫 → 非数字键不派发(log.debug + return)。 这挡掉的是 NumberFormatException 中止整条监听链 + 回滚审批事务。
  2. validateModelPermission → 既无 MANAGE 又无 DESIGN(或 DESIGN 但从未部署)即抛 MODEL_UPDATE_FAIL_NOT_MANAGER。
  3. validateScopeWritable → 只持 DESIGN 的人动了 startUserIds / startDeptIds / managerUserIds 即抛 MODEL_UPDATE_FAIL_NOT_MANAGER_FOR_SCOPE。这里刻意选择报错而不是静默保留旧值。
  4. BpmProcessConstraintsRespDTO 的硬拦 → agent 生成流程配置时违规直接拒, 而不是给个警告让部署去静默丢配置。
  5. BpmCandidateSubjectTypes.of() 穷尽 switch 无 default → 加枚举值编译失败。
  6. restore() 的 @DataPermission(enable = false) 被 processInstanceId 限定死, 入口本身有权限校验(这是正当关闭行权限的样板,不是漏洞)。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

测试 ​

维度实测值
测试文件36
@Test 方法194
@Disabled5
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(我读到的是没有),结论就不成立。

相关页面 ​