Flowable 状态通知设计:单体用本地事件,微服务如何回写业务状态?
审批人点了通过,流程图已经走到结束,业务单据却还显示"审批中"。排查这类问题,不能只看Flowable有没有结束,还得沿着另一条链继续往下找:流程结果由谁解释,通知送到哪个服务,最后由谁更新业务数据?
在 RuoYi Office 中,用印、资产维修等单据共用流程能力,各自保留业务规则。单体部署时,流程和业务可能运行在同一个Spring应用里;拆成微服务后,原来的Java对象调用就遇到了进程边界。我们用一套状态消息连接两端,再把传输方式交给通知策略选择。
这篇重点讲审批之后的接力:从引擎事件,到状态通知,再到业务状态和后续处理。

▲ 三条线路表达可选择的通知通道,同一流程按配置选一种。当前Feign发送实现面向OA;不同通道的接收能力也需要分别核对。
从页面看:审批通过与业务完成,各有一条状态线
先看用印申请。页面保留了企业管理平台的顶栏、业务菜单、页签和单据详情,状态显示"审批中"。对申请人而言,关心的是这张业务单;对流程引擎而言,管理的是关联的流程实例和审批任务。两者需要有明确的关联和同步规则。

▲ 历史演示样本OA103-2026072200002。重点看右上角"审批中"和单据信息、审批信息、流程图的分工。整屏图片在手机上可点开放大。
流程结束以后,业务接收结果也不应直接把所有事情都标记为"完成"。用印服务收到通过状态,会更新流程状态,并将用印状态设为待处理。审批解决了授权问题,后续用印仍有自己的动作。
资产维修把这层区别表现得更明显:下面这张维修单已经审批通过,底部仍有"办结维修"。这时审批结果应推动维修进入执行阶段,不能提前写入完成日期或假定资产已经恢复可用。

▲ 演示样本AM705-2026080200001。绿色审批结果与"办结维修"同时存在,说明流程授权与业务执行是两个阶段。
维修服务的通过钩子会将维修状态更新为维修中,更新资产状态并记录履历;后续办结方法再检查审批结果和维修阶段,保存维修结果与完成日期,并按原态规则恢复资产状态。

▲ 演示样本WX202607274710已有完成日期和维修结果。它与上一张不是同一单据,两个既有样本分别说明执行中和办结后的页面状态,不代表本轮现场跑完审批。
这些页面给通知层提出了三个要求:结果必须回到正确的单据;审批状态与业务执行状态分开;通用通知不能替业务模块决定如何处理资产、印章或其他业务资源。
先把"发生了什么"说清楚,再选择怎样送达
平台没有直接把Flowable原始事件名称当成业务最终结论。引擎监听器监听创建、完成和取消事件,再交给流程实例服务处理。
以完成事件为例,服务先读取实例变量中的流程状态。只有状态仍为运行中时,才把它转为通过;已经是拒绝等结果时,需要保留原结果。删除也可能触发引擎取消事件,监听器会识别删除原因,避免把删除正在运行的流程误判成审批通过。
之后,流程实例服务调用通知管理器,把流程状态转换为平台事件,并组装统一消息。这里尤其容易忽略两件事。
第一,任务通过不等于整张单据通过。 多节点审批中,一个任务完成,下一节点仍可能继续。任务消息包含taskId、处理人和任务结果;实例消息表达整条流程的状态。通用接收端先区分事件类别,不能在每个TASK_APPROVED到来时都执行整单生效。
第二,事件名称不能替代状态值。 当前转换中,拒绝和取消都会映射到PROCESS_INSTANCE_CANCELLED,但消息内status仍分别保留拒绝和取消。业务生命周期钩子依据status选择拒绝或取消处理。只按事件名分支,就会丢掉这个业务差别。
重新进入发起节点也不意味着可以无条件把单据改回未开始。本地监听器中,这类任务创建通知只记录信息,状态由实例级事件驱动,避免异步任务通知覆盖拒绝、撤回等状态。
一份消息,连接流程身份与业务身份

▲ 图为逻辑关联,不表示三者存放在同一数据库,也不表示已经建立物理外键。消息通过流程定义Key定位服务,通过businessKey定位业务记录。
消息最重要的部分是processInstanceInfo,其中携带流程实例ID、流程定义Key、businessKey、status和tenantId。外层还有eventType、eventTime,以及任务事件才需要的taskInfo。读取消息时应以这个嵌套结构为准;类中的便捷getter只是从内部信息取值。
| 字段 | 在回写中承担的职责 |
|---|---|
| processInstanceId | 串联流程详情、通知日志与排查记录 |
| processDefinitionKey | 先判断模块,再找到具体单据服务 |
| businessKey | 对应业务记录身份,不是页面展示单号 |
| status | 决定审批状态与生命周期分支 |
| tenantId | 恢复处理业务数据时的租户上下文 |
以用印为例,页面单号方便人查找,业务服务实际把businessKey解析为记录ID。流程Key决定该调用用印服务还是其他单据服务,不能只凭一个数字ID在所有模块里尝试更新。
通知消息也不宜携带整张业务表单。资产状态、用印规则等仍由业务模块读取和判断。这样消息承担的是"某个流程发生了什么",业务服务承担的是"这件事对本单据意味着什么"。流程定义版本ID可以帮助追查具体实例使用的版本,但业务服务路由依赖定义Key,两者用途不同。
单体与微服务,变化的是通道和失败边界
我们把接入时的主要决策收在四处:
| 决策点 | 当前做法与原因 |
|---|---|
| 哪个流程走哪条线路 | 流程专属配置优先,再用默认类型,便于分批迁移 |
| 谁解释业务结果 | 接收端按模块过滤,再由单据服务工厂定位,避免通知器堆业务分支 |
| 何时执行后续动作 | 先回写流程状态,再调用对应生命周期钩子,保留业务规则入口 |
| 如何处理线程切换 | 单独控制是否异步,并传递租户上下文,避免把异步等同跨服务 |
通道选择是单选,处理器启用是另一道条件
通知管理器的选择逻辑如下,摘录保留了真实配置优先级:
java
private BpmNotificationTypeEnum getNotificationType(String processDefinitionKey) {
// 流程专属配置优先
if (processNotificationConfig != null) {
String configType = processNotificationConfig.get(processDefinitionKey);
if (StrUtil.isNotBlank(configType)) {
return BpmNotificationTypeEnum.getByCode(configType);
}
}
// 没有专属配置时,再使用默认值
if (StrUtil.isNotBlank(defaultNotificationType)) {
return BpmNotificationTypeEnum.getByCode(defaultNotificationType);
}
return null;
}
default-type缺省为local。选定类型后,管理器从已注册处理器中取一个执行,不会自动把同一事件广播到三条线路,也不会在Feign失败后自动切换MQ。Feign和MQ处理器还分别受enabled配置控制;选择了类型,却没有可用处理器时,管理器记录警告并返回。
还有一个配置细节:枚举getByCode对无法识别的编码会返回local。配置拼错可能表现为本地事件被发布,而不是启动失败。跨服务迁移时,应该同时确认配置值、处理器注册情况和接收端部署位置,不能仅看到配置文件里有一个mq就认为迁移完成。
本地事件适合同一个Spring应用,异步开关只改变执行线程
本地处理器将统一消息转换成状态事件,再通过ApplicationEventPublisher发布。业务模块监听这个事件并更新自己的单据,不需要网络序列化,链路比较短。
但本地事件不能跨JVM。拆出独立业务服务后,原Spring应用中的监听器不能替远端服务接收事件。即便把通知改成异步,也只是换一个线程执行。
下面是管理器sendNotification中的异步分支摘录。它抓取当前租户,在工作线程中恢复,并在结束后清理:
java
if (Boolean.TRUE.equals(asyncProcess)) {
Long tenantId = TenantContextHolder.getTenantId();
CompletableFuture.runAsync(() -> {
try {
if (tenantId != null) {
TenantContextHolder.setTenantId(tenantId);
}
handler.handleNotification(message);
} catch (Exception e) {
log.error("[sendNotification] 异步通知处理失败", e);
} finally {
TenantContextHolder.clear();
}
});
}
async缺省为true。这段实现没有把原事务迁移到新线程,也没有表示"原事务提交成功以后再发送"。若业务回写必须以原事务提交为前提,需要另外设计提交后发布或事务消息方案。将async设为false也不意味着获得统一回滚保障:处理器和管理器的异常处理仍需一起看。
Feign可以跨服务调用,但先核对实际接收入口
当前Feign发送处理器只接受oa_开头的流程,通过OaFeignNotificationApi调用OA。它不是面向所有业务模块的自动路由器。
更关键的是,当前发送端调用processStatusChange,对应接收端的兼容状态入口。该入口更新单据流程状态;同一Controller还提供支持事件分类的新入口,但不能因为新入口存在,就认为发送端已经使用了它。
这会影响生命周期语义:本地和MQ的实例处理路径会在状态回写后调用生命周期钩子,当前Feign兼容入口没有这一步。用印把待处理状态写在updateProcessStatus里,可以沿兼容路径执行;把业务动作放在onProcessApproved的其他模块,不能直接照搬"改成Feign即可"的接入方式。
选择通道时,既要比较调用成本,也要比较接收端执行了哪些步骤。否则传输成功、接口返回成功,仍可能没有完成业务期望的后续动作。
回到业务模块:先过滤,再回写,再执行钩子

▲ 主链展示MQ接力。异步提交任务、写入Stream、业务回写和ACK是不同的完成点;图下方注明另外两条线路的差异。
本地与MQ接收器都先用模块前缀过滤,再通过完整流程Key查单据服务。前缀只回答"属于哪个模块",精确Key才回答"调用哪一种单据"。共同接口提供updateProcessStatus,以及通过、拒绝、取消等可选钩子。
以用印服务为例,回写规则包含了明确业务含义。下面省略日志,保留校验和状态更新:
java
@Override
public void updateProcessStatus(String businessKey, Integer status) {
Long id = Long.parseLong(businessKey);
validateSealApplyBillExists(id);
SealApplyBillDO updateObj = new SealApplyBillDO();
updateObj.setId(id);
updateObj.setProcessStatus(status);
// 授权通过后,进入待用印处理阶段
if (APPROVE.getStatus().equals(status)) {
updateObj.setUseStatus(SealUseStatusEnum.PENDING.getStatus());
}
sealApplyBillMapper.updateById(updateObj);
}
因此,新增业务时不必在通用MQ消费者中增加"如果是维修就改资产,如果是用印就改印章"的分支。业务服务自己实现规则,公共接收端负责分派。资产维修把改资产与记履历放在通过钩子中,就是另一种合理的落点。
不过,先回写再调用钩子是调用顺序,不能直接推出两步处于一个原子事务。当前钩子异常会被公共调用层捕获记录,可能出现审批状态已更新、业务动作失败的部分完成,需要专门处理这种状态。
租户也要沿链路恢复:发送端异步线程显式传递上下文;MQ通过消息拦截器处理,并在缺少上下文时尝试从消息体tenantId恢复。本地接收端把业务操作放进租户执行工具中。当前本地实现对缺失或无效租户存在忽略租户执行的分支,因此"消息带了tenantId"不等于租户隔离已完整验收;跨服务部署仍须核对入口鉴权与实际租户恢复行为。
Redis Stream解决了哪些问题,又留下哪些边界
这里的MQ通道使用Redis Stream,发送器调用RedisMQTemplate,把统一消息写入bpm.workflow.instance.status.changed。不能把工程中存在其他消息中间件依赖,等同于这条流程状态链实际使用了它们。
消费基类在业务处理正常返回后ACK,处理抛出异常则保留pending。下面摘自onMessage的处理与确认部分,省略的是后续拦截器清理:
java
T messageObj = null;
boolean consumed = false;
try {
messageObj = JsonUtils.parseObject(message.getValue(), messageType);
consumeMessageBefore(messageObj);
this.onMessage(messageObj);
consumed = true;
} catch (Throwable ex) {
log.error("[onMessage][stream({}) group({}) recordId({}) 消费失败,将由 pending 任务重试]",
streamKey, group, message.getId(), ex);
}
if (consumed) {
try {
redisMQTemplate.getRedisTemplate().opsForStream().acknowledge(group, message);
} catch (Throwable ackEx) {
log.error("[onMessage][stream({}) group({}) recordId({}) ack 失败]",
streamKey, group, message.getId(), ackEx);
}
}
公共pending任务按分钟扫描,对距离上次投递达到五分钟的记录重新加入Stream,再确认旧记录。这是针对已入队、未确认消费消息的恢复路径,实际执行还依赖任务启用、Redis可用与消费组配置。它不覆盖发送前就失败的通知,也不保证每次异常都会进入重试。
特别是生命周期钩子的异常在内部被捕获,外层消费者可能仍正常返回并ACK。非法参数、非本模块消息等主动返回路径同样可能正常确认。因此,排查"状态回写了但业务没执行",不能只看pending里是否有消息,还要看具体钩子日志与业务状态。
默认消费组取spring.application.name。多个业务服务要各自接收并过滤事件时,应核对组名是否按应用隔开;如果不同应用误用同组,消息可能被其他模块消费者拿走并过滤,而不是每个模块都收到一份。单体聚合中有多个模块监听器时,也应单独核对同组订阅行为,不能仅凭监听器都注册了就假定广播成立。
把通知接通之后,可靠性要落到业务结果上
如果下一步要把这条链用于更严格的业务生效场景,我们会优先补三个层面的设计。这些是扩展建议,不是上述实现已经提供的保证。
首先,解决发送与事务提交之间的缝隙。可以在业务事务里落一条待发送记录,提交后投递,由独立任务扫描失败项。记录需要有明确的处理状态与重试依据,不能只依靠进程中的异步任务。
其次,让重复与乱序都有规则。写同一个审批状态可能看起来可重复,钩子中的履历、资源变更或下游单据创建却未必如此。应围绕业务动作设置唯一约束或处理记录,同时用实例关联、状态版本等条件拒绝旧结果覆盖新状态。eventTime便于排查,并不自动构成可靠的全局顺序。
最后,把"送达"和"生效"分开观察。至少串起租户、流程Key、实例ID、businessKey和事件类型;把状态更新失败、钩子失败、发送失败分开记录。需要补偿时按业务事实重建动作,避免为了让日志变绿而盲目重放删除、扣减或生成操作。
从单体走向微服务,保留统一消息与业务服务契约,可以减少每种单据各写一套通知逻辑的成本。真正决定接入质量的,仍是那几个具体判断:任务与实例是否分清,哪个入口执行了钩子,租户是否恢复,失败有没有进入可恢复的路径。审批结果送到正确的单据,并推动正确的下一步,这条通知链才算对业务有用。
