!893 发布 6.1.0 正式版 warm-flow 大升级

Merge pull request !893 from 疯狂的狮子Li/dev
This commit is contained in:
疯狂的狮子Li 2026-11-04 08:43:41 +00:00 committed by Gitee
commit 835246ebeb
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
121 changed files with 5825 additions and 2339 deletions

View File

@ -11,6 +11,7 @@ description: 公共基础模块专家。用于修改 ruoyi-common 下的 mybatis
2. 优先保持公开方法签名、泛型、返回值、异常语义兼容。
3. 新增能力要贴合已有命名和链式调用风格,不自造平行体系。
4. 只补注释时不改实现、不重排 import、不运行无关格式化。
5. 新增工具类或通用辅助方法前,先读取 `.claude/agents/backend-engineering.md` 的“已有能力复用”规则,按能力类别检索、确认模块依赖可用性并核对业务语义;满足需求时直接复用,不添加无业务语义的包装。
## common-mybatis
@ -38,5 +39,5 @@ description: 公共基础模块专家。用于修改 ruoyi-common 下的 mybatis
- 是否破坏已有调用点。
- 是否遗漏 `instance()` / `clear()` / 批量翻译 / 缓存失效等公共模块关键路径。
- 是否新增了与现有工具重复的临时类或临时方法。
- 是否已检索同模块、`ruoyi-common` 和已有依赖,避免重复能力;确需新增时,是否在交付说明中简述现有能力为何不适用(无需为此额外添加代码注释,既有 JavaDoc 规则仍适用)。
- JavaDoc 是否简洁说明公共 API 的参数、返回值和兼容语义。

View File

@ -11,6 +11,7 @@ description: 标准后端 CRUD 专家。用于当前项目中的新增单表 CRU
2. 再参考当前模块内最近似的标准管理模块。
3. 分层保持稳定:
`domain`、`domain.bo`、`domain.vo`、`mapper`、`service`、`service.impl`、`controller`
4. 新增工具类或通用辅助方法前,先读取 `.claude/agents/backend-engineering.md` 的“已有能力复用”规则,按能力类别检索、确认模块依赖可用性并核对业务语义;满足需求时直接复用,不添加无业务语义的包装。
## 结构约定
@ -57,6 +58,7 @@ description: 标准后端 CRUD 专家。用于当前项目中的新增单表 CRU
## 自检
- 新增工具类或通用辅助方法是否已完成同类能力检索与语义核对;确需新增时,在交付说明中简述现有能力为何不适用,无需为此额外添加代码注释,既有 JavaDoc 规则仍适用
- CRUD 链路是否完整
- BO / VO / Entity 是否职责分离
- 导出、分页、删除前校验是否齐全

View File

@ -14,19 +14,32 @@ description: 后端工程总入口。用于在当前 RuoYi-Vue-Plus 项目中识
5. 如果只要求补充或修正 JavaDoc 注释,优先使用 `backend-javadoc.md` 的规则。
6. 如果同时要求同步前端接口或前端页面骨架,先确认目标前端是 Vue 还是 React,保持后端路由与 generator 风格稳定,便于前端 agent 对接。
文档读取顺序:
文档读取规则:
- 后端 Java、Mapper、Service、Controller、BO、VO、Entity、权限、查询、公共模块或 JavaDoc 任务,先读 `.codex/skills/ruoyi-plus-ai-coding/references/backend.md`。
- 同步前端 Vue、React、TypeScript、api、types 或页面骨架时,再读 `.codex/skills/ruoyi-plus-ai-coding/references/frontend.md`。
- 任务边界不清晰或需要标准场景示例时,再读 `.codex/skills/ruoyi-plus-ai-coding/references/examples.md`。
- 只读取当前任务相关的 reference,不一次性展开全部文档。
- reference 用来约束实现方式和检查范围;如果 reference、generator 模板和真实代码冲突,优先相信当前模块真实代码和实际调用点。
- 按任务类型读取上面对应的 `.claude/agents` 文档,只读取当前任务相关内容。
- 同步前端时,直接阅读目标 Vue 或 React 工程的现有 API、types、页面及对应 generator 模板,确认路由、返回结构和日期范围参数。
- 需要场景示例时,优先阅读当前模块最近似实现及实际调用点。
- 本套规则独立维护,不依赖 `.codex` 下的技能或 reference;规则、generator 模板和真实代码冲突时,优先相信当前模块真实代码和实际调用点。
通用要求:
- 先读同模块最近似实现,再动代码。
- 新增工具类或通用辅助方法前,必须执行下方“已有能力复用”流程。
- 发生冲突时优先相信当前模块真实代码,其次是公共基础设施,再其次才是 generator 模板。
- 默认直接产出可落地代码,而不是只给抽象建议。
- 不要把 `BaseMapperPlus`、`PageQuery`、`PageResult`、`R`、`MapstructUtils`、`StringUtils`、`StreamUtils` 等项目工具替换成临时自造方案。
- import、注解顺序、文件结构以附近代码为准,不做无关重排。
- 修改公开 API 前先查调用点;公共模块优先保持方法签名、泛型、返回值和异常语义兼容。
## 已有能力复用
1. 按能力类别检索同模块、`ruoyi-common` 和已有依赖,例如类型转换、字符串处理、集合处理、日期计算,不只搜索拟新增的方法名。
2. 优先阅读实际调用示例,再核对候选实现或依赖 API;结合目标模块的 `pom.xml`、传递依赖及版本确认可用性,不能仅凭其他模块有调用或根 POM 管理了版本就认定可用。
3. 核对空值、非法输入、默认值、异常和数值精度(含溢出、截断),依赖行为以当前版本的实现或测试为准。满足业务语义时直接复用,不添加无业务语义的包装;不满足时保留必要的业务差异,不能为了复用改变业务行为。
例如容错 ID 转换可先查已有调用中的 Hutool `Convert.toLong`,确认所用重载的失败返回值和默认值符合需求;必须抛错的严格解析不能改成返回 `null` 或默认值,还要核对小数、越界等输入的处理。这里只示范核对方式,不维护全量工具 API 清单。
交付自检:
- 新增工具类或通用辅助方法是否已检索并核对同类能力,避免重复实现。
- 确需新增时,在交付说明中简述现有能力为何不适用,无需为此额外添加代码注释;既有 JavaDoc 规则仍适用。

View File

@ -12,6 +12,7 @@ description: 复杂后端模块增强专家。用于修改当前项目中已经
3. 保留已有的数据权限、事务、缓存、导入导出、唯一性校验、删除前校验。
4. 不能为了“简洁”把复杂模块退化成模板式单表 CRUD。
5. 修改 workflow 模块时检查同包是否需要 `@ConditionalOnEnable`。
6. 新增工具类或通用辅助方法前,先读取 `.claude/agents/backend-engineering.md` 的“已有能力复用”规则,按能力类别检索、确认模块依赖可用性并核对业务语义;满足需求时直接复用,不添加无业务语义的包装。
## 常见任务
@ -33,6 +34,7 @@ description: 复杂后端模块增强专家。用于修改当前项目中已经
## 自检
- 新增工具类或通用辅助方法是否已完成同类能力检索与语义核对;确需新增时,在交付说明中简述现有能力为何不适用,无需为此额外添加代码注释,既有 JavaDoc 规则仍适用
- 是否破坏了原模块的权限边界
- 是否误删了旧逻辑中的事务或校验
- 是否错误简化了复杂关系维护

View File

@ -12,6 +12,7 @@ description: 后端查询、联表与数据权限专家。用于当前项目中
3. 复杂联表优先参考 MPJ 风格,不轻易改回手写零散 SQL。
4. 如果 `BaseMapperPlus + wrapper` 足够,不要额外补 XML。
5. wrapper 查询条件优先使用项目已有工具和命名,不自造查询 DSL。
6. 新增工具类或通用辅助方法前,先读取 `.claude/agents/backend-engineering.md` 的“已有能力复用”规则,按能力类别检索、确认模块依赖可用性并核对业务语义;满足需求时直接复用,不添加无业务语义的包装。
## 重点关注
@ -38,6 +39,7 @@ description: 后端查询、联表与数据权限专家。用于当前项目中
## 输出要求
- 自检新增工具类或通用辅助方法是否已完成同类能力检索与语义核对;确需新增时,在交付说明中简述现有能力为何不适用,无需为此额外添加代码注释,既有 JavaDoc 规则仍适用
- 明确说明查询是单表、联表还是带权限控制
- 保持与当前模块 mapper 风格一致
- 不要让查询参数风格和前端现有调用脱节

View File

@ -31,7 +31,7 @@ description: 在仓库内按代码生成器模板、项目 reference 文档和
## 执行流程
1. 先判断任务类型,并按“文档读取规则”读取当前任务需要的 reference。
2. 确认目标模块,优先复用同模块中最近似功能的写法。
2. 确认目标模块,优先复用同模块中最近似功能的写法;新增工具类或通用辅助方法前,执行“已有能力检索”。
3. 新增标准 CRUD 代码前,先读取 `ruoyi-modules/ruoyi-gen/src/main/resources/fm/` 下的 FreeMarker 模板。
4. 命名和分层保持与仓库一致:
`domain` entity、`domain.bo`、`domain.vo`、`mapper`、`service`、`service.impl`、`controller`。
@ -40,6 +40,12 @@ description: 在仓库内按代码生成器模板、项目 reference 文档和
7. 修改 `ruoyi-common` 公共模块前,先阅读同包接口、实现类和调用点,优先保持已有 API 语义与兼容性。
8. 只补注释或文档时,不运行无关格式化,不重排 import,不改代码逻辑。
### 已有能力检索
1. 按能力类别搜索同模块、`ruoyi-common` 和已有依赖,例如类型转换、字符串处理、集合处理、日期计算;不要只搜拟新增的方法名。
2. 优先阅读实际调用示例,再核对候选实现或依赖 API;结合目标模块的 `pom.xml`、传递依赖及版本确认可用性,不能仅凭仓库其他模块有调用或根 POM 管理了版本就认定可用。
3. 按适用 reference 核对业务语义;满足需求时直接复用,不添加无业务语义的包装。不满足时保留必要的业务差异,再决定是否新增。
## 文档读取规则
使用本 skill 时,先按任务类型读取适用 reference,不一次性展开所有文档:
@ -148,9 +154,11 @@ Vue 3、React、TypeScript API 文件、生成式列表页、表单状态、字
- 查询、分页、删除校验、写入校验逻辑闭环完整。
- 权限、日志、防重、事务、数据权限尽量贴近同模块现有实现。
- 如果同步改前端,前端 API 路径和后端接口保持一致。
- 确需新增工具类或通用辅助方法时,在交付说明中简述检索过的现有能力为何不适用,无需为此额外添加代码注释;既有 JavaDoc 规则仍适用。
## 快速检查清单
- 新增工具类或通用辅助方法是否已完成同类能力检索与语义核对,避免重复实现或无业务语义的包装。
- 包路径和 `@RequestMapping` 与模块保持一致。
- 权限标识遵循 `${module}:${business}:${action}`。
- Mapper 继承 `BaseMapperPlus<Entity, Vo>`。

View File

@ -166,6 +166,8 @@
- 分页统一使用 `PageQuery` 和 `PageResult`,不要无故引入新的分页 DTO。
- 优先使用项目工具类:`MapstructUtils`、`StringUtils`、`StreamUtils`、`ValidatorUtils`、`SpringUtils`、`RedisUtils`。
- 复用前核对空值、非法输入、默认值、异常和数值精度(含溢出、截断)的行为,不能为了复用改变业务语义;依赖行为以当前版本的实现或测试为准。
- 例如容错 ID 转换可先查已有调用中的 Hutool `Convert.toLong`,核对所用重载的失败返回值与默认值是否符合需求;必须抛错的严格解析不能改成返回 `null` 或默认值,也要核对小数、越界等输入的处理。此处只示范核对方式,不维护全量工具 API 清单。
- 数组转列表按附近代码习惯使用 `List.of(ids)` 或 `Arrays.asList(ids)`。
- 日期范围查询通常从 `bo.getParams()` 中读取 `beginTime`、`endTime` 或 `beginFieldName`、`endFieldName`。
- 构建查询优先识别 `QueryBuilder.lambda(...)`、`QueryBuilder.lambdaJoin(...)`、`BaseMapperPlus#lambda()` 三类入口,不要退回临时手写 SQL 或自造 wrapper。

View File

@ -2,7 +2,7 @@
<configuration default="false" name="ruoyi-monitor-admin" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="ruoyi/ruoyi-monitor-admin:6.0.0" />
<option name="imageTag" value="ruoyi/ruoyi-monitor-admin:6.1.0" />
<option name="buildOnly" value="true" />
<option name="sourceFilePath" value="ruoyi-extend/ruoyi-monitor-admin/Dockerfile" />
</settings>

View File

@ -2,7 +2,7 @@
<configuration default="false" name="ruoyi-server" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="ruoyi/ruoyi-server:6.0.0" />
<option name="imageTag" value="ruoyi/ruoyi-server:6.1.0" />
<option name="buildOnly" value="true" />
<option name="sourceFilePath" value="ruoyi-admin/Dockerfile" />
</settings>

View File

@ -3,7 +3,7 @@
server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="ruoyi/ruoyi-snailai-server:6.0.0"/>
<option name="imageTag" value="ruoyi/ruoyi-snailai-server:6.1.0"/>
<option name="buildOnly" value="true"/>
<option name="sourceFilePath" value="ruoyi-extend/ruoyi-snailai-server/Dockerfile"/>
</settings>

View File

@ -2,7 +2,7 @@
<configuration default="false" name="ruoyi-snailjob-server" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="ruoyi/ruoyi-snailjob-server:6.0.0" />
<option name="imageTag" value="ruoyi/ruoyi-snailjob-server:6.1.0" />
<option name="buildOnly" value="true" />
<option name="sourceFilePath" value="ruoyi-extend/ruoyi-snailjob-server/Dockerfile" />
</settings>

6
AGENTS.md Normal file
View File

@ -0,0 +1,6 @@
# 项目指令
## 已有能力复用
- 新增工具类或通用辅助方法前,必须检索同模块、`ruoyi-common` 和已有依赖中的同类能力;满足业务语义且当前模块可用时直接复用,不添加无业务语义的包装。
- 检索流程、后端语义核对与交付自检见 `.codex/skills/ruoyi-plus-ai-coding/SKILL.md` 及其适用 reference。

View File

@ -10,7 +10,7 @@
[![Star](https://gitcode.com/dromara/RuoYi-Vue-Plus/star/badge.svg)](https://gitcode.com/dromara/RuoYi-Vue-Plus)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://gitee.com/dromara/RuoYi-Vue-Plus/blob/6.X/LICENSE)
<br>
[![RuoYi-Vue-Plus](https://img.shields.io/badge/RuoYi_Vue_Plus-6.0.0-success.svg)](https://gitee.com/dromara/RuoYi-Vue-Plus)
[![RuoYi-Vue-Plus](https://img.shields.io/badge/RuoYi_Vue_Plus-6.1.0-success.svg)](https://gitee.com/dromara/RuoYi-Vue-Plus)
[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.1-blue.svg)]()
[![JDK-21](https://img.shields.io/badge/JDK-21-green.svg)]()
[![JDK-25](https://img.shields.io/badge/JDK-25-green.svg)]()

28
pom.xml
View File

@ -14,23 +14,23 @@
<properties>
<!-- 项目基础配置 -->
<revision>6.0.0</revision>
<revision>6.1.0</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>21</java.version>
<!-- Spring 生态依赖版本 -->
<spring-boot.version>4.1.1</spring-boot.version>
<spring-boot-admin.version>4.1.2</spring-boot-admin.version>
<spring-ai.version>2.0.1</spring-ai.version>
<spring-boot.version>4.1.2</spring-boot.version>
<spring-boot-admin.version>4.1.4</spring-boot-admin.version>
<spring-ai.version>2.0.2</spring-ai.version>
<springdoc.version>3.1.1</springdoc.version>
<!-- 数据访问与持久化相关依赖版本 -->
<mybatis.version>3.5.19</mybatis.version>
<mybatis-plus.version>3.5.17</mybatis-plus.version>
<mybatis-plus.version>3.5.18</mybatis-plus.version>
<mybatis-plus-join.version>1.5.9</mybatis-plus-join.version>
<dynamic-ds.version>4.5.0</dynamic-ds.version>
<anyline.version>8.7.3-20260306</anyline.version>
<dynamic-ds.version>4.6.0</dynamic-ds.version>
<anyline.version>17.7.6-20261020</anyline.version>
<easy-es.version>3.0.2</easy-es.version>
<elasticsearch-client.version>7.17.28</elasticsearch-client.version>
@ -40,7 +40,7 @@
<bouncycastle.version>1.86</bouncycastle.version>
<!-- 缓存、锁与任务调度相关依赖版本 -->
<redisson.version>4.7.0</redisson.version>
<redisson.version>4.8.0</redisson.version>
<lock4j.version>2.2.7</lock4j.version>
<snailjob.version>2.0.2</snailjob.version>
<snailai.version>1.1.1</snailai.version>
@ -51,8 +51,8 @@
<mapstruct-plus.lombok.version>0.2.0</mapstruct-plus.lombok.version>
<lombok.version>1.18.48</lombok.version>
<therapi-javadoc.version>0.15.0</therapi-javadoc.version>
<fesod.version>2.0.2-incubating</fesod.version>
<fory.version>1.7.4</fory.version>
<fesod.version>2.1.0-incubating</fesod.version>
<fory.version>1.7.7</fory.version>
<!-- 三方集成与业务扩展相关依赖版本 -->
<!-- 离线IP地址定位库 -->
@ -62,8 +62,8 @@
<!-- SMS 配置 -->
<sms4j.version>3.3.5</sms4j.version>
<!-- 工作流配置 -->
<warm-flow.version>1.8.9</warm-flow.version>
<liteflow.version>2.16.2.1</liteflow.version>
<warm-flow.version>2.1.1</warm-flow.version>
<liteflow.version>2.16.3.1</liteflow.version>
<!-- mqtt客户端 -->
<mica-mqtt.version>2.6.12</mica-mqtt.version>
@ -357,13 +357,13 @@
<!-- Warm-Flow国产工作流引擎, 在线文档:http://warm-flow.cn/ -->
<dependency>
<groupId>org.dromara.warm</groupId>
<groupId>io.github.sgs98</groupId>
<artifactId>warm-flow-mybatis-plus-sb4-starter</artifactId>
<version>${warm-flow.version}</version>
</dependency>
<!-- Warm-Flow UI 插件 -->
<dependency>
<groupId>org.dromara.warm</groupId>
<groupId>io.github.sgs98</groupId>
<artifactId>warm-flow-plugin-ui-sb-web</artifactId>
<version>${warm-flow.version}</version>
</dependency>

View File

@ -1,6 +1,6 @@
# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/
FROM bellsoft/liberica-openjdk-rocky:21.0.12-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.4-cds
FROM bellsoft/liberica-openjdk-rocky:21.0.13-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.5-cds
# FROM findepi/graalvm:java21-native
LABEL maintainer="Lion Li"

View File

@ -82,6 +82,8 @@ mybatis-plus:
enabled: true
# 输出方式,可选 console、log
output: console
# 忽略高频轮询表的成功 SQL 日志;执行异常仍会输出
ignore-tables: [flow_event_wait]
--- # 数据源配置
spring:

View File

@ -85,6 +85,8 @@ mybatis-plus:
enabled: false
# 输出方式,可选 console、log
output: console
# 忽略高频轮询表的成功 SQL 日志;执行异常仍会输出
ignore-tables: [ flow_event_wait ]
--- # 数据源配置
spring:

View File

@ -31,6 +31,8 @@ logging:
org.springframework: warn
org.mybatis.spring.mapper: error
org.apache.fury: warn
com.yomahub.liteflow: warn
org.anyline: warn
config: classpath:logback-plus.xml
# 用户配置
@ -239,19 +241,22 @@ warm-flow:
ui: true
# 是否显示流程图顶部文字
top-text-show: true
# 流程图状态颜色:未办理、待办理、已办理(RGB)
chart-status-color: ["107,114,128", "245,158,11", "64,158,255"]
# 是否渲染节点悬浮提示,默认true
node-tooltip: true
# 默认Authorization,如果有多个token,用逗号分隔
token-name: ${sa-token.token-name},clientid
# 是否开启定时/事件引擎线程(默认false)。开启后常驻扫描线程池,
# 已发布的「定时启动」流程定义会按配置自动发起实例
timer-enabled: true
--- # LiteFlow 业务规则编排
liteflow:
# 跟随工作流模块开关,避免禁用工作流时仍加载任务办理链
enable: ${warm-flow.enabled:true}
metrics:
# 临时修复:LiteFlow 2.16.2.1 的 Actuator 端点与 Spring Boot 4.1 参数名校验不兼容
# 待 LiteFlow 修复该问题后恢复为 ${liteflow.enable:true}
enabled: false
enabled: ${liteflow.enable:true}
# 工作流模块用于编排任务办理链路
rule-source: classpath:liteflow/*.el.xml
# 开启虚拟线程

View File

@ -117,6 +117,8 @@
<!-- </encoder>-->
<!-- </appender>-->
<logger name="org.springframework.context.support.PostProcessorRegistrationDelegate$BeanPostProcessorChecker" level="ERROR"/>
<!--系统操作日志-->
<root level="info">
<appender-ref ref="console" />

View File

@ -93,6 +93,14 @@ public interface UserService {
*/
List<UserDTO> selectUsersByDeptIds(Collection<Long> deptIds);
/**
* 通过部门ID查询用户ID
*
* @param deptIds 部门ids
* @return 用户ids
*/
List<Long> selectUserIdsByDeptIds(Collection<Long> deptIds);
/**
* 通过岗位ID查询用户
*
@ -101,6 +109,14 @@ public interface UserService {
*/
List<UserDTO> selectUsersByPostIds(Collection<Long> postIds);
/**
* 通过岗位ID查询用户ID
*
* @param postIds 岗位ids
* @return 用户ids
*/
List<Long> selectUserIdsByPostIds(Collection<Long> postIds);
/**
* 根据用户 ID 列表查询用户昵称映射关系
*

View File

@ -1,8 +1,10 @@
package org.dromara.system.api.model;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.core.constant.RegexConstants;
import org.dromara.common.core.domain.model.LoginBody;
/**
@ -18,6 +20,7 @@ public class SmsLoginBody extends LoginBody {
* 手机号
*/
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = RegexConstants.MOBILE, message = "手机号格式不正确")
private String phoneNumber;
/**

View File

@ -3,6 +3,9 @@ package org.dromara.workflow.api;
import org.dromara.workflow.api.domain.CompleteTaskDTO;
import org.dromara.workflow.api.domain.StartProcessDTO;
import org.dromara.workflow.api.domain.StartProcessReturnDTO;
import org.dromara.workflow.api.domain.StartMessageEventDTO;
import org.dromara.workflow.api.domain.StartTimerEventDTO;
import org.dromara.workflow.api.domain.TriggerMessageEventDTO;
import java.util.List;
import java.util.Map;
@ -96,4 +99,48 @@ public interface WorkflowService {
* @return 首节点办理成功返回 {@code true}
*/
boolean startCompleteTask(StartProcessDTO startProcess);
/**
* 外部消息按流程编码和事件名称启动新实例。
*
* @param request 消息启动参数,业务 ID 由调用方提供
* @return 新建流程实例 ID
*/
Long startByMessageEvent(StartMessageEventDTO request);
/**
* 触发实例中等待的消息捕获或消息边界事件。重复投递时引擎保持实例不变。
*
* @param request 消息事件参数
* @return 流程实例 ID
*/
Long triggerMessageEvent(TriggerMessageEventDTO request);
/**
* 手动发起定时启动事件,不改变等待记录及下一次自动触发时间。
*
* @param request 定时启动参数,业务 ID 留空时生成数字雪花 ID
* @return 新建流程实例 ID
*/
Long startByTimerEvent(StartTimerEventDTO request);
/**
* 从实例级错误捕获节点处理外部错误。
*
* @param instanceId 流程实例 ID
* @param errorCode 错误码,精确匹配优先于通配
* @param errorMessage 错误描述
* @return 错误事件已被处理时返回 true
*/
boolean throwInstanceErrorEvent(Long instanceId, String errorCode, String errorMessage);
/**
* 从当前待办的错误边界节点处理外部错误。
*
* @param taskId 当前待办任务 ID
* @param errorCode 错误码,精确匹配优先于通配
* @param errorMessage 错误描述
* @return 错误事件已被处理时返回 true
*/
boolean throwTaskErrorEvent(Long taskId, String errorCode, String errorMessage);
}

View File

@ -0,0 +1,42 @@
package org.dromara.workflow.api.domain;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Map;
/**
* 消息启动事件请求。
*/
@Data
public class StartMessageEventDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 业务主键,调用方必须提供。
*/
private String businessId;
/**
* 流程定义编码。
*/
private String flowCode;
/**
* 消息启动事件名称。
*/
private String eventName;
/**
* 合并到流程变量的消息数据,可为空。
*/
private Map<String, Object> payload;
/**
* 操作人,留空时使用系统处理人。
*/
private String handler;
}

View File

@ -0,0 +1,42 @@
package org.dromara.workflow.api.domain;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Map;
/**
* 手动发起定时启动事件请求。
*/
@Data
public class StartTimerEventDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 业务主键,留空时生成数字雪花 ID。
*/
private String businessId;
/**
* 已发布的流程定义 ID。
*/
private Long definitionId;
/**
* 定时启动事件节点编码。
*/
private String nodeCode;
/**
* 合并到流程变量的业务数据,可为空。
*/
private Map<String, Object> payload;
/**
* 操作人,留空时使用系统处理人。
*/
private String handler;
}

View File

@ -0,0 +1,37 @@
package org.dromara.workflow.api.domain;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Map;
/**
* 触发实例中等待的消息事件请求。
*/
@Data
public class TriggerMessageEventDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 已存在的流程实例 ID。
*/
private Long instanceId;
/**
* 等待中的消息捕获或消息边界事件名称。
*/
private String eventName;
/**
* 合并到流程变量的消息数据,可为空。
*/
private Map<String, Object> payload;
/**
* 操作人,留空时使用系统处理人。
*/
private String handler;
}

View File

@ -14,7 +14,7 @@
</description>
<properties>
<revision>6.0.0</revision>
<revision>6.1.0</revision>
</properties>
<dependencyManagement>

View File

@ -40,6 +40,12 @@
<artifactId>dynamic-datasource-spring-boot4-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-actuator</artifactId>
<scope>provided</scope>
</dependency>
<!-- MyBatis Plus 启动器 -->
<dependency>
<groupId>com.baomidou</groupId>
@ -58,6 +64,12 @@
<artifactId>mybatis-plus-join-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,88 @@
/*
* Copyright © 2018 organization baomidou
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.baomidou.dynamic.datasource.spring.boot.autoconfigure;
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
import com.baomidou.dynamic.datasource.ds.GroupDataSource;
import com.baomidou.dynamic.datasource.ds.ItemDataSource;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import javax.sql.DataSource;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 只读运维端点:暴露当前所有数据源(含运行期动态增删的)、分组构成与全局开关状态,
* 访问路径 {@code /actuator/dynamicDatasource}(需按 actuator 惯例显式暴露)。
* <p>
* 刻意不返回 URL / 用户名等连接串信息——URL 可能携带凭据,状态盘点不需要它们。
*/
@Endpoint(id = "dynamicDatasource")
public class DynamicDataSourceEndpoint {
private final ObjectProvider<DataSource> dataSources;
private final DynamicDataSourceProperties properties;
public DynamicDataSourceEndpoint(ObjectProvider<DataSource> dataSources, DynamicDataSourceProperties properties) {
this.dataSources = dataSources;
this.properties = properties;
}
@ReadOperation
public Map<String, Object> summary() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("primary", properties.getPrimary());
result.put("strict", properties.getStrict());
result.put("p6spy", properties.getP6spy());
result.put("seata", properties.getSeata());
DataSource dataSource = dataSources.getIfAvailable();
if (!(dataSource instanceof DynamicRoutingDataSource)) {
result.put("dataSources", Collections.emptyMap());
result.put("groups", Collections.emptyMap());
return result;
}
DynamicRoutingDataSource routingDataSource = (DynamicRoutingDataSource) dataSource;
Map<String, Object> dataSourceSummary = new LinkedHashMap<>();
for (Map.Entry<String, DataSource> entry : routingDataSource.getDataSources().entrySet()) {
dataSourceSummary.put(entry.getKey(), poolType(entry.getValue()));
}
result.put("dataSources", dataSourceSummary);
Map<String, Object> groupSummary = new LinkedHashMap<>();
for (Map.Entry<String, GroupDataSource> entry : routingDataSource.getGroupDataSources().entrySet()) {
Map<String, Object> group = new LinkedHashMap<>();
group.put("size", entry.getValue().size());
group.put("dataSources", new ArrayList<>(entry.getValue().getDataSourceMap().keySet()));
groupSummary.put(entry.getKey(), group);
}
result.put("groups", groupSummary);
return result;
}
private String poolType(DataSource dataSource) {
DataSource target = dataSource;
if (dataSource instanceof ItemDataSource) {
target = ((ItemDataSource) dataSource).getRealDataSource();
}
return target.getClass().getSimpleName();
}
}

View File

@ -3,6 +3,9 @@ package org.dromara.common.mybatis.config.properties;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
/**
* SQL 日志配置。
*
@ -22,4 +25,9 @@ public class SqlLogProperties {
*/
private String output = "console";
/**
* 不输出成功执行日志的表名;执行异常仍会输出。
*/
private List<String> ignoreTables = new ArrayList<>();
}

View File

@ -30,6 +30,7 @@ import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* 完整 SQL 日志拦截器。
@ -55,6 +56,8 @@ public class SqlLogInterceptor implements Interceptor {
*/
private static final String BLANK_REGEX = "\\s+";
private static final Pattern SQL_STRING_LITERAL = Pattern.compile("'(?:''|[^'])*'");
/**
* 日期时间格式。
*/
@ -75,8 +78,17 @@ public class SqlLogInterceptor implements Interceptor {
*/
private final SqlLogProperties sqlLogProperties;
private final List<Pattern> ignoredTablePatterns;
public SqlLogInterceptor(SqlLogProperties sqlLogProperties) {
this.sqlLogProperties = sqlLogProperties;
this.ignoredTablePatterns = sqlLogProperties.getIgnoreTables().stream()
.filter(StrUtil::isNotBlank)
.map(String::trim)
.map(table -> Pattern.compile("(?i)\\b(?:FROM|JOIN|UPDATE|INTO)\\s+"
+ "(?:[`\"\\[]?[A-Za-z_][A-Za-z0-9_]*[`\"\\]]?\\s*\\.\\s*)?"
+ "[`\"\\[]?" + Pattern.quote(table) + "[`\"\\]]?(?=\\s|[,);]|$)"))
.toList();
}
@Override
@ -104,11 +116,27 @@ public class SqlLogInterceptor implements Interceptor {
* @param throwable 执行异常
*/
private void printSql(MappedStatement mappedStatement, BoundSql boundSql, long elapsedTime, Throwable throwable) {
if (throwable == null && isIgnoredTableSql(boundSql.getSql())) {
return;
}
String fullSql = buildFullSql(mappedStatement.getConfiguration(), boundSql);
String message = buildLogMessage(mappedStatement, elapsedTime, fullSql, throwable);
printChunk(message);
}
boolean isIgnoredTableSql(String sql) {
if (ignoredTablePatterns.isEmpty()) {
return false;
}
String statement = sql.indexOf('\'') >= 0 ? SQL_STRING_LITERAL.matcher(sql).replaceAll("''") : sql;
for (Pattern pattern : ignoredTablePatterns) {
if (pattern.matcher(statement).find()) {
return true;
}
}
return false;
}
/**
* 构建日志内容。
*

View File

@ -0,0 +1,254 @@
package org.dromara.common.mybatis.core.mapper;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.config.GlobalConfig;
import com.baomidou.mybatisplus.core.toolkit.GlobalConfigUtils;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.MultiDataPermissionHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.DataPermissionInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.yulichang.base.MPJBaseMapper;
import com.github.yulichang.injector.MPJSqlInjector;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Table;
import org.apache.ibatis.exceptions.TooManyResultsException;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.query.QueryBuilder;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("MyBatis-Plus H2 SQL 集成测试")
class MybatisPlusH2IntegrationTest {
private SqlSessionFactory sqlSessionFactory;
@BeforeEach
void setUp() throws Exception {
String url = "jdbc:h2:mem:mp_" + UUID.randomUUID().toString().replace("-", "") + ";DB_CLOSE_DELAY=-1";
UnpooledDataSource dataSource = new UnpooledDataSource("org.h2.Driver", url, "sa", "");
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE audit_record (id BIGINT PRIMARY KEY, owner_id BIGINT, name VARCHAR(40))");
statement.execute("CREATE TABLE audit_extra (record_id BIGINT PRIMARY KEY, flag INT)");
statement.execute("INSERT INTO audit_record VALUES (1, 1, 'one'), (2, 2, 'two'), (3, 1, 'three'), (4, 2, 'four')");
statement.execute("INSERT INTO audit_extra VALUES (1, 0), (2, 1), (3, 0), (4, 0)");
}
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("h2", new JdbcTransactionFactory(), dataSource));
configuration.setMapUnderscoreToCamelCase(true);
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new DataPermissionInterceptor(new MultiDataPermissionHandler() {
@Override
public Expression getSqlSegment(Table table, Expression where, String mappedStatementId) {
if (!"audit_record".equalsIgnoreCase(table.getName())) {
return null;
}
String qualifier = table.getAlias() == null ? "audit_record" : table.getAlias().getName();
try {
return CCJSqlParserUtil.parseCondExpression(qualifier + ".owner_id = 1");
} catch (JSQLParserException e) {
throw new IllegalStateException(e);
}
}
}));
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
configuration.addInterceptor(interceptor);
GlobalConfigUtils.setGlobalConfig(configuration, new GlobalConfig()
.setDbConfig(new GlobalConfig.DbConfig())
.setSqlInjector(new MPJSqlInjector()));
configuration.addMapper(AuditMapper.class);
configuration.addMapper(AuditExtraMapper.class);
sqlSessionFactory = new MybatisSqlSessionFactoryBuilder().build(configuration);
}
@Test
@DisplayName("联表分页保留权限过滤和总数")
void shouldPageJoinedQueryWithPermission() {
try (SqlSession session = sqlSessionFactory.openSession()) {
Page<AuditRecord> page = new Page<>(1, 1);
List<AuditRecord> records = session.getMapper(AuditMapper.class).findPage(page, 1L);
assertEquals(2, page.getTotal());
assertEquals(List.of(1L), records.stream().map(AuditRecord::getId).toList());
}
}
@Test
@DisplayName("OR 查询和更新均受权限条件约束")
void shouldRestrictOrSelectAndUpdate() {
try (SqlSession session = sqlSessionFactory.openSession()) {
AuditMapper mapper = session.getMapper(AuditMapper.class);
assertEquals(List.of(1L), mapper.findByEitherId(1L, 2L).stream().map(AuditRecord::getId).toList());
assertEquals(1, mapper.renameEitherId(1L, 2L));
assertEquals("changed", mapper.selectById(1L).getName());
session.commit();
try (Connection connection = sqlSessionFactory.getConfiguration().getEnvironment().getDataSource().getConnection();
Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT name FROM audit_record WHERE id = 2")) {
assertTrue(result.next());
assertEquals("two", result.getString(1));
} catch (Exception e) {
fail(e);
}
}
}
@Test
@DisplayName("selectOne 多行时保持异常和非异常模式")
void shouldKeepSelectOneSemantics() {
try (SqlSession session = sqlSessionFactory.openSession()) {
AuditMapper mapper = session.getMapper(AuditMapper.class);
LambdaQueryWrapper<AuditRecord> query = new LambdaQueryWrapper<AuditRecord>()
.eq(AuditRecord::getOwnerId, 1L);
assertThrows(TooManyResultsException.class, () -> mapper.selectOne(query));
assertEquals(1L, mapper.selectOne(query, false).getId());
}
}
@Test
@DisplayName("BaseMapperPlus 链式查询使用真实 Mapper SQL")
void shouldExecuteMapperChain() {
try (SqlSession session = sqlSessionFactory.openSession()) {
AuditMapper mapper = session.getMapper(AuditMapper.class);
assertEquals(List.of(1L, 3L), mapper.lambda()
.eq(AuditRecord::getOwnerId, 1L)
.orderByAsc(AuditRecord::getId)
.list().stream().map(AuditRecord::getId).toList());
}
}
@Test
@DisplayName("PageQuery 分页正确返回记录和总数")
void shouldExecutePageQuery() {
try (SqlSession session = sqlSessionFactory.openSession()) {
PageQuery pageQuery = new PageQuery(1, 2);
Page<AuditRecord> page = pageQuery.build();
List<AuditRecord> records = session.getMapper(AuditMapper.class).selectList(page,
QueryBuilder.lambda(AuditRecord.class)
.eq(AuditRecord::getOwnerId, 1L)
.orderByAsc(AuditRecord::getId)
.build());
assertEquals(2, page.getTotal());
assertEquals(List.of(3L), records.stream().map(AuditRecord::getId).toList());
}
}
@Test
@DisplayName("BaseMapperPlus 基础写入和删除实际执行")
void shouldExecuteCrud() {
try (SqlSession session = sqlSessionFactory.openSession()) {
AuditMapper mapper = session.getMapper(AuditMapper.class);
AuditRecord record = new AuditRecord();
record.id = 5L;
record.ownerId = 1L;
record.name = "five";
assertEquals(1, mapper.insert(record));
assertEquals("five", mapper.selectById(5L).getName());
record.name = "updated";
assertEquals(1, mapper.updateById(record));
assertEquals("updated", mapper.selectById(5L).getName());
assertEquals(1, mapper.deleteById(5L));
assertNull(mapper.selectById(5L));
}
}
@Test
@DisplayName("MPJ 联表分页正确过滤记录和统计总数")
void shouldExecuteMpjJoinPage() {
try (SqlSession session = sqlSessionFactory.openSession()) {
AuditMapper mapper = session.getMapper(AuditMapper.class);
Page<AuditRecord> page = mapper.selectJoinPage(new Page<>(1, 1), AuditRecord.class,
QueryBuilder.lambdaJoin("r", AuditRecord.class)
.selectAll(AuditRecord.class)
.leftJoin(AuditExtra.class, "e", AuditExtra::getRecordId, AuditRecord::getId)
.eq("e", AuditExtra::getFlag, 0)
.build());
assertEquals(2, page.getTotal());
assertEquals(1, page.getRecords().size());
assertEquals(1L, page.getRecords().getFirst().getId());
}
}
public interface AuditMapper extends BaseMapperPlus<AuditRecord, AuditRecord>, MPJBaseMapper<AuditRecord> {
@Select("SELECT r.id, r.owner_id, r.name FROM audit_record r LEFT JOIN audit_extra e ON e.record_id = r.id " +
"WHERE r.owner_id = #{ownerId} OR e.flag = 1 ORDER BY r.id")
List<AuditRecord> findPage(Page<AuditRecord> page, @Param("ownerId") Long ownerId);
@Select("SELECT id, owner_id, name FROM audit_record WHERE id = #{firstId} OR id = #{secondId} ORDER BY id")
List<AuditRecord> findByEitherId(@Param("firstId") Long firstId, @Param("secondId") Long secondId);
@Update("UPDATE audit_record SET name = 'changed' WHERE id = #{firstId} OR id = #{secondId}")
int renameEitherId(@Param("firstId") Long firstId, @Param("secondId") Long secondId);
}
public interface AuditExtraMapper extends BaseMapperPlus<AuditExtra, AuditExtra> {
}
@TableName("audit_record")
public static class AuditRecord {
@TableId
private Long id;
private Long ownerId;
private String name;
public Long getId() {
return id;
}
public Long getOwnerId() {
return ownerId;
}
public String getName() {
return name;
}
}
@TableName("audit_extra")
public static class AuditExtra {
@TableId
private Long recordId;
private Integer flag;
public Long getRecordId() {
return recordId;
}
public Integer getFlag() {
return flag;
}
}
}

View File

@ -0,0 +1,84 @@
package org.dromara.common.mybatis.interceptor;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.dromara.common.mybatis.config.properties.SqlLogProperties;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.Statement;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SqlLogInterceptorTest {
@Test
void ignoredTableStillExecutesAndOtherTableStillLogs() throws Exception {
SqlLogProperties properties = new SqlLogProperties();
properties.setIgnoreTables(List.of("flow_event_wait"));
SqlLogInterceptor interceptor = new SqlLogInterceptor(properties);
String url = "jdbc:h2:mem:sql_log_" + UUID.randomUUID().toString().replace("-", "") + ";DB_CLOSE_DELAY=-1";
UnpooledDataSource dataSource = new UnpooledDataSource("org.h2.Driver", url, "sa", "");
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.execute("CREATE TABLE flow_event_wait (id BIGINT PRIMARY KEY)");
statement.execute("CREATE TABLE audit_record (id BIGINT PRIMARY KEY)");
statement.execute("INSERT INTO flow_event_wait VALUES (1)");
statement.execute("INSERT INTO audit_record VALUES (2)");
}
MybatisConfiguration configuration = new MybatisConfiguration();
configuration.setEnvironment(new Environment("h2", new JdbcTransactionFactory(), dataSource));
configuration.addInterceptor(interceptor);
configuration.addMapper(LogMapper.class);
SqlSessionFactory factory = new MybatisSqlSessionFactoryBuilder().build(configuration);
PrintStream originalErr = System.err;
ByteArrayOutputStream output = new ByteArrayOutputStream();
try (PrintStream capture = new PrintStream(output, true, StandardCharsets.UTF_8)) {
System.setErr(capture);
try (SqlSession session = factory.openSession()) {
assertEquals(1L, session.getMapper(LogMapper.class).eventWaitId());
assertEquals(2L, session.getMapper(LogMapper.class).auditId());
}
} finally {
System.setErr(originalErr);
}
String logs = output.toString(StandardCharsets.UTF_8);
assertFalse(logs.contains("flow_event_wait"));
assertTrue(logs.contains("audit_record"));
}
@Test
void matchesTableReferencesWithoutMatchingLiteralsOrSimilarNames() {
SqlLogProperties properties = new SqlLogProperties();
properties.setIgnoreTables(List.of("flow_event_wait"));
SqlLogInterceptor interceptor = new SqlLogInterceptor(properties);
assertTrue(interceptor.isIgnoredTableSql("select * from flow_event_wait where id = ?"));
assertTrue(interceptor.isIgnoredTableSql("select * from `app`.`flow_event_wait`"));
assertTrue(interceptor.isIgnoredTableSql("select * from audit_record join FLOW_EVENT_WAIT w on w.id = audit_record.id"));
assertTrue(interceptor.isIgnoredTableSql("update flow_event_wait set status = ?"));
assertFalse(interceptor.isIgnoredTableSql("select 'from flow_event_wait' from audit_record"));
assertFalse(interceptor.isIgnoredTableSql("select * from flow_event_wait_history"));
}
interface LogMapper {
@Select("SELECT id FROM flow_event_wait")
Long eventWaitId();
@Select("SELECT id FROM audit_record")
Long auditId();
}
}

View File

@ -1,6 +1,5 @@
package org.dromara.common.push.core;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.utils.ThreadUtils;
@ -19,6 +18,7 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
/**
@ -58,52 +58,41 @@ public class SseEmitterSessionManager implements PushSessionManager {
* @return 返回一个 SseEmitter 实例,客户端可以通过该实例接收 SSE 事件
*/
public SseEmitter connect(Long userId, String token) {
// 从 USER_TOKEN_EMITTERS 中获取或创建当前用户的 SseEmitter 映射表(ConcurrentHashMap)
// 每个用户可以有多个 SSE 连接,通过 token 进行区分
Map<String, SseEmitter> emitters = USER_TOKEN_EMITTERS.computeIfAbsent(userId, k -> new ConcurrentHashMap<>());
// 关闭已存在的SseEmitter,防止超过最大连接数
SseEmitter oldEmitter = emitters.remove(token);
if (oldEmitter != null) {
sendKickedMessage(oldEmitter);
oldEmitter.complete();
}
// 创建一个新的 SseEmitter 实例,避免连接之后直接关闭浏览器导致连接停滞
SseEmitter emitter = new SseEmitter(messageProperties.getSseTimeout());
emitter.onCompletion(() -> removeEmitter(userId, token, emitter));
emitter.onTimeout(() -> removeEmitter(userId, token, emitter));
emitter.onError(e -> removeEmitter(userId, token, emitter));
emitters.put(token, emitter);
// 当 emitter 完成、超时或发生错误时,从映射表中移除对应的 token
emitter.onCompletion(() -> {
SseEmitter remove = emitters.remove(token);
if (remove != null) {
remove.complete();
}
});
emitter.onTimeout(() -> {
SseEmitter remove = emitters.remove(token);
if (remove != null) {
remove.complete();
}
});
emitter.onError((e) -> {
SseEmitter remove = emitters.remove(token);
if (remove != null) {
remove.complete();
AtomicReference<SseEmitter> oldEmitter = new AtomicReference<>();
USER_TOKEN_EMITTERS.compute(userId, (id, emitters) -> {
if (emitters == null) {
emitters = new ConcurrentHashMap<>();
}
oldEmitter.set(emitters.put(token, emitter));
return emitters;
});
if (oldEmitter.get() != null) {
sendKickedMessage(oldEmitter.get());
oldEmitter.get().complete();
}
try {
// 向客户端发送一条连接成功的事件
emitter.send(SseEmitter.event().comment("connected"));
} catch (IOException e) {
// 如果发送消息失败,则从映射表中移除 emitter
emitters.remove(token);
removeEmitter(userId, token, emitter);
emitter.complete();
}
return emitter;
}
private void removeEmitter(Long userId, String token, SseEmitter emitter) {
USER_TOKEN_EMITTERS.computeIfPresent(userId, (id, emitters) -> {
emitters.remove(token, emitter);
return emitters.isEmpty() ? null : emitters;
});
}
/**
* 通知旧连接已被同 token 新连接替换。
*
@ -129,17 +118,18 @@ public class SseEmitterSessionManager implements PushSessionManager {
if (userId == null || token == null) {
return;
}
Map<String, SseEmitter> emitters = USER_TOKEN_EMITTERS.get(userId);
if (MapUtil.isNotEmpty(emitters)) {
AtomicReference<SseEmitter> removed = new AtomicReference<>();
USER_TOKEN_EMITTERS.computeIfPresent(userId, (id, emitters) -> {
removed.set(emitters.remove(token));
return emitters.isEmpty() ? null : emitters;
});
SseEmitter sseEmitter = removed.get();
if (sseEmitter != null) {
try {
SseEmitter sseEmitter = emitters.get(token);
sseEmitter.send(SseEmitter.event().comment("disconnected"));
sseEmitter.complete();
} catch (Exception ignore) {
}
emitters.remove(token);
} else {
USER_TOKEN_EMITTERS.remove(userId);
sseEmitter.complete();
}
}
@ -148,15 +138,7 @@ public class SseEmitterSessionManager implements PushSessionManager {
*/
public void sseMonitor() {
final SseEmitter.SseEventBuilder heartbeat = SseEmitter.event().comment("heartbeat");
// 记录需要移除的用户ID
List<Long> toRemoveUsers = new ArrayList<>();
USER_TOKEN_EMITTERS.forEach((userId, emitterMap) -> {
if (CollUtil.isEmpty(emitterMap)) {
toRemoveUsers.add(userId);
return;
}
emitterMap.entrySet().removeIf(entry -> {
try {
entry.getValue().send(heartbeat);
@ -171,14 +153,8 @@ public class SseEmitterSessionManager implements PushSessionManager {
}
});
// 移除空连接用户
if (emitterMap.isEmpty()) {
toRemoveUsers.add(userId);
}
USER_TOKEN_EMITTERS.computeIfPresent(userId, (id, current) -> current.isEmpty() ? null : current);
});
// 循环结束后统一清理空用户,避免并发修改异常
toRemoveUsers.forEach(USER_TOKEN_EMITTERS::remove);
}
/**
@ -206,14 +182,13 @@ public class SseEmitterSessionManager implements PushSessionManager {
.name("message")
.data(message));
} catch (Exception e) {
SseEmitter remove = emitters.remove(entry.getKey());
if (remove != null) {
remove.complete();
if (emitters.remove(entry.getKey(), entry.getValue())) {
entry.getValue().complete();
}
}
}
} else {
USER_TOKEN_EMITTERS.remove(userId);
USER_TOKEN_EMITTERS.computeIfPresent(userId, (id, current) -> current.isEmpty() ? null : current);
}
}

View File

@ -3,13 +3,19 @@ package org.dromara.common.push;
import org.dromara.common.push.annotation.ConditionalOnMessageTransport;
import org.dromara.common.push.condition.MessageTransportCondition;
import org.dromara.common.push.enums.MessageTransportEnum;
import org.dromara.common.push.core.SseEmitterSessionManager;
import org.dromara.common.push.properties.MessageProperties;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@ -52,4 +58,28 @@ class PushFunctionTest {
environment.setProperty("message.enabled", "false");
assertFalse(condition.matches(context, metadata));
}
@Test
@DisplayName("旧 SSE 回调不移除重连后的新连接")
@SuppressWarnings("unchecked")
void shouldKeepReplacementEmitterWhenOldConnectionCompletes() {
ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
SseEmitterSessionManager manager = new SseEmitterSessionManager(scheduler, new MessageProperties());
Map<Long, Map<String, SseEmitter>> sessions =
(Map<Long, Map<String, SseEmitter>>) ReflectionTestUtils.getField(SseEmitterSessionManager.class, "USER_TOKEN_EMITTERS");
long userId = 999_999L;
String token = "replacement-test";
SseEmitter oldEmitter = new SseEmitter();
SseEmitter newEmitter = new SseEmitter();
sessions.put(userId, new ConcurrentHashMap<>(Map.of(token, newEmitter)));
try {
ReflectionTestUtils.invokeMethod(manager, "removeEmitter", userId, token, oldEmitter);
assertEquals(newEmitter, sessions.get(userId).get(token));
ReflectionTestUtils.invokeMethod(manager, "removeEmitter", userId, token, newEmitter);
assertFalse(sessions.containsKey(userId));
} finally {
sessions.remove(userId);
}
}
}

View File

@ -7,6 +7,8 @@ import cn.hutool.http.HttpStatus;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.domain.R;
import org.springframework.http.ResponseEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@ -19,6 +21,9 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class SaTokenExceptionHandler {
@Value("${message.path:/resource/message}")
private String messagePath = "/resource/message";
/**
* 处理权限码校验失败异常。
*
@ -42,9 +47,12 @@ public class SaTokenExceptionHandler {
* @return 统一失败响应
*/
@ExceptionHandler(NotLoginException.class)
public R<Void> handleNotLoginException(NotLoginException e, HttpServletRequest request) {
public ResponseEntity<?> handleNotLoginException(NotLoginException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
log.error("请求地址'{}',认证失败'{}',无法访问系统资源", requestURI, e.getMessage());
log.warn("请求地址'{}',认证失败,类型'{}'", requestURI, e.getType());
if (request.getServletPath().equals(messagePath)) {
return ResponseEntity.status(HttpStatus.HTTP_UNAUTHORIZED).build();
}
String msg = switch (e.getType()) {
case NotLoginException.TOKEN_TIMEOUT,
NotLoginException.TOKEN_FREEZE -> "登录已过期,请重新登录";
@ -52,7 +60,7 @@ public class SaTokenExceptionHandler {
case NotLoginException.KICK_OUT -> "账号已被管理员强制下线";
default -> "登录状态异常,请重新登录";
};
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, msg);
return ResponseEntity.ok(R.fail(HttpStatus.HTTP_UNAUTHORIZED, msg));
}
}

View File

@ -55,8 +55,22 @@ class SaTokenFunctionTest {
NotLoginException timeout = NotLoginException.newInstance("login", NotLoginException.TOKEN_TIMEOUT, "timeout", "token");
NotLoginException replaced = NotLoginException.newInstance("login", NotLoginException.BE_REPLACED, "replaced", "token");
assertEquals("登录已过期,请重新登录", handler.handleNotLoginException(timeout, request).getMsg());
assertEquals("当前账号已在其他设备登录,您已被强制下线", handler.handleNotLoginException(replaced, request).getMsg());
assertEquals("登录已过期,请重新登录", ((R<?>) handler.handleNotLoginException(timeout, request).getBody()).getMsg());
assertEquals("当前账号已在其他设备登录,您已被强制下线", ((R<?>) handler.handleNotLoginException(replaced, request).getBody()).getMsg());
}
@Test
@DisplayName("SSE 登录失效返回无正文 401")
void shouldRejectSseWithoutJsonConversion() {
SaTokenExceptionHandler handler = new SaTokenExceptionHandler();
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/resource/message");
request.setServletPath("/resource/message");
NotLoginException expired = NotLoginException.newInstance("login", NotLoginException.TOKEN_TIMEOUT, "timeout", "token");
var response = handler.handleNotLoginException(expired, request);
assertEquals(HttpStatus.HTTP_UNAUTHORIZED, response.getStatusCode().value());
assertEquals(null, response.getBody());
}
/**

View File

@ -1,6 +1,6 @@
# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/
FROM bellsoft/liberica-openjdk-rocky:21.0.12-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.4-cds
FROM bellsoft/liberica-openjdk-rocky:21.0.13-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.5-cds
# FROM findepi/graalvm:java21-native
LABEL maintainer="Lion Li"

View File

@ -1,6 +1,6 @@
# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/
FROM bellsoft/liberica-openjdk-rocky:21.0.12-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.4-cds
FROM bellsoft/liberica-openjdk-rocky:21.0.13-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.5-cds
# FROM findepi/graalvm:java21-native
LABEL maintainer="Lion Li"

View File

@ -1,6 +1,6 @@
# 贝尔实验室 Spring 官方推荐镜像 JDK下载地址 https://bell-sw.com/pages/downloads/
FROM bellsoft/liberica-openjdk-rocky:21.0.12-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.4-cds
FROM bellsoft/liberica-openjdk-rocky:21.0.13-cds
# FROM bellsoft/liberica-openjdk-rocky:25.0.5-cds
# FROM findepi/graalvm:java21-native
LABEL maintainer="Lion Li"

View File

@ -130,7 +130,7 @@ public class GenTableServiceImpl implements IGenTableService {
String tableName = genTable.getTableName();
String tableComment = genTable.getTableComment();
LinkedHashMap<String, Table<?>> tablesMap = ServiceProxy.metadata().tables();
Map<String, ? extends Table<?>> tablesMap = ServiceProxy.metadata().tables();
if (CollUtil.isEmpty(tablesMap)) {
return PageResult.build();
}
@ -195,13 +195,13 @@ public class GenTableServiceImpl implements IGenTableService {
@Override
public List<GenTable> selectDbTableListByNames(String[] tableNames, String dataName) {
Set<String> tableNameSet = new HashSet<>(List.of(tableNames));
LinkedHashMap<String, Table<?>> tablesMap = ServiceProxy.metadata().tables();
Map<String, ? extends Table<?>> tablesMap = ServiceProxy.metadata().tables();
if (CollUtil.isEmpty(tablesMap)) {
return new ArrayList<>();
}
List<Table<?>> tableList = tablesMap.values().stream()
List<? extends Table<?>> tableList = tablesMap.values().stream()
.filter(x -> !StringUtils.startWithAnyIgnoreCase(x.getName(), TABLE_IGNORE))
.filter(x -> tableNameSet.contains(x.getName())).toList();
@ -340,9 +340,11 @@ public class GenTableServiceImpl implements IGenTableService {
@Override
public byte[] downloadCode(Long tableId) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(outputStream);
writeCodeToZip(tableId, zip);
IoUtil.close(zip);
try(ZipOutputStream zip = new ZipOutputStream(outputStream)) {
writeCodeToZip(tableId, zip);
} catch (IOException e) {
log.error("渲染模板失败,Zip输出流IO异常。", e);
}
return outputStream.toByteArray();
}
@ -407,11 +409,13 @@ public class GenTableServiceImpl implements IGenTableService {
@Override
public byte[] downloadCode(String[] tableIds) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ZipOutputStream zip = new ZipOutputStream(outputStream);
for (String tableId : tableIds) {
writeCodeToZip(Long.parseLong(tableId), zip);
try(ZipOutputStream zip = new ZipOutputStream(outputStream)) {
for (String tableId : tableIds) {
writeCodeToZip(Long.parseLong(tableId), zip);
}
} catch (IOException e) {
log.error("渲染模板失败,Zip输出流IO异常。", e);
}
IoUtil.close(zip);
return outputStream.toByteArray();
}

View File

@ -278,11 +278,8 @@ public class GenUtils {
* @return 业务名
*/
public static String getBusinessName(String tableName) {
int firstIndex = tableName.indexOf("_");
int nameLength = tableName.length();
String businessName = StringUtils.substring(tableName, firstIndex + 1, nameLength);
businessName = StringUtils.toCamelCase(businessName);
return businessName;
// 业务名与类名共用表前缀处理规则,仅需转为首字母小写。
return StringUtils.uncapitalize(convertClassName(tableName));
}
/**

View File

@ -3,9 +3,11 @@ package org.dromara.system.domain.bo;
import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.dromara.common.core.constant.RegexConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.xss.Xss;
import org.dromara.system.domain.SysUser;
@ -70,6 +72,7 @@ public class SysUserBo implements Serializable {
/**
* 手机号码
*/
@Pattern(regexp = RegexConstants.MOBILE, message = "手机号格式不正确")
private String phoneNumber;
/**

View File

@ -7,8 +7,6 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import org.dromara.common.core.constant.RegexConstants;
import org.dromara.common.core.xss.Xss;
import org.dromara.common.sensitive.annotation.Sensitive;
import org.dromara.common.sensitive.core.SensitiveStrategy;
import java.io.Serial;
import java.io.Serializable;
@ -36,7 +34,6 @@ public class SysUserProfileBo implements Serializable {
/**
* 用户邮箱
*/
@Sensitive(strategy = SensitiveStrategy.EMAIL)
@Email(message = "邮箱格式不正确")
@Size(min = 0, max = 50, message = "邮箱长度不能超过{max}个字符")
private String email;
@ -45,7 +42,6 @@ public class SysUserProfileBo implements Serializable {
* 手机号码
*/
@Pattern(regexp = RegexConstants.MOBILE, message = "手机号格式不正确")
@Sensitive(strategy = SensitiveStrategy.PHONE)
private String phoneNumber;
/**

View File

@ -27,7 +27,6 @@ public class SystemApplicationRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
ossConfigService.init();
log.info("初始化OSS配置成功");
}
}

View File

@ -180,9 +180,11 @@ public class SysDeptServiceImpl implements ISysDeptService, DeptService {
@Override
public List<SysDeptVo> selectDeptByIds(Collection<Long> deptIds) {
return deptMapper.selectDeptList(deptMapper.lambda()
.select(SysDept::getDeptId, SysDept::getDeptName, SysDept::getLeader)
.select(SysDept::getDeptId, SysDept::getDeptName, SysDept::getLeader,
SysDept::getDeptCategory, SysDept::getParentId, SysDept::getAncestors)
.eq(SysDept::getStatus, SystemConstants.NORMAL)
.in(CollUtil.isNotEmpty(deptIds), SysDept::getDeptId, deptIds)
.orderByAsc(SysDept::getAncestors, SysDept::getParentId, SysDept::getOrderNum, SysDept::getDeptId)
.build());
}

View File

@ -51,14 +51,31 @@ public class SysOssConfigServiceImpl implements ISysOssConfigService {
*/
@Override
public void init() {
List<SysOssConfig> list = ossConfigMapper.selectList();
// 加载OSS初始化配置
for (SysOssConfig config : list) {
String configKey = config.getConfigKey();
if (SystemConstants.YES.equals(config.getStatus())) {
RedisUtils.setCacheObject(OssConstant.DEFAULT_CONFIG_KEY, configKey);
try {
List<SysOssConfig> list = ossConfigMapper.selectList();
if (CollUtil.isEmpty(list)) {
log.warn("OSS配置初始化未查询到配置数据");
return;
}
CacheUtils.put(CacheNames.SYS_OSS_CONFIG, config.getConfigKey(), JsonUtils.toJsonString(config));
boolean hasDefaultConfig = false;
// 加载OSS初始化配置
for (SysOssConfig config : list) {
String configKey = config.getConfigKey();
if (SystemConstants.YES.equals(config.getStatus())) {
RedisUtils.setCacheObject(OssConstant.DEFAULT_CONFIG_KEY, configKey);
hasDefaultConfig = true;
}
CacheUtils.put(CacheNames.SYS_OSS_CONFIG, configKey, JsonUtils.toJsonString(config));
}
if (!hasDefaultConfig) {
log.warn("OSS配置初始化未找到启用的默认配置");
} else {
log.info("初始化OSS配置成功");
}
} catch (RuntimeException e) {
log.error("OSS配置初始化失败", e);
throw e;
}
}

View File

@ -552,10 +552,10 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
*/
@Override
public List<SysUserVo> selectUserListByDept(Long deptId) {
return userMapper.lambda()
return userMapper.selectUserList(userMapper.lambda()
.eq(SysUser::getDeptId, deptId)
.orderByAsc(SysUser::getUserId)
.voList();
.build());
}
/**
@ -690,8 +690,12 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
if (CollUtil.isEmpty(roleIds)) {
return List.of();
}
List<SysUserRole> userRoles = userRoleMapper.lambda().in(SysUserRole::getRoleId, roleIds).list();
return StreamUtils.toList(userRoles, SysUserRole::getUserId);
List<SysUserRole> userRoles = userRoleMapper.lambda()
.select(SysUserRole::getUserId)
.in(SysUserRole::getRoleId, roleIds)
.list();
Set<Long> userIds = StreamUtils.toSet(userRoles, SysUserRole::getUserId);
return new ArrayList<>(userIds);
}
/**
@ -702,17 +706,8 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
*/
@Override
public List<UserDTO> selectUsersByRoleIds(Collection<Long> roleIds) {
if (CollUtil.isEmpty(roleIds)) {
return List.of();
}
// 通过角色ID获取用户角色信息
List<SysUserRole> userRoles = userRoleMapper.lambda().in(SysUserRole::getRoleId, roleIds).list();
// 获取用户ID列表
Set<Long> userIds = StreamUtils.toSet(userRoles, SysUserRole::getUserId);
return this.selectListByIds(new ArrayList<>(userIds));
List<Long> userIds = this.selectUserIdsByRoleIds(roleIds);
return this.selectListByIds(userIds);
}
/**
@ -726,14 +721,33 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
if (CollUtil.isEmpty(deptIds)) {
return List.of();
}
List<SysUserVo> list = userMapper.lambda()
List<SysUser> list = userMapper.lambda()
.select(SysUser::getUserId, SysUser::getUserName, SysUser::getNickName, SysUser::getEmail, SysUser::getPhoneNumber)
.eq(SysUser::getStatus, SystemConstants.NORMAL)
.in(SysUser::getDeptId, deptIds)
.voList();
.list();
return BeanUtil.copyToList(list, UserDTO.class);
}
/**
* 通过部门ID查询用户ID
*
* @param deptIds 部门ids
* @return 用户ids
*/
@Override
public List<Long> selectUserIdsByDeptIds(Collection<Long> deptIds) {
if (CollUtil.isEmpty(deptIds)) {
return List.of();
}
List<SysUser> list = userMapper.lambda()
.select(SysUser::getUserId)
.eq(SysUser::getStatus, SystemConstants.NORMAL)
.in(SysUser::getDeptId, deptIds)
.list();
return StreamUtils.toList(list, SysUser::getUserId);
}
/**
* 通过岗位ID查询用户
*
@ -742,17 +756,27 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
*/
@Override
public List<UserDTO> selectUsersByPostIds(Collection<Long> postIds) {
List<Long> userIds = this.selectUserIdsByPostIds(postIds);
return this.selectListByIds(userIds);
}
/**
* 通过岗位ID查询用户ID
*
* @param postIds 岗位ids
* @return 用户ids
*/
@Override
public List<Long> selectUserIdsByPostIds(Collection<Long> postIds) {
if (CollUtil.isEmpty(postIds)) {
return List.of();
}
// 通过岗位ID获取用户岗位信息
List<SysUserPost> userPosts = userPostMapper.lambda().in(SysUserPost::getPostId, postIds).list();
// 获取用户ID列表
List<SysUserPost> userPosts = userPostMapper.lambda()
.select(SysUserPost::getUserId)
.in(SysUserPost::getPostId, postIds)
.list();
Set<Long> userIds = StreamUtils.toSet(userPosts, SysUserPost::getUserId);
return this.selectListByIds(new ArrayList<>(userIds));
return new ArrayList<>(userIds);
}
/**

View File

@ -86,13 +86,13 @@
<!-- Warm-Flow 工作流引擎 -->
<dependency>
<groupId>org.dromara.warm</groupId>
<groupId>io.github.sgs98</groupId>
<artifactId>warm-flow-mybatis-plus-sb4-starter</artifactId>
</dependency>
<!-- Warm-Flow UI 插件 -->
<dependency>
<groupId>org.dromara.warm</groupId>
<groupId>io.github.sgs98</groupId>
<artifactId>warm-flow-plugin-ui-sb-web</artifactId>
</dependency>

View File

@ -83,11 +83,6 @@ public interface FlowConstant {
*/
String WF_TASK_STATUS = "wf_task_status";
/**
* 自动通过变量标识。
*/
String AUTO_PASS = "autoPass";
/**
* 业务编码流程变量名。
*/

View File

@ -1,32 +0,0 @@
package org.dromara.workflow.common.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 抄送设置枚举
*
* @author AprilWind
*/
@Getter
@AllArgsConstructor
public enum CopySettingEnum implements NodeExtEnum {
;
/**
* 展示名称。
*/
private final String label;
/**
* 配置值。
*/
private final String value;
/**
* 是否默认选中。
*/
private final boolean selected;
}

View File

@ -1,115 +0,0 @@
package org.dromara.workflow.common.enums;
import cn.hutool.core.util.StrUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 任务状态枚举
*
* @author may
*/
@Getter
@AllArgsConstructor
public enum TaskStatusEnum {
/**
* 撤销
*/
CANCEL("cancel", "撤销"),
/**
* 通过
*/
PASS("pass", "通过"),
/**
* 待审核
*/
WAITING("waiting", "待审核"),
/**
* 作废
*/
INVALID("invalid", "作废"),
/**
* 退回
*/
BACK("back", "退回"),
/**
* 终止
*/
TERMINATION("termination", "终止"),
/**
* 转办
*/
TRANSFER("transfer", "转办"),
/**
* 委托
*/
DEPUTE("depute", "委托"),
/**
* 抄送
*/
COPY("copy", "抄送"),
/**
* 加签
*/
SIGN("sign", "加签"),
/**
* 减签
*/
SIGN_OFF("sign_off", "减签"),
/**
* 超时
*/
TIMEOUT("timeout", "超时");
/**
* 状态编码与描述缓存。
*/
private static final Map<String, String> STATUS_DESC_MAP = Arrays.stream(values())
.collect(Collectors.toConcurrentMap(TaskStatusEnum::getStatus, TaskStatusEnum::getDesc));
/**
* 状态
*/
private final String status;
/**
* 状态描述。
*/
private final String desc;
/**
* 根据状态编码获取任务业务状态描述。
*
* @param status 状态
* @return 状态描述
*/
public static String findByStatus(String status) {
// 从缓存中直接获取描述
return STATUS_DESC_MAP.getOrDefault(status, StrUtil.EMPTY);
}
/**
* 判断状态是否为通过或退回
*
* @param status 状态值
* @return true 表示是通过或退回状态
*/
public static boolean isPassOrBack(String status) {
return PASS.getStatus().equals(status) || BACK.getStatus().equals(status);
}
}

View File

@ -0,0 +1,110 @@
package org.dromara.workflow.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.domain.R;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.redis.annotation.RepeatSubmit;
import org.dromara.common.web.core.BaseController;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.domain.bo.FlowEventWaitBo;
import org.dromara.workflow.domain.bo.FlowTimerTriggerBo;
import org.dromara.workflow.domain.vo.FlowEventWaitVo;
import org.dromara.workflow.service.IFlwEventWaitService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 定时任务(事件等待记录)控制器。
* <p>数据来源为引擎事件等待表,默认只查定时类事件;消息类 / 错误类等待需显式放开事件类型范围。
* 支持定时启动任务手动补发,以及已死事件等待记录复活;补发不推进等待记录,
* 定时任务的下次触发时间不受影响。</p>
*
* @author may
* @since 2026/10/21
*/
@ConditionalOnEnable
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/workflow/eventWait")
public class FlwEventWaitController extends BaseController {
private final IFlwEventWaitService flwEventWaitService;
/**
* 分页查询定时任务(事件等待记录)列表。
*
* @param bo 查询条件
* @return 等待记录分页数据
*/
@SaCheckPermission("workflow:eventWait:list")
@GetMapping("/list")
public R<PageResult<FlowEventWaitVo>> list(FlowEventWaitBo bo) {
return R.ok(flwEventWaitService.queryPageList(bo));
}
/**
* 按同一组筛选条件统计各状态等待记录数量。
*
* @param bo 查询条件
* @return 各状态计数
*/
@SaCheckPermission("workflow:eventWait:list")
@GetMapping("/summary")
public R<Map<String, Long>> summary(FlowEventWaitBo bo) {
return R.ok(flwEventWaitService.summary(bo));
}
/**
* 查询定时任务详情。
*
* @param id 事件等待记录主键
* @return 等待记录详情
*/
@SaCheckPermission("workflow:eventWait:query")
@GetMapping("/{id}")
public R<FlowEventWaitVo> getInfo(@NotNull(message = "主键不能为空") @PathVariable Long id) {
return R.ok(flwEventWaitService.queryById(id));
}
/**
* 手动补发一次定时启动任务(运维应急)。
* <p>不推进等待记录,下一次定时触发照常进行;实例发起人记为当前登录用户。</p>
*
* @param bo 补发参数
* @return 新建实例主键
*/
@SaCheckPermission("workflow:eventWait:trigger")
@Log(title = "定时任务手动补发", businessType = BusinessType.INSERT)
@RepeatSubmit()
@PostMapping("/trigger")
public R<Long> trigger(@RequestBody FlowTimerTriggerBo bo) {
return R.ok(flwEventWaitService.manualTrigger(bo));
}
/**
* 复活已死的事件等待记录,定时启动任务需通过重新发布或挂起后激活恢复。
*
* @param id 事件等待记录主键
* @return 操作结果
*/
@SaCheckPermission("workflow:eventWait:trigger")
@Log(title = "事件等待记录复活", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/{id}/revive")
public R<Void> revive(@NotNull(message = "主键不能为空") @PathVariable Long id) {
flwEventWaitService.revive(id);
return R.ok();
}
}

View File

@ -171,6 +171,16 @@ public class FlwInstanceController extends BaseController {
return R.ok(flwInstanceService.flowHisTaskList(businessId));
}
/**
* 根据流程实例 ID、业务 ID 或两者查询流程进度。
*/
@GetMapping("/flowHisTaskList")
@SaCheckPermission("workflow:instance:query")
public R<Map<String, Object>> flowHisTaskList(@RequestParam(required = false) Long instanceId,
@RequestParam(required = false) String businessId) {
return R.ok(flwInstanceService.flowHisTaskList(instanceId, businessId));
}
/**
* 获取流程变量。
*

View File

@ -0,0 +1,32 @@
package org.dromara.workflow.demo;
import lombok.extern.slf4j.Slf4j;
import org.dromara.warm.flow.core.service.task.ServiceTaskContext;
import org.dromara.warm.flow.core.service.task.ServiceTaskHandler;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.springframework.stereotype.Component;
/**
* 服务任务示例:class 模式
* <p>
* 节点配置 {@code node_config.serviceTask} 里写 {@code {"type":"class","handler":"本类全名"}},
* 引擎先按全名从容器取 Bean,取不到才反射新建实例,所以注册成 Bean 后可以注入其它服务。
*
* @author may
*/
@ConditionalOnEnable
@Slf4j
@Component
public class DemoClassServiceTask implements ServiceTaskHandler {
@Override
public void execute(ServiceTaskContext context) {
log.info("服务任务[class 模式]开始执行: instanceId={}, nodeCode={}, nodeName={}",
context.getInstance().getId(), context.getNode().getNodeCode(), context.getNode().getNodeName());
context.setVariable("classTaskNode", context.getNode().getNodeCode());
context.setVariable("classTaskResult", "class模式已执行");
log.info("服务任务[class 模式]执行完成: instanceId={}, nodeCode={}",
context.getInstance().getId(), context.getNode().getNodeCode());
}
}

View File

@ -0,0 +1,38 @@
package org.dromara.workflow.demo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.warm.flow.core.service.task.ServiceTaskContext;
import org.dromara.warm.flow.core.service.task.ServiceTaskHandler;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.springframework.stereotype.Component;
/**
* 服务任务示例:delegateExpression 模式
* <p>
* 节点配置写 {@code {"type":"delegateExpression","expression":"#{@demoDelegateServiceTask}"}},
* 引擎按 SpEL 从容器取出本 Bean 后调用 {@code execute}。Bean 名即表达式里的名字,改名会打断已发布的定义。
*
* @author may
*/
@ConditionalOnEnable
@Slf4j
@Component("demoDelegateServiceTask")
@RequiredArgsConstructor
public class DemoDelegateServiceTask implements ServiceTaskHandler {
private final DemoOrderService demoOrderService;
@Override
public void execute(ServiceTaskContext context) {
log.info("服务任务[delegateExpression 模式]开始执行: instanceId={}, nodeCode={}",
context.getInstance().getId(), context.getNode().getNodeCode());
String orderNo = demoOrderService.createOrder(context);
context.setVariable("delegateTaskOrderNo", orderNo);
context.setVariable("delegateTaskResult", StringUtils.format("delegateExpression模式已执行, 单号={}", orderNo));
log.info("服务任务[delegateExpression 模式]执行完成: instanceId={}, orderNo={}",
context.getInstance().getId(), orderNo);
}
}

View File

@ -0,0 +1,60 @@
package org.dromara.workflow.demo;
import lombok.extern.slf4j.Slf4j;
import org.dromara.warm.flow.core.service.task.ServiceTaskContext;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicLong;
/**
* 服务任务示例:expression 模式的被调用方
* <p>
* 节点配置写 {@code {"type":"expression","expression":"#{@demoOrderService.createOrder(#execution)}",
* "resultVariable":"orderNo"}},{@code #execution} 就是引擎传入的 {@link ServiceTaskContext},
* 返回值按 {@code resultVariable} 写回流程变量。
*
* @author may
*/
@ConditionalOnEnable
@Slf4j
@Component("demoOrderService")
public class DemoOrderService {
/**
* 示例单号序列,仅用于演示“服务任务产出变量供后续网关判断”,非业务落库。
*/
private final AtomicLong sequence = new AtomicLong(1000L);
/**
* 生成示例业务单号。
*
* @param context 服务任务上下文
* @return 示例单号
*/
public String createOrder(ServiceTaskContext context) {
log.info("服务任务[expression 模式]开始执行: instanceId={}, nodeCode={}",
context.getInstance().getId(), context.getNode().getNodeCode());
context.setVariable("expressionTaskNode", context.getNode().getNodeCode());
context.setVariable("expressionTaskResult", "expression模式已执行");
String orderNo = "SO" + sequence.incrementAndGet();
log.info("服务任务[expression 模式]执行完成: instanceId={}, nodeCode={}, orderNo={}",
context.getInstance().getId(), context.getNode().getNodeCode(), orderNo);
return orderNo;
}
/**
* 按入参变量算请假天数,产出变量供后面的网关条件(如 {@code gt@@day|3})判断。
*
* @param context 服务任务上下文
* @return 天数
*/
public Integer evalDay(ServiceTaskContext context) {
Object inputDay = context.getVariable("inputDay");
int day = inputDay == null ? 5 : Integer.parseInt(String.valueOf(inputDay));
log.info("服务任务[expression 模式]计算天数: instanceId={}, nodeCode={}, inputDay={}, day={}",
context.getInstance().getId(), context.getNode().getNodeCode(), inputDay, day);
return day;
}
}

View File

@ -0,0 +1,96 @@
package org.dromara.workflow.demo;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.warm.flow.core.entity.Task;
import org.dromara.warm.flow.core.spi.Listener;
import org.dromara.warm.flow.core.spi.ListenerVariable;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Map;
/**
* 付款失败善后监听器(示例)
* <p>
* 挂在「财务执行付款」节点的 create 监听上:付款金额超过限额就调 {@code TaskService#throwError}
* 抛出 {@code PAY_FAILED},由流程定义上的错误边界捕获并走善后分支。金额先取本次办理变量,
* 取不到再回落实例变量,因为监听器可能在变量合并前触发。
* <p>
* 节点监听器配置里写的是本类全名,改包名或类名要同步改已发布的流程定义。
*
* @author may
*/
@ConditionalOnEnable
@Slf4j
@Component
public class DemoPayFailListener implements Listener {
/**
* 付款金额变量名。
*/
public static final String VAR_PAY_AMOUNT = "payAmount";
/**
* 免审限额,超过即视为付款失败需善后。
*/
public static final BigDecimal PAY_AMOUNT_LIMIT = new BigDecimal("1000");
/**
* 错误码,需与流程定义上错误边界的错误码一致。
*/
public static final String ERROR_CODE_PAY_FAILED = "PAY_FAILED";
@Override
public void notify(ListenerVariable listenerVariable) {
Task task = listenerVariable == null ? null : listenerVariable.getTask();
if (task == null || task.getId() == null) {
return;
}
BigDecimal amount = readPayAmount(listenerVariable);
if (amount == null || amount.compareTo(PAY_AMOUNT_LIMIT) <= 0) {
return;
}
log.info("付款金额 {} 超过限额 {},抛出 {} 转入错误边界: instanceId={}, taskId={}",
amount, PAY_AMOUNT_LIMIT, ERROR_CODE_PAY_FAILED, task.getInstanceId(), task.getId());
FlowEngine.taskService().throwError(task.getId(), ERROR_CODE_PAY_FAILED,
"付款金额 " + amount.toPlainString() + " 超过限额 " + PAY_AMOUNT_LIMIT.toPlainString() + ",转入善后处理");
}
/**
* 先取本次办理的变量,取不到再回落实例变量。
*
* @param listenerVariable 监听器变量
* @return 付款金额,未配置时返回 {@code null}
*/
private BigDecimal readPayAmount(ListenerVariable listenerVariable) {
BigDecimal amount = parseAmount(listenerVariable.getVariable());
if (amount != null) {
return amount;
}
Instance instance = listenerVariable.getInstance();
return parseAmount(instance == null ? null : instance.getVariableMap());
}
/**
* 从变量集合里解析金额,非法值按未配置处理。
*
* @param variable 变量集合
* @return 付款金额,解析失败返回 {@code null}
*/
private BigDecimal parseAmount(Map<String, Object> variable) {
if (variable == null || variable.get(VAR_PAY_AMOUNT) == null) {
return null;
}
String value = String.valueOf(variable.get(VAR_PAY_AMOUNT));
if (!StringUtils.isNumeric(value)) {
log.warn("流程变量 {} 不是数字,跳过付款校验: {}", VAR_PAY_AMOUNT, value);
return null;
}
return new BigDecimal(value);
}
}

View File

@ -0,0 +1,68 @@
package org.dromara.workflow.demo;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import lombok.extern.slf4j.Slf4j;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.entity.EventWait;
import org.dromara.warm.flow.core.enums.EventType;
import org.dromara.warm.flow.core.service.impl.EventRuntimeHandler;
import org.dromara.warm.flow.core.timer.EventTaskHandler;
import org.dromara.warm.flow.core.workflow.context.WorkflowContext;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.springframework.stereotype.Component;
/**
* 定时启动示例:为流程实例提供可被业务页面解析的数字业务 ID。
*
* <p>接入要点:实现 {@link EventTaskHandler} 后引擎内置默认处理器(EventRuntimeHandler)
* 自动让位,本类成为全部事件的处理入口——非本演示流程的事件必须委托默认处理器,
* 否则会覆盖引擎原生的定时启动与边界事件行为;等待记录的完成由默认三参编排
* (handle 后执行 completion),实现里无需自行调用。</p>
*
* <p>使用方式:发布含「定时启动」节点的定义(流程编码 {@link #DEMO_FLOW_CODE}),
* 开启 {@code warm-flow.timer-enabled}。</p>
*
* @author may
*/
@Slf4j
@Component
@ConditionalOnEnable
public class DemoTimerInspectHandler implements EventTaskHandler {
/**
* 演示流程编码:对应设计器发布的含「定时启动」节点的定义
*/
public static final String DEMO_FLOW_CODE = "demo_timer_start";
/**
* 引擎内置默认处理器:兜底所有非本演示流程的事件
*/
private final EventTaskHandler defaultHandler = new EventRuntimeHandler();
/**
* 定时调度入口(无上下文):统一处理。
*/
@Override
public void handle(EventWait wait) {
if (isInspectTimerStart(wait)) {
// 这里应该使用业务的id demo使用自动生成id 需要用户自行替换
String businessId = String.valueOf(IdWorker.getId());
WorkflowContext context = WorkflowContext.create();
FlowEngine.insService().startByTimer(businessId, wait.getDefinitionId(),
wait.getNodeCode(), null, context);
log.info("定时启动示例已自动发起审批: businessId={}", businessId);
return;
}
// 非本流程的事件:委托默认处理器,保持定时启动与边界事件原生行为
defaultHandler.handle(wait);
}
/**
* 只拦截本演示流程的定时启动事件。
*/
private boolean isInspectTimerStart(EventWait wait) {
return EventType.TIMER_START.getValue().equals(wait.getEventType())
&& DEMO_FLOW_CODE.equals(FlowEngine.defService()
.getById(wait.getDefinitionId()).getFlowCode());
}
}

View File

@ -0,0 +1,136 @@
package org.dromara.workflow.domain.bo;
import lombok.Data;
import org.dromara.warm.flow.core.dto.EventWaitQuery;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serial;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
/**
* 定时任务(事件等待记录)查询业务对象。
* <p>过滤维度与引擎 {@link EventWaitQuery} 一一对应:多值条件取「或」、不同条件之间取「且」;
* 事件类型留空时按 {@link #timerOnly} 决定是否只查定时类事件。数据来自引擎 flow_event_wait,
* 本对象只读,不含写操作字段。</p>
*
* @author may
* @since 2026/10/21
*/
@Data
public class FlowEventWaitBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 页码
*/
private Integer pageNum;
/**
* 每页条数
*/
private Integer pageSize;
/**
* 排序字段(引擎白名单:id / nextTriggerAt / retryAt / createTime / updateTime / status)
*/
private String orderByColumn;
/**
* 排序方向 asc / desc
*/
private String isAsc;
/**
* 等待状态多选(WAITING / RETRY / RUNNING / DEAD / CANCELLED),逗号分隔,留空表示不限
*/
private String statuses;
/**
* 事件类型多选(TIMER_START / TIMER_CATCH / TIMER_BOUNDARY / …),逗号分隔,
* 留空时按 timerOnly 取默认范围
*/
private String eventTypes;
/**
* 是否只查定时类事件,默认 true
*/
private Boolean timerOnly = true;
/**
* 流程定义编码
*/
private String flowCode;
/**
* 业务流程主键
*/
private String businessId;
/**
* 事件节点编码
*/
private String nodeCode;
/**
* 事件名称(消息名 / 错误码)
*/
private String eventName;
/**
* 下次触发时间下界
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date nextTriggerAtFrom;
/**
* 下次触发时间上界
*/
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date nextTriggerAtTo;
/**
* 转换为引擎查询条件。
*
* @return 引擎只读查询条件
*/
public EventWaitQuery toQuery() {
EventWaitQuery query = new EventWaitQuery();
query.setPageNum(pageNum == null || pageNum < 1 ? 1 : pageNum);
query.setPageSize(pageSize == null || pageSize < 1 ? 10 : pageSize);
query.setStatuses(splitCsv(statuses));
query.setEventTypes(splitCsv(eventTypes));
query.setTimerOnly(!Boolean.FALSE.equals(timerOnly));
query.setFlowCode(flowCode);
query.setBusinessId(businessId);
query.setNodeCode(nodeCode);
query.setEventName(eventName);
query.setNextTriggerAtFrom(nextTriggerAtFrom);
query.setNextTriggerAtTo(nextTriggerAtTo);
if (orderByColumn != null && !orderByColumn.isBlank()) {
query.setOrderBy(orderByColumn);
query.setIsAsc(isAsc);
}
return query;
}
/**
* 逗号分隔串拆成引擎多值条件;空串返回 {@code null} 表示不限。
*
* @param csv 逗号分隔串
* @return 多值列表或 null
*/
private static List<String> splitCsv(String csv) {
if (csv == null || csv.isBlank()) {
return null;
}
return Arrays.stream(csv.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toList();
}
}

View File

@ -0,0 +1,37 @@
package org.dromara.workflow.domain.bo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Map;
/**
* 定时启动任务手动补发业务对象。
* <p>只对 TIMER_START 事件的等待记录生效;补发只创建一次实例,不读取也不推进等待记录,
* 定时任务的下次触发时间不受影响。</p>
*
* @author may
* @since 2026/10/21
*/
@Data
public class FlowTimerTriggerBo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 事件等待记录主键
*/
private Long waitId;
/**
* 业务主键,留空时生成数字雪花 ID
*/
private String businessId;
/**
* 额外流程变量,合并到节点默认变量之后
*/
private Map<String, Object> variables;
}

View File

@ -1,7 +1,6 @@
package org.dromara.workflow.domain.context;
import lombok.Data;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.warm.flow.orm.entity.FlowTask;
import org.dromara.workflow.domain.bo.CompleteTaskBo;
@ -31,14 +30,5 @@ public class CompleteTaskContext {
*/
private Instance instance;
/**
* Warm-Flow 办理参数,由参数构建节点生成并供执行、自动审批节点复用。
*/
private FlowParams flowParams;
/**
* 流程实例是否开启自动审批,由参数构建节点从实例变量中解析。
*/
private Boolean autoPass;
}

View File

@ -1,7 +1,7 @@
package org.dromara.workflow.domain.context;
import lombok.Data;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.workflow.context.OperatorContext;
import org.dromara.warm.flow.core.entity.Task;
import org.dromara.warm.flow.orm.entity.FlowNode;
import org.dromara.workflow.common.enums.TaskOperationEnum;
@ -33,9 +33,14 @@ public class TaskOperationContext {
private TaskOperationEnum operation;
/**
* Warm-Flow 操作参数。
* 当前任务操作者。
*/
private FlowParams flowParams;
private OperatorContext operator;
/**
* 当前操作说明。
*/
private String message;
/**
* 当前任务。

View File

@ -0,0 +1,158 @@
package org.dromara.workflow.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 定时任务(事件等待记录)视图对象 flow_event_wait。
* <p>字段来自引擎 flow_event_wait;flowName / businessId 由服务层按定义与实例主键装配,
* eventTypeName / statusName 为展示文案。</p>
*
* @author may
* @since 2026/10/21
*/
@Data
public class FlowEventWaitVo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 主键id
*/
private Long id;
/**
* 流程定义id
*/
private Long definitionId;
/**
* 流程名称
*/
private String flowName;
/**
* 流程实例id(定时启动事件为空)
*/
private Long instanceId;
/**
* 业务流程主键
*/
private String businessId;
/**
* 事件节点编码
*/
private String nodeCode;
/**
* 事件类型(TIMER_START / TIMER_CATCH / TIMER_BOUNDARY / …)
*/
private String eventType;
/**
* 事件类型文案
*/
private String eventTypeName;
/**
* 事件名称(消息名 / 错误码)
*/
private String eventName;
/**
* 边界事件关联的待办任务id
*/
private Long attachedTaskId;
/**
* 定时触发策略(AFTER / FIXED / POINT_IN_TIME)
*/
private String triggerType;
/**
* 触发间隔表达式
*/
private String triggerInterval;
/**
* 下次触发时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date nextTriggerAt;
/**
* 时区
*/
private String timezone;
/**
* 已触发次数
*/
private Integer repeatCount;
/**
* 最大触发次数
*/
private Integer maxRepeatCount;
/**
* 等待状态(WAITING / RETRY / RUNNING / CANCELLED / DEAD / SUCCESS)
*/
private String status;
/**
* 等待状态文案
*/
private String statusName;
/**
* 已重试次数
*/
private Integer retryCount;
/**
* 最大重试次数
*/
private Integer maxRetryCount;
/**
* 下次重试时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date retryAt;
/**
* 载荷
*/
private String payload;
/**
* 最近一次错误
*/
private String lastError;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
/**
* 更新时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
/**
* 定时引擎扫描开关是否开启(warm-flow.timer-enabled)。
* 关闭时等待记录仍会生成但不会被自动触发,页面据此给出提醒。
*/
private Boolean timerEnabled;
}

View File

@ -64,12 +64,12 @@ public class FlowHisTaskVo implements Serializable {
private Long taskId;
/**
* 协作方式(1审批 2转办 3委派 4会签 5票签 6加签 7减签)
* 协作方式(none无 transfer转办 delegate委派 add_signer加签 remove_signer减签)
*/
private Integer cooperateType;
private String cooperateType;
/**
* 协作方式(1审批 2转办 3委派 4会签 5票签 6加签 7减签)
* 协作方式展示名,由 cooperateType 翻译而来
*/
private String cooperateTypeName;
@ -246,7 +246,7 @@ public class FlowHisTaskVo implements Serializable {
/**
* 设置协作方式,并通过协作方式获取名称
*/
public void setCooperateType(Integer cooperateType) {
public void setCooperateType(String cooperateType) {
this.cooperateType = cooperateType;
this.cooperateTypeName = CooperateType.getValueByKey(cooperateType);
}

View File

@ -6,7 +6,6 @@ import java.io.Serial;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Node 扩展属性解析结果 VO
@ -28,13 +27,6 @@ public class NodeExtVo implements Serializable {
*/
private List<ButtonPermissionVo> buttonPermissions;
/**
* 抄送对象 ID 集合
* <p>
* 根据扩展属性中 CopySettingEnum 类型的数据生成,存储需要抄送的对象 ID
*/
private Set<String> copySettings;
/**
* 自定义参数 Map
* <p>

View File

@ -73,4 +73,9 @@ public class TestLeaveVo implements Serializable {
@ExcelProperty(value = "状态")
private String status;
/**
* 流程定义编码(已有流程实例时回显,供修改后重新提交沿用)
*/
private String flowCode;
}

View File

@ -0,0 +1,33 @@
package org.dromara.workflow.handler;
import lombok.extern.slf4j.Slf4j;
import org.dromara.warm.flow.core.service.event.EventMessage;
import org.dromara.warm.flow.core.service.event.EventPublisher;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.springframework.stereotype.Component;
/**
* 流程消息事件的宿主发布器
* <p>
* 引擎在 {@code MESSAGE_THROW} / {@code MESSAGE_END} 时经 {@code FlowEngine.eventPublisher()}
* (即容器里唯一的 {@link EventPublisher} Bean)把消息交出来。没有这个 Bean 时引擎只会告警并跳过,
* 消息事件看起来“配了但没生效”。
* <p>
* 当前实现只落日志,够演示与排查用;接真实中间件时在这里改成发 MQ / 调用外部接口,
* 注意本方法与流程状态更新在同一事务边界内,需要可靠投递请自行引入 Outbox。
*
* @author may
*/
@ConditionalOnEnable
@Slf4j
@Component
public class WorkflowEventPublisher implements EventPublisher {
@Override
public void publish(EventMessage message) {
log.info("收到流程消息事件: eventName={}, eventType={}, definitionId={}, instanceId={}, nodeCode={}, payload={}",
message.getEventName(), message.getEventType(), message.getDefinitionId(), message.getInstanceId(),
message.getNodeCode(), message.getPayload());
}
}

View File

@ -1,70 +0,0 @@
package org.dromara.workflow.handler;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.api.domain.UserDTO;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.handler.PermissionHandler;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.service.IFlwTaskAssigneeService;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
/**
* 办理人权限处理器
*
* @author AprilWind
*/
@ConditionalOnEnable
@RequiredArgsConstructor
@Component
@Slf4j
public class WorkflowPermissionHandler implements PermissionHandler {
private final IFlwTaskAssigneeService flwTaskAssigneeService;
/**
* 办理人权限标识,比如用户,角色,部门等,用于校验是否有权限办理任务
* 后续在{@link FlowParams#getPermissionFlag} 中获取
* 返回当前用户权限集合
*
* @return 当前用户权限集合
*/
@Override
public List<String> permissions() {
return Collections.singletonList(LoginHelper.getUserIdStr());
}
/**
* 获取当前办理人
*
* @return 当前办理人
*/
@Override
public String getHandler() {
return LoginHelper.getUserIdStr();
}
/**
* 将预设办理人转换为实际用户 ID 列表。
*
* @param permissions 预设权限标识列表
* @return 用户 ID 列表
*/
@Override
public List<String> convertPermissions(List<String> permissions) {
if (CollUtil.isEmpty(permissions)) {
return permissions;
}
String storageIds = CollUtil.join(permissions, StringUtils.SEPARATOR);
List<UserDTO> users = flwTaskAssigneeService.fetchUsersByStorageIds(storageIds);
return StreamUtils.toList(users, userDTO -> Convert.toStr(userDTO.getUserId()));
}
}

View File

@ -1,7 +1,6 @@
package org.dromara.workflow.listener;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.TypeReference;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjectUtil;
@ -9,19 +8,17 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.enums.BusinessStatusEnum;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.system.api.UserService;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.workflow.context.WorkflowContext;
import org.dromara.warm.flow.core.entity.Definition;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.warm.flow.core.entity.Task;
import org.dromara.warm.flow.core.listener.GlobalListener;
import org.dromara.warm.flow.core.listener.ListenerVariable;
import org.dromara.warm.flow.core.enums.TaskStatus;
import org.dromara.warm.flow.core.spi.GlobalListener;
import org.dromara.warm.flow.core.spi.ListenerVariable;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.constant.FlowConstant;
import org.dromara.workflow.common.enums.TaskStatusEnum;
import org.dromara.workflow.domain.bo.FlowCopyBo;
import org.dromara.workflow.domain.vo.NodeExtVo;
import org.dromara.workflow.event.WorkflowCopyEvent;
@ -37,7 +34,6 @@ import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 工作流全局监听器,处理任务流转中的扩展变量、消息和事件发布。
@ -55,7 +51,6 @@ public class WorkflowGlobalListener implements GlobalListener {
private final FlowProcessEventHandler flowProcessEventHandler;
private final IFlwCommonService flwCommonService;
private final IFlwNodeExtService nodeExtService;
private final UserService userService;
/**
* 任务创建回调,当前预留扩展。
@ -81,20 +76,6 @@ public class WorkflowGlobalListener implements GlobalListener {
variable = new HashMap<>();
}
NodeExtVo nodeExt = nodeExtService.parseNodeExt(ext, variable);
Set<String> copyList = nodeExt.getCopySettings();
if (CollUtil.isNotEmpty(copyList)) {
List<Long> userIds = StreamUtils.toList(copyList, Convert::toLong);
Map<Long, String> nickNameMap = userService.selectUserNicksByIds(userIds);
List<FlowCopyBo> list = StreamUtils.toList(copyList, x -> {
FlowCopyBo bo = new FlowCopyBo();
Long id = Convert.toLong(x);
bo.setUserId(id);
bo.setNickName(nickNameMap.getOrDefault(id, StringUtils.EMPTY));
return bo;
});
// 节点配置仅作为默认值,保留本次办理显式提交的抄送人或空名单。
variable.putIfAbsent(FlowConstant.FLOW_COPY_LIST, list);
}
if (CollUtil.isNotEmpty(nodeExt.getVariables())) {
variable.putAll(nodeExt.getVariables());
}
@ -110,20 +91,20 @@ public class WorkflowGlobalListener implements GlobalListener {
public void assignment(ListenerVariable listenerVariable) {
Map<String, Object> variable = listenerVariable.getVariable();
List<Task> nextTasks = listenerVariable.getNextTasks();
FlowParams flowParams = listenerVariable.getFlowParams();
WorkflowContext workflowContext = listenerVariable.getContext();
Definition definition = listenerVariable.getDefinition();
Instance instance = listenerVariable.getInstance();
String applyNodeCode = flwCommonService.applyNodeCode(definition.getId());
String hisStatus = flowParams != null ? flowParams.getHisStatus() : null;
String taskStatus = workflowContext != null ? workflowContext.getTaskStatus() : null;
for (Task flowTask : nextTasks) {
String nodeCode = flowTask.getNodeCode();
// 处理办理或退回时指定办理人的情况
if (TaskStatusEnum.PASS.getStatus().equals(hisStatus)) {
processTaskPermission(variable, flowTask, hisStatus);
} else if (TaskStatusEnum.BACK.getStatus().equals(hisStatus)) {
processTaskPermission(variable, flowTask, hisStatus);
if (TaskStatus.PASS.getKey().equals(taskStatus)) {
processTaskPermission(variable, flowTask, taskStatus);
} else if (TaskStatus.BACK.getKey().equals(taskStatus)) {
processTaskPermission(variable, flowTask, taskStatus);
}
// 如果是申请节点,则把启动人添加到办理人
@ -163,7 +144,7 @@ public class WorkflowGlobalListener implements GlobalListener {
List<String> userIdList = StringUtils.str2List(userIds, StringUtils.SEPARATOR, true, true);
if (CollUtil.isNotEmpty(userIdList)) {
flowTask.setPermissionList(userIdList);
if (TaskStatusEnum.PASS.getStatus().equals(taskStatus)) {
if (TaskStatus.PASS.getKey().equals(taskStatus)) {
// 办理指定人变量只消费一次;驳回指定人变量需要保留给后续重复驳回。
variable.remove(nodeKey);
FlowEngine.insService().removeVariables(flowTask.getInstanceId(), nodeKey);
@ -183,16 +164,16 @@ public class WorkflowGlobalListener implements GlobalListener {
Task task = listenerVariable.getTask();
List<Task> nextTasks = listenerVariable.getNextTasks();
Map<String, Object> params = new HashMap<>();
FlowParams flowParams = listenerVariable.getFlowParams();
WorkflowContext workflowContext = listenerVariable.getContext();
Map<String, Object> variable = new HashMap<>();
if (ObjectUtil.isNotNull(flowParams)) {
if (ObjectUtil.isNotNull(workflowContext)) {
// 历史任务扩展(通常为附件)
params.put("hisTaskExt", flowParams.getHisTaskExt());
params.put("hisTaskExt", workflowContext.getExt());
// 办理人
params.put("handler", flowParams.getHandler());
params.put("handler", workflowContext.getHandler());
// 办理意见
params.put("message", flowParams.getMessage());
variable = flowParams.getVariable();
params.put("message", workflowContext.getMessage());
variable = workflowContext.getVariables();
}
//申请人提交事件
Boolean submit = MapUtil.getBool(variable, FlowConstant.SUBMIT);
@ -224,11 +205,11 @@ public class WorkflowGlobalListener implements GlobalListener {
flowProcessEventHandler.processTaskHandler(definition.getFlowCode(), instance, nextTask, params);
}
}
if (ObjectUtil.isNull(flowParams)) {
if (ObjectUtil.isNull(workflowContext)) {
return;
}
// 只有办理或者退回的时候才执行消息通知和抄送
if (!TaskStatusEnum.isPassOrBack(flowParams.getHisStatus())) {
if (!isPassOrBack(workflowContext.getTaskStatus())) {
return;
}
if (ObjectUtil.isNull(variable)) {
@ -246,7 +227,7 @@ public class WorkflowGlobalListener implements GlobalListener {
});
String notice = MapUtil.getStr(variable, FlowConstant.MESSAGE_NOTICE);
// 退回到申请人时只保留“已退回”结果消息,避免再追加一条“新的待办”形成重复提醒。
if (shouldSendTaskMessage(flowParams, definition, nextTasks)) {
if (shouldSendTaskMessage(workflowContext, definition, nextTasks)) {
SpringUtils.context().publishEvent(new WorkflowTaskMessageEvent(definition.getFlowName(), instance.getId(), messageType, notice));
}
}
@ -261,13 +242,13 @@ public class WorkflowGlobalListener implements GlobalListener {
/**
* 判断是否需要发送后续待办消息。
*
* @param flowParams 流程参数
* @param workflowContext 流程执行上下文
* @param definition 流程定义
* @param nextTasks 后续任务列表
* @return 是否发送待办消息
*/
private boolean shouldSendTaskMessage(FlowParams flowParams, Definition definition, List<Task> nextTasks) {
if (flowParams == null || !TaskStatusEnum.BACK.getStatus().equals(flowParams.getHisStatus())) {
private boolean shouldSendTaskMessage(WorkflowContext workflowContext, Definition definition, List<Task> nextTasks) {
if (workflowContext == null || !TaskStatus.BACK.getKey().equals(workflowContext.getTaskStatus())) {
return true;
}
if (CollUtil.isEmpty(nextTasks) || nextTasks.size() != 1) {
@ -302,6 +283,16 @@ public class WorkflowGlobalListener implements GlobalListener {
SpringUtils.context().publishEvent(new WorkflowResultMessageEvent(definition.getFlowName(), status, instance.getCreateBy(), messageType));
}
/**
* 任务收口是否为「通过」或「退回」,只有这两种结果才触发消息通知与抄送。
*
* @param taskStatus 任务状态编码
* @return 是否为通过或退回
*/
private boolean isPassOrBack(String taskStatus) {
return TaskStatus.PASS.getKey().equals(taskStatus) || TaskStatus.BACK.getKey().equals(taskStatus);
}
/**
* 根据流程实例确定最终状态
*

View File

@ -1,95 +0,0 @@
package org.dromara.workflow.liteflow.complete;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.yomahub.liteflow.annotation.LiteflowComponent;
import com.yomahub.liteflow.core.NodeComponent;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.query.QueryBuilder;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.entity.User;
import org.dromara.warm.flow.core.service.TaskService;
import org.dromara.warm.flow.orm.entity.FlowTask;
import org.dromara.warm.flow.orm.mapper.FlowTaskMapper;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.domain.context.CompleteTaskContext;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static org.dromara.workflow.common.constant.FlowConstant.*;
/**
* 执行当前处理人的后续自动审批。
* <p>
* 仅当流程实例开启自动审批时生效,用于连续跳过同一登录人负责的后续待办任务。
*
* @author may
*/
@ConditionalOnEnable
@RequiredArgsConstructor
@LiteflowComponent("completeAutoPass")
public class CompleteAutoPassComponent extends NodeComponent {
private final TaskService taskService;
private final FlowTaskMapper flowTaskMapper;
@Override
public void process() {
CompleteTaskContext context = getContextBean(CompleteTaskContext.class);
FlowTask flowTask = context.getFlowTask();
autoPass(flowTask.getId(), context.getFlowParams(), flowTask.getInstanceId());
}
/**
* 递归跳过当前处理人的后续任务。
*
* @param taskId 本轮已处理任务 id,用于避免重复跳过同一任务
* @param flowParams 当前办理参数,会在自动审批时覆盖为自动审批消息和空抄送变量
* @param instanceId 流程实例 id
*/
private void autoPass(Long taskId, FlowParams flowParams, Long instanceId) {
List<FlowTask> flowTaskList = selectByInstId(instanceId);
if (CollUtil.isEmpty(flowTaskList)) {
return;
}
List<User> userList = FlowEngine.userService()
.getByAssociateds(StreamUtils.toList(flowTaskList, FlowTask::getId));
if (CollUtil.isEmpty(userList)) {
return;
}
for (FlowTask task : flowTaskList) {
if (task.getId().equals(taskId)) {
continue;
}
// 自动审批只处理当前登录人仍是办理人的后续任务,避免替其他候选人或并行分支误审批。
List<User> users = StreamUtils.filter(userList,
e -> ObjectUtil.equals(task.getId(), e.getAssociated()) && ObjectUtil.equal(e.getProcessedBy(), LoginHelper.getUserIdStr()));
if (CollUtil.isEmpty(users)) {
continue;
}
// 自动审批不继承人工办理时的抄送和消息通知,避免重复发送业务消息。
flowParams
.message("流程引擎自动审批!")
.variable(Map.of(
SUBMIT, false,
FLOW_COPY_LIST, Collections.emptyList(),
MESSAGE_NOTICE, StringUtils.EMPTY));
taskService.skip(task.getId(), flowParams);
// 跳过后可能生成新的后续任务,继续扫描直到当前登录人不再有可自动办理任务。
autoPass(task.getId(), flowParams, instanceId);
}
}
private List<FlowTask> selectByInstId(Long instanceId) {
return flowTaskMapper.selectList(QueryBuilder.lambda(FlowTask.class)
.eq(FlowTask::getInstanceId, instanceId)
.build());
}
}

View File

@ -4,12 +4,11 @@ import cn.hutool.core.convert.Convert;
import com.yomahub.liteflow.annotation.LiteflowComponent;
import com.yomahub.liteflow.core.NodeComponent;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.enums.BusinessStatusEnum;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.enums.SkipType;
import org.dromara.warm.flow.core.service.TaskService;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.enums.TaskStatus;
import org.dromara.warm.flow.core.workflow.command.CompleteCommand;
import org.dromara.warm.flow.core.workflow.context.OperatorContext;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.TaskStatusEnum;
import org.dromara.workflow.domain.bo.CompleteTaskBo;
import org.dromara.workflow.domain.context.CompleteTaskContext;
@ -27,28 +26,23 @@ import static org.dromara.workflow.common.constant.FlowConstant.*;
@LiteflowComponent("completeExecute")
public class CompleteExecuteComponent extends NodeComponent {
private final TaskService taskService;
@Override
public void process() {
CompleteTaskContext context = getContextBean(CompleteTaskContext.class);
CompleteTaskBo completeTaskBo = context.getCompleteTaskBo();
Map<String, Object> variables = completeTaskBo.getVariables();
FlowParams flowParams = FlowParams.build()
.handler(completeTaskBo.getHandler())
.variable(variables)
.ignore(Convert.toBool(variables.getOrDefault(VAR_IGNORE, false)))
.ignoreDepute(Convert.toBool(variables.getOrDefault(VAR_IGNORE_DEPUTE, false)))
.ignoreCooperate(Convert.toBool(variables.getOrDefault(VAR_IGNORE_COOPERATE, false)))
.skipType(SkipType.PASS.getKey())
.message(completeTaskBo.getMessage())
.flowStatus(BusinessStatusEnum.WAITING.getStatus())
.hisStatus(TaskStatusEnum.PASS.getStatus())
.hisTaskExt(completeTaskBo.getFileId());
context.setFlowParams(flowParams);
context.setAutoPass(Convert.toBool(context.getInstance().getVariableMap().getOrDefault(AUTO_PASS, false)));
taskService.skip(context.getFlowTask().getId(), flowParams);
CompleteCommand command = new CompleteCommand();
OperatorContext operator = new OperatorContext(completeTaskBo.getHandler());
operator.setIgnore(Convert.toBool(variables.getOrDefault(VAR_IGNORE, false)));
command.setOperator(operator);
command.setTaskId(context.getFlowTask().getId());
command.setVariables(variables);
command.setMessage(completeTaskBo.getMessage());
command.setExt(completeTaskBo.getFileId());
// 全局监听器用 context.taskStatus 判断「通过/退回」,才决定要不要抄送与发通知;
// 引擎不会把解析后的状态回填进调用上下文,办理路径不显式声明 PASS 就会静默跳过抄送。
command.setTaskStatus(TaskStatus.PASS.getKey());
FlowEngine.workflow().complete(command);
}
}

View File

@ -1,23 +0,0 @@
package org.dromara.workflow.liteflow.complete;
import com.yomahub.liteflow.annotation.LiteflowComponent;
import com.yomahub.liteflow.core.NodeBooleanComponent;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.domain.context.CompleteTaskContext;
/**
* 判断办理任务后是否进入自动审批分支。
*
* @author may
*/
@ConditionalOnEnable
@LiteflowComponent("completeNeedAutoPass")
public class CompleteNeedAutoPassComponent extends NodeBooleanComponent {
@Override
public boolean processBoolean() {
CompleteTaskContext context = getContextBean(CompleteTaskContext.class);
return Boolean.TRUE.equals(context.getAutoPass());
}
}

View File

@ -9,11 +9,11 @@ import org.dromara.common.core.enums.BusinessStatusEnum;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.warm.flow.core.enums.TaskStatus;
import org.dromara.warm.flow.core.service.InsService;
import org.dromara.warm.flow.orm.entity.FlowTask;
import org.dromara.warm.flow.orm.mapper.FlowTaskMapper;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.TaskStatusEnum;
import org.dromara.workflow.domain.bo.CompleteTaskBo;
import org.dromara.workflow.domain.context.CompleteTaskContext;
@ -86,12 +86,12 @@ public class CompletePrepareComponent extends NodeComponent {
hashSet.addAll(popUserIds);
hashSet.addAll(variableUserIds);
// Warm-Flow 会按“状态:变量名”读取办理人,正向和退回都写入同一批候选人。
map.put(TaskStatusEnum.PASS.getStatus() + StringUtils.COLON + entry.getKey(), StringUtils.joinComma(hashSet));
map.put(TaskStatusEnum.BACK.getStatus() + StringUtils.COLON + entry.getKey(), StringUtils.joinComma(hashSet));
map.put(TaskStatus.PASS.getKey() + StringUtils.COLON + entry.getKey(), StringUtils.joinComma(hashSet));
map.put(TaskStatus.BACK.getKey() + StringUtils.COLON + entry.getKey(), StringUtils.joinComma(hashSet));
}
} else {
map.put(TaskStatusEnum.PASS.getStatus() + StringUtils.COLON + entry.getKey(), entry.getValue());
map.put(TaskStatusEnum.BACK.getStatus() + StringUtils.COLON + entry.getKey(), entry.getValue());
map.put(TaskStatus.PASS.getKey() + StringUtils.COLON + entry.getKey(), entry.getValue());
map.put(TaskStatus.BACK.getKey() + StringUtils.COLON + entry.getKey(), entry.getValue());
}
}
return map;

View File

@ -7,13 +7,13 @@ import lombok.RequiredArgsConstructor;
import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.mybatis.core.query.QueryBuilder;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.enums.TaskStatus;
import org.dromara.warm.flow.core.service.InsService;
import org.dromara.warm.flow.orm.entity.FlowHisTask;
import org.dromara.warm.flow.orm.entity.FlowTask;
import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper;
import org.dromara.warm.flow.orm.mapper.FlowTaskMapper;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.TaskStatusEnum;
import org.dromara.workflow.domain.context.InstanceDeleteContext;
import java.util.List;
@ -56,23 +56,27 @@ public class InstanceDeleteExecuteComponent extends NodeComponent {
/**
* 删除流程实例产生的抄送人员关联。
*
* <p>抄送人挂在<b>抄送历史主键</b>上(与引擎 {@code CopyAdvancer} 一致),不是待办主键。
* 只有本组件的历史分支需要显式清理——非历史分支走 {@code insService.remove},
* 引擎级联里已按历史主键清过一遍。</p>
*
* @param instanceIds 流程实例 ID 集合
*/
private void deleteCopyUsers(List<Long> instanceIds) {
List<FlowHisTask> copyTasks = flowHisTaskMapper.selectList(QueryBuilder.lambda(FlowHisTask.class)
.in(FlowHisTask::getInstanceId, instanceIds)
.eq(FlowHisTask::getFlowStatus, TaskStatusEnum.COPY.getStatus())
.eq(FlowHisTask::getFlowStatus, TaskStatus.COPY.getKey())
.build());
if (CollUtil.isEmpty(copyTasks)) {
return;
}
List<Long> copyTaskIds = copyTasks.stream()
.map(FlowHisTask::getTaskId)
List<Long> copyHisIds = copyTasks.stream()
.map(FlowHisTask::getId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (CollUtil.isNotEmpty(copyTaskIds)) {
FlowEngine.userService().deleteByTaskIds(copyTaskIds);
if (CollUtil.isNotEmpty(copyHisIds)) {
FlowEngine.userService().deleteByTaskIds(copyHisIds);
}
}

View File

@ -3,9 +3,13 @@ package org.dromara.workflow.liteflow.operation;
import com.yomahub.liteflow.annotation.LiteflowComponent;
import com.yomahub.liteflow.core.NodeComponent;
import lombok.RequiredArgsConstructor;
import org.dromara.warm.flow.core.service.TaskService;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.workflow.command.AddSignerCommand;
import org.dromara.warm.flow.core.workflow.command.DelegateCommand;
import org.dromara.warm.flow.core.workflow.command.RemoveSignerCommand;
import org.dromara.warm.flow.core.workflow.command.TransferCommand;
import org.dromara.warm.flow.core.workflow.context.OperatorContext;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.TaskStatusEnum;
import org.dromara.workflow.domain.context.TaskOperationContext;
/**
@ -18,29 +22,45 @@ import org.dromara.workflow.domain.context.TaskOperationContext;
@LiteflowComponent("taskOpExecute")
public class TaskOpExecuteComponent extends NodeComponent {
private final TaskService taskService;
@Override
public void process() {
TaskOperationContext context = getContextBean(TaskOperationContext.class);
Long taskId = context.getTaskOperationBo().getTaskId();
boolean result = false;
OperatorContext operator = context.getOperator();
String message = context.getMessage();
switch (context.getOperation()) {
case DELEGATE_TASK -> {
context.getFlowParams().hisStatus(TaskStatusEnum.DEPUTE.getStatus());
result = taskService.depute(taskId, context.getFlowParams());
DelegateCommand command = new DelegateCommand();
command.setOperator(operator);
command.setTaskId(taskId);
command.setTargetHandler(context.getTaskOperationBo().getUserId());
command.setMessage(message);
result = FlowEngine.workflow().delegate(command).isSuccess();
}
case TRANSFER_TASK -> {
context.getFlowParams().hisStatus(TaskStatusEnum.TRANSFER.getStatus());
result = taskService.transfer(taskId, context.getFlowParams());
TransferCommand command = new TransferCommand();
command.setOperator(operator);
command.setTaskId(taskId);
command.setTargetHandler(context.getTaskOperationBo().getUserId());
command.setMessage(message);
result = FlowEngine.workflow().transfer(command).isSuccess();
}
case ADD_SIGNATURE -> {
context.getFlowParams().hisStatus(TaskStatusEnum.SIGN.getStatus());
result = taskService.addSignature(taskId, context.getFlowParams());
AddSignerCommand command = new AddSignerCommand();
command.setOperator(operator);
command.setTaskId(taskId);
command.setTargetHandlers(context.getTaskOperationBo().getUserIds());
command.setMessage(message);
result = FlowEngine.workflow().addSigner(command).isSuccess();
}
case REDUCTION_SIGNATURE -> {
context.getFlowParams().hisStatus(TaskStatusEnum.SIGN_OFF.getStatus());
result = taskService.reductionSignature(taskId, context.getFlowParams());
RemoveSignerCommand command = new RemoveSignerCommand();
command.setOperator(operator);
command.setTaskId(taskId);
command.setTargetHandlers(context.getTaskOperationBo().getUserIds());
command.setMessage(message);
result = FlowEngine.workflow().removeSigner(command).isSuccess();
}
}
context.setResult(result);

View File

@ -8,14 +8,12 @@ import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.workflow.context.OperatorContext;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.TaskOperationEnum;
import org.dromara.workflow.domain.bo.TaskOperationBo;
import org.dromara.workflow.domain.context.TaskOperationContext;
import java.util.Collections;
/**
* 准备任务操作参数。
*
@ -42,16 +40,10 @@ public class TaskOpPrepareComponent extends NodeComponent {
case ADD_SIGNATURE, REDUCTION_SIGNATURE -> ValidatorUtils.validate(bo, EditGroup.class);
}
FlowParams flowParams = FlowParams.build().message(bo.getMessage());
if (LoginHelper.isSuperAdmin()) {
flowParams.ignore(true);
}
switch (op) {
case DELEGATE_TASK, TRANSFER_TASK -> flowParams.addHandlers(Collections.singletonList(bo.getUserId()));
case ADD_SIGNATURE -> flowParams.addHandlers(bo.getUserIds());
case REDUCTION_SIGNATURE -> flowParams.reductionHandlers(bo.getUserIds());
}
context.setFlowParams(flowParams);
OperatorContext operator = new OperatorContext(LoginHelper.getUserIdStr());
operator.setIgnore(LoginHelper.isSuperAdmin());
context.setOperator(operator);
context.setMessage(bo.getMessage());
}
}

View File

@ -6,11 +6,14 @@ import com.yomahub.liteflow.core.NodeComponent;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.enums.BusinessStatusEnum;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.warm.flow.core.entity.Task;
import org.dromara.warm.flow.core.service.InsService;
import org.dromara.warm.flow.core.enums.TaskStatus;
import org.dromara.warm.flow.core.service.TaskService;
import org.dromara.warm.flow.core.workflow.command.StartCommand;
import org.dromara.warm.flow.core.workflow.context.OperatorContext;
import org.dromara.warm.flow.core.workflow.result.WorkflowResult;
import org.dromara.warm.flow.orm.entity.FlowTask;
import org.dromara.workflow.api.domain.StartProcessReturnDTO;
import org.dromara.workflow.common.ConditionalOnEnable;
@ -30,19 +33,21 @@ import java.util.List;
@LiteflowComponent("startExecute")
public class StartExecuteComponent extends NodeComponent {
private final InsService insService;
private final TaskService taskService;
private final FlwInstanceBizExtMapper flwInstanceBizExtMapper;
@Override
public void process() {
StartProcessContext context = getContextBean(StartProcessContext.class);
FlowParams flowParams = FlowParams.build()
.handler(context.getStartProcessBo().getHandler())
.flowCode(context.getStartProcessBo().getFlowCode())
.variable(context.getVariables())
.flowStatus(BusinessStatusEnum.DRAFT.getStatus());
Instance instance = insService.start(context.getBusinessId(), flowParams);
StartCommand command = new StartCommand();
command.setBusinessId(context.getBusinessId());
command.setFlowCode(context.getStartProcessBo().getFlowCode());
command.setOperator(new OperatorContext(context.getStartProcessBo().getHandler()));
command.setVariables(context.getVariables());
command.setFlowStatus(BusinessStatusEnum.DRAFT.getStatus());
command.setTaskStatus(TaskStatus.PASS.getKey());
WorkflowResult result = FlowEngine.workflow().start(command);
Instance instance = FlowEngine.insService().getById(result.getInstanceId());
context.setInstance(instance);
saveBizExt(context, instance);

View File

@ -1,12 +1,10 @@
package org.dromara.workflow.liteflow.start;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.ObjectUtil;
import com.yomahub.liteflow.annotation.LiteflowComponent;
import com.yomahub.liteflow.core.NodeComponent;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.entity.Definition;
import org.dromara.workflow.common.ConditionalOnEnable;
@ -36,9 +34,6 @@ public class StartPrepareInstanceComponent extends NodeComponent {
context.setDefinition(definition);
Map<String, Object> variables = context.getVariables();
Dict dict = JsonUtils.parseMap(definition.getExt());
boolean autoPass = !ObjectUtil.isNull(dict) && dict.getBool(AUTO_PASS);
variables.put(AUTO_PASS, autoPass);
variables.put(BUSINESS_CODE, generateBusinessCode(context.getBizExt()));
}

View File

@ -5,10 +5,10 @@ import com.github.yulichang.base.MPJBaseMapper;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import org.dromara.common.mybatis.core.query.QueryBuilder;
import org.dromara.warm.flow.core.enums.NodeType;
import org.dromara.warm.flow.core.enums.TaskStatus;
import org.dromara.warm.flow.orm.entity.FlowDefinition;
import org.dromara.warm.flow.orm.entity.FlowHisTask;
import org.dromara.warm.flow.orm.entity.FlowInstance;
import org.dromara.workflow.common.enums.TaskStatusEnum;
import org.dromara.workflow.domain.FlowInstanceBizExt;
import org.dromara.workflow.domain.bo.FlowTaskBo;
import org.dromara.workflow.domain.vo.FlowHisTaskVo;
@ -51,7 +51,7 @@ public interface FlwHisTaskMapper extends BaseMapperPlus<FlowHisTask, FlowHisTas
.leftJoin(FlowDefinition.class, "c", FlowDefinition::getId, FlowHisTask::getDefinitionId)
.leftJoin(FlowInstanceBizExt.class, "biz", FlowInstanceBizExt::getInstanceId, FlowInstance::getId)
.in("a", FlowHisTask::getNodeType, List.of(1, 3, 4))
.ne("a", FlowHisTask::getFlowStatus, TaskStatusEnum.COPY.getStatus())
.ne("a", FlowHisTask::getFlowStatus, TaskStatus.COPY.getKey())
.likeIfText("a", FlowHisTask::getNodeName, bo.getNodeName())
.likeIfText("c", FlowDefinition::getFlowName, bo.getFlowName())
.likeIfText("c", FlowDefinition::getFlowCode, bo.getFlowCode())

View File

@ -82,7 +82,7 @@ public interface FlwTaskMapper extends BaseMapperPlus<FlowTask, FlowTaskVo>, MPJ
.select("d", FlowDefinition::getFlowName, FlowDefinition::getFlowCode, FlowDefinition::getCategory,
FlowDefinition::getVersion)
.select("biz", FlowInstanceBizExt::getBusinessCode, FlowInstanceBizExt::getBusinessTitle)
.leftJoin(FlowHisTask.class, "b", FlowHisTask::getTaskId, FlowUser::getAssociated)
.leftJoin(FlowHisTask.class, "b", FlowHisTask::getId, FlowUser::getAssociated)
.leftJoin(FlowInstance.class, "c", FlowInstance::getId, FlowHisTask::getInstanceId)
.leftJoin(FlowDefinition.class, "d", FlowDefinition::getId, FlowInstance::getDefinitionId)
.leftJoin(FlowInstanceBizExt.class, "biz", FlowInstanceBizExt::getInstanceId, FlowInstance::getId)

View File

@ -0,0 +1,60 @@
package org.dromara.workflow.service;
import org.dromara.common.core.domain.PageResult;
import org.dromara.workflow.domain.bo.FlowEventWaitBo;
import org.dromara.workflow.domain.bo.FlowTimerTriggerBo;
import org.dromara.workflow.domain.vo.FlowEventWaitVo;
import java.util.Map;
/**
* 定时任务(事件等待记录)服务接口。
* <p>数据来自引擎 flow_event_wait,提供查询、状态统计、已死记录复活与定时启动任务的手动补发。
* 手动补发只创建一次实例,不读取也不推进等待记录,定时任务的下次触发时间不受影响。</p>
*
* @author may
* @since 2026/10/21
*/
public interface IFlwEventWaitService {
/**
* 分页查询事件等待记录,默认只查定时类事件、按下次触发时间升序。
*
* @param bo 查询条件
* @return 等待记录分页数据
*/
PageResult<FlowEventWaitVo> queryPageList(FlowEventWaitBo bo);
/**
* 按同一组筛选条件统计各状态等待记录数量。
*
* @param bo 查询条件(其中状态条件被逐一分组覆盖)
* @return 各状态计数,键为 waiting / retry / running / dead / cancelled
*/
Map<String, Long> summary(FlowEventWaitBo bo);
/**
* 查询单条等待记录详情。
*
* @param id 事件等待记录主键
* @return 等待记录详情,不存在时返回 {@code null}
*/
FlowEventWaitVo queryById(Long id);
/**
* 手动补发一次定时启动任务(运维应急)。
* <p>实例发起人记为当前登录用户;等待记录保持不变,「每天 9 点的任务今天提前补发一次」
* 不会吞掉当天 9 点的正常触发。</p>
*
* @param bo 补发参数(等待记录主键、可选业务主键与变量)
* @return 新建流程实例主键
*/
Long manualTrigger(FlowTimerTriggerBo bo);
/**
* 复活已死的事件等待记录,状态与实例有效性由引擎校验。
*
* @param id 事件等待记录主键
*/
void revive(Long id);
}

View File

@ -119,6 +119,15 @@ public interface IFlwInstanceService {
*/
Map<String, Object> flowHisTaskList(String businessId);
/**
* 按实例 ID、业务 ID 查询流程图及历史办理记录;同时提供时须指向同一实例。
*
* @param instanceId 流程实例 ID,可为空
* @param businessId 业务 ID,可为空
* @return 流程图与办理记录
*/
Map<String, Object> flowHisTaskList(Long instanceId, String businessId);
/**
* 按照实例id更新状态
*

View File

@ -1,24 +0,0 @@
package org.dromara.workflow.service;
import org.dromara.system.api.domain.UserDTO;
import java.util.List;
/**
* 流程设计器-获取办理人
*
* @author AprilWind
*/
public interface IFlwTaskAssigneeService {
/**
* 批量解析多个存储标识符(storageIds),按类型分类并合并查询用户列表
* 输入格式支持多个以逗号分隔的标识(如 "user:123,role:456,789")
* 会自动去重返回结果,非法格式的标识将被忽略
*
* @param storageIds 多个存储标识符字符串(逗号分隔)
* @return 合并后的用户列表,去重后返回,非法格式的标识将被跳过
*/
List<UserDTO> fetchUsersByStorageIds(String storageIds);
}

View File

@ -20,10 +20,15 @@ import org.dromara.warm.flow.core.dto.PromptContent;
import org.dromara.warm.flow.core.enums.NodeType;
import org.dromara.warm.flow.core.utils.MapUtil;
import org.dromara.warm.flow.orm.entity.FlowHisTask;
import org.dromara.warm.flow.orm.entity.FlowTask;
import org.dromara.warm.flow.orm.entity.FlowUser;
import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper;
import org.dromara.warm.flow.orm.mapper.FlowTaskMapper;
import org.dromara.warm.flow.orm.mapper.FlowUserMapper;
import org.dromara.warm.flow.ui.service.ChartExtService;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.constant.FlowConstant;
import org.dromara.workflow.common.enums.TaskAssigneeType;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@ -45,6 +50,8 @@ public class FlwChartExtServiceImpl implements ChartExtService {
private final UserService userService;
private final DeptService deptService;
private final FlowHisTaskMapper flowHisTaskMapper;
private final FlowTaskMapper flowTaskMapper;
private final FlowUserMapper flowUserMapper;
private final DictService dictService;
@Value("${warm-flow.node-tooltip:true}")
private boolean nodeTooltip;
@ -61,45 +68,80 @@ public class FlwChartExtServiceImpl implements ChartExtService {
return;
}
// 根据流程实例ID查询所有相关的历史任务列表
List<FlowHisTask> flowHisTasks = this.getHisTaskGroupedByNode(defJson.getInstance().getId());
if (CollUtil.isEmpty(flowHisTasks)) {
Long instanceId = defJson.getInstance().getId();
List<FlowHisTask> flowHisTasks = this.getHisTaskGroupedByNode(instanceId);
List<FlowTask> activeTasks = flowTaskMapper.selectList(Wrappers.<FlowTask>lambdaQuery()
.eq(FlowTask::getInstanceId, instanceId)
.eq(FlowTask::getNodeType, NodeType.BETWEEN.getKey()));
if (CollUtil.isEmpty(flowHisTasks) && CollUtil.isEmpty(activeTasks)) {
return;
}
// 按节点编号(nodeCode)对历史任务进行分组
Map<String, List<FlowHisTask>> groupedByNode = StreamUtils.groupByKey(flowHisTasks, FlowHisTask::getNodeCode);
Map<String, List<FlowHisTask>> groupedByNode = CollUtil.isEmpty(flowHisTasks)
? Collections.emptyMap() : StreamUtils.groupByKey(flowHisTasks, FlowHisTask::getNodeCode);
Map<Long, String> taskNodeCodes = activeTasks.stream()
.collect(Collectors.toMap(FlowTask::getId, FlowTask::getNodeCode));
List<FlowUser> activeUsers = taskNodeCodes.isEmpty() ? Collections.emptyList()
: flowUserMapper.selectList(Wrappers.<FlowUser>lambdaQuery()
.in(FlowUser::getAssociated, taskNodeCodes.keySet())
.in(FlowUser::getType, List.of(TaskAssigneeType.APPROVER.getCode(),
TaskAssigneeType.TRANSFER.getCode(), TaskAssigneeType.DELEGATE.getCode())));
Map<String, Set<Long>> pendingUsersByNode = new HashMap<>();
for (FlowUser activeUser : activeUsers) {
Long userId = Convert.toLong(activeUser.getProcessedBy(), null);
String nodeCode = taskNodeCodes.get(activeUser.getAssociated());
if (userId != null && nodeCode != null) {
pendingUsersByNode.computeIfAbsent(nodeCode, key -> new LinkedHashSet<>()).add(userId);
}
}
// 批量查询所有审批人的用户信息
List<UserDTO> userDTOList = userService.selectListByIds(StreamUtils.toSet(flowHisTasks, e -> Convert.toLong(e.getApprover())));
Set<Long> userIds = new HashSet<>();
flowHisTasks.forEach(task -> {
Long approverId = Convert.toLong(task.getApprover(), null);
if (approverId != null) {
userIds.add(approverId);
}
});
pendingUsersByNode.values().forEach(userIds::addAll);
if (userIds.isEmpty()) {
return;
}
List<UserDTO> userDTOList = userService.selectListByIds(userIds);
// 将查询到的用户列表转换为以用户ID为key的映射
Map<Long, UserDTO> userMap = StreamUtils.toIdentityMap(userDTOList, UserDTO::getUserId);
Set<Long> deptIds = StreamUtils.toSet(userDTOList, UserDTO::getDeptId);
Map<Long, String> deptNameMap = deptService.selectDeptNamesByIds(deptIds);
deptIds.remove(null);
Map<Long, String> deptNameMap = deptIds.isEmpty() ? Collections.emptyMap()
: deptService.selectDeptNamesByIds(deptIds);
Map<String, String> dictType = dictService.getAllDictByDictType(FlowConstant.WF_TASK_STATUS);
Map<String, String> dictType = flowHisTasks.isEmpty() ? Collections.emptyMap()
: dictService.getAllDictByDictType(FlowConstant.WF_TASK_STATUS);
for (NodeJson nodeJson : defJson.getNodeList()) {
List<FlowHisTask> taskList = groupedByNode.get(nodeJson.getNodeCode());
if (CollUtil.isEmpty(taskList)) {
continue;
if (CollUtil.isNotEmpty(taskList)) {
List<FlowHisTask> latestPerApprover = taskList.stream()
.filter(task -> StringUtils.isNotBlank(task.getApprover()))
.collect(Collectors.collectingAndThen(
Collectors.toMap(
FlowHisTask::getApprover,
Function.identity(),
(oldTask, newTask) -> newTask.getUpdateTime().after(oldTask.getUpdateTime()) ? newTask : oldTask,
LinkedHashMap::new
),
map -> new ArrayList<>(map.values())
));
this.processNodeExtInfo(nodeJson, latestPerApprover, userMap, deptNameMap, dictType);
}
for (Long userId : pendingUsersByNode.getOrDefault(nodeJson.getNodeCode(), Collections.emptySet())) {
UserDTO user = userMap.get(userId);
if (user != null) {
addUserInfo(nodeJson.getPromptContent().getInfo(), user,
deptNameMap.getOrDefault(user.getDeptId(), StringUtils.EMPTY));
nodeJson.getPromptContent().getInfo().add(buildInfoItem("审批状态", "待审批"));
}
}
// 按审批人分组去重,保留最新处理记录,最终转换成 List
List<FlowHisTask> latestPerApprover = taskList.stream()
.collect(Collectors.collectingAndThen(
Collectors.toMap(
FlowHisTask::getApprover,
Function.identity(),
(oldTask, newTask) -> newTask.getUpdateTime().after(oldTask.getUpdateTime()) ? newTask : oldTask,
LinkedHashMap::new
),
map -> new ArrayList<>(map.values())
));
// 处理当前节点的扩展信息
this.processNodeExtInfo(nodeJson, latestPerApprover, userMap, deptNameMap, dictType);
}
}
@ -183,10 +225,16 @@ public class FlwChartExtServiceImpl implements ChartExtService {
continue;
}
String deptName = deptNameMap.getOrDefault(userDTO.getDeptId(), StringUtils.EMPTY);
addUserInfo(info, userDTO, deptNameMap.getOrDefault(userDTO.getDeptId(), StringUtils.EMPTY));
// 添加标题项,如:👤 张三(市场部)
info.add(new PromptContent.InfoItem()
info.add(buildInfoItem("审批状态", dictType.get(task.getFlowStatus())));
info.add(buildInfoItem("审批耗时", DateUtils.formatBetweenBySecond(task.getUpdateTime(), task.getCreateTime())));
info.add(buildInfoItem("办理时间", DateUtils.formatDateTime(task.getUpdateTime())));
}
}
private void addUserInfo(List<PromptContent.InfoItem> info, UserDTO userDTO, String deptName) {
info.add(new PromptContent.InfoItem()
.setPrefix(StringUtils.format("👥 {}({})", userDTO.getNickName(), deptName))
.setPrefixStyle(Map.of(
"fontWeight", "bold",
@ -199,12 +247,7 @@ public class FlwChartExtServiceImpl implements ChartExtService {
))
);
// 添加具体信息项:账号、耗时、时间
info.add(buildInfoItem("用户账号", userDTO.getUserName()));
info.add(buildInfoItem("审批状态", dictType.get(task.getFlowStatus())));
info.add(buildInfoItem("审批耗时", DateUtils.formatBetweenBySecond(task.getUpdateTime(), task.getCreateTime())));
info.add(buildInfoItem("办理时间", DateUtils.formatDateTime(task.getUpdateTime())));
}
info.add(buildInfoItem("用户账号", userDTO.getUserName()));
}
/**

View File

@ -126,7 +126,7 @@ public class FlwDefinitionServiceImpl implements IFlwDefinitionService {
if (CollUtil.isNotEmpty(flowNodes)) {
String applyNodeCode = flwCommonService.applyNodeCode(id);
for (FlowNode flowNode : flowNodes) {
if (StringUtils.isBlank(flowNode.getPermissionFlag()) && !applyNodeCode.equals(flowNode.getNodeCode()) && NodeType.BETWEEN.getKey().equals(flowNode.getNodeType())) {
if (requiresAssignee(flowNode, applyNodeCode) && StringUtils.isBlank(flowNode.getPermissionFlag())) {
errorMsg.add(flowNode.getNodeName());
}
}
@ -137,6 +137,12 @@ public class FlwDefinitionServiceImpl implements IFlwDefinitionService {
return defService.publish(id);
}
static boolean requiresAssignee(FlowNode node, String applyNodeCode) {
return NodeType.BETWEEN.getKey().equals(node.getNodeType())
&& !applyNodeCode.equals(node.getNodeCode())
&& StringUtils.isBlank(node.getEventType());
}
/**
* 导入流程定义
*

View File

@ -0,0 +1,242 @@
package org.dromara.workflow.service.impl;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.domain.PageResult;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.constant.EventCons;
import org.dromara.warm.flow.core.dto.EventWaitQuery;
import org.dromara.warm.flow.core.entity.EventWait;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.warm.flow.core.enums.EventType;
import org.dromara.warm.flow.core.exception.FlowException;
import org.dromara.warm.flow.core.utils.page.Page;
import org.dromara.warm.flow.core.workflow.command.ReviveEventCommand;
import org.dromara.warm.flow.core.workflow.context.WorkflowContext;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.domain.bo.FlowEventWaitBo;
import org.dromara.workflow.domain.bo.FlowTimerTriggerBo;
import org.dromara.workflow.domain.vo.FlowEventWaitVo;
import org.dromara.workflow.service.IFlwEventWaitService;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 定时任务(事件等待记录)服务实现。
* <p>条件解析与合法性校验由引擎 {@link EventWaitQuery} 与门面层完成,本类只负责参数透传、
* 引擎实体到视图对象的装配(流程名 / 业务主键)与状态文案。手动补发复用引擎
* {@code InsService#startByTimer},不读写等待记录,定时任务下次触发时间不受影响。</p>
*
* @author may
* @since 2026/10/21
*/
@ConditionalOnEnable
@RequiredArgsConstructor
@Slf4j
@Service
public class FlwEventWaitServiceImpl implements IFlwEventWaitService {
/**
* 事件类型持久化值 → 页面文案。
*/
private static final Map<String, String> EVENT_TYPE_NAMES = new LinkedHashMap<>();
/**
* 等待状态持久化值 → 页面文案。
*/
private static final Map<String, String> STATUS_NAMES = new LinkedHashMap<>();
static {
EVENT_TYPE_NAMES.put(EventType.MESSAGE_START.getValue(), "消息启动");
EVENT_TYPE_NAMES.put(EventType.TIMER_START.getValue(), "定时启动");
EVENT_TYPE_NAMES.put(EventType.TIMER_CATCH.getValue(), "定时捕获");
EVENT_TYPE_NAMES.put(EventType.MESSAGE_CATCH.getValue(), "消息捕获");
EVENT_TYPE_NAMES.put(EventType.MESSAGE_THROW.getValue(), "消息抛出");
EVENT_TYPE_NAMES.put(EventType.TIMER_BOUNDARY.getValue(), "定时边界");
EVENT_TYPE_NAMES.put(EventType.MESSAGE_BOUNDARY.getValue(), "消息边界");
EVENT_TYPE_NAMES.put(EventType.TERMINATE_END.getValue(), "终止结束");
EVENT_TYPE_NAMES.put(EventType.MESSAGE_END.getValue(), "消息结束");
EVENT_TYPE_NAMES.put(EventType.ERROR_CATCH.getValue(), "错误捕获");
EVENT_TYPE_NAMES.put(EventType.ERROR_BOUNDARY.getValue(), "错误边界");
STATUS_NAMES.put(EventCons.STATUS_WAITING, "待触发");
STATUS_NAMES.put(EventCons.STATUS_RETRY, "重试中");
STATUS_NAMES.put(EventCons.STATUS_RUNNING, "执行中");
STATUS_NAMES.put(EventCons.STATUS_CANCELLED, "已取消");
STATUS_NAMES.put(EventCons.STATUS_DEAD, "已死");
STATUS_NAMES.put(EventCons.STATUS_SUCCESS, "成功");
}
/**
* 分页查询事件等待记录。
*
* @param bo 查询条件
* @return 分页等待记录
*/
@Override
public PageResult<FlowEventWaitVo> queryPageList(FlowEventWaitBo bo) {
Page<EventWait> page = FlowEngine.workflow().pageEventWaits(bo.toQuery());
List<EventWait> items = page.getList() == null ? List.of() : page.getList();
boolean timerEnabled = FlowEngine.getFlowConfig().isTimerEnabled();
List<FlowEventWaitVo> rows = new ArrayList<>(items.size());
for (EventWait wait : items) {
FlowEventWaitVo vo = toVo(wait);
vo.setTimerEnabled(timerEnabled);
rows.add(vo);
}
return PageResult.build(rows, page.getTotal());
}
/**
* 按同一组筛选条件统计各状态等待记录数量。
*
* @param bo 查询条件
* @return 各状态计数
*/
@Override
public Map<String, Long> summary(FlowEventWaitBo bo) {
Map<String, Long> summary = new LinkedHashMap<>();
summary.put("waiting", countByStatus(bo, EventCons.STATUS_WAITING));
summary.put("retry", countByStatus(bo, EventCons.STATUS_RETRY));
summary.put("running", countByStatus(bo, EventCons.STATUS_RUNNING));
summary.put("dead", countByStatus(bo, EventCons.STATUS_DEAD));
summary.put("cancelled", countByStatus(bo, EventCons.STATUS_CANCELLED));
return summary;
}
/**
* 查询单条等待记录详情。
*
* @param id 事件等待记录主键
* @return 等待记录详情,不存在时返回 null
*/
@Override
public FlowEventWaitVo queryById(Long id) {
EventWait wait = FlowEngine.eventWaitService().getById(id);
if (wait == null) {
return null;
}
FlowEventWaitVo vo = toVo(wait);
vo.setTimerEnabled(FlowEngine.getFlowConfig().isTimerEnabled());
return vo;
}
/**
* 手动补发一次定时启动任务(运维应急)。
* <p>
* 只做「按定义 + 事件节点直接发起」,<b>不读写等待记录</b>:定时任务的下次触发时间保持原样,
* 提前补发不会吞掉当天的正常触发。业务主键优先取调用方传入值,留空时用雪花 long 型 id
* (与宿主其它业务表主键同型,便于下游按 long 解析),天然不会与定时触发的
* {@code T{waitId}_{计划时间}} 撞号。加分布式锁防止同机/多机双击连发。
*
* @param bo 补发参数
* @return 新建实例主键
*/
@Override
@Lock4j(keys = {"#bo.waitId"})
public Long manualTrigger(FlowTimerTriggerBo bo) {
if (bo == null || bo.getWaitId() == null) {
throw new ServiceException("事件等待记录主键不能为空");
}
EventWait wait = FlowEngine.eventWaitService().getById(bo.getWaitId());
if (wait == null) {
throw new ServiceException("事件等待记录不存在: " + bo.getWaitId());
}
if (!EventType.TIMER_START.getValue().equals(wait.getEventType())) {
throw new ServiceException("只有定时启动任务支持手动触发,当前事件类型为 " + wait.getEventType());
}
String finalBusinessId = StringUtils.isNotBlank(bo.getBusinessId())
? bo.getBusinessId().trim()
: String.valueOf(IdWorker.getId());
WorkflowContext context = WorkflowContext.create(LoginHelper.getUserIdStr());
try {
Instance instance = FlowEngine.insService().startByTimer(finalBusinessId, wait.getDefinitionId()
, wait.getNodeCode(), bo.getVariables(), context);
return instance.getId();
} catch (FlowException e) {
log.warn("定时启动任务手动补发失败 waitId={} 原因={}", bo.getWaitId(), e.getMessage());
throw new ServiceException(e.getMessage());
}
}
/**
* 复活已死的事件等待记录,复用引擎的状态校验、排期重算与乐观锁更新。
*
* @param id 事件等待记录主键
*/
@Override
public void revive(Long id) {
ReviveEventCommand command = new ReviveEventCommand();
command.setEventWaitId(id);
try {
FlowEngine.workflow().reviveEvent(command);
} catch (FlowException e) {
log.warn("事件等待记录复活失败 waitId={} 原因={}", id, e.getMessage());
throw new ServiceException(e.getMessage());
}
}
/**
* 统计某一状态下的等待记录数量。
*
* @param bo 查询参数(其中状态条件被覆盖)
* @param status 等待状态
* @return 记录数量
*/
private long countByStatus(FlowEventWaitBo bo, String status) {
EventWaitQuery query = bo.toQuery();
query.setStatuses(List.of(status));
return FlowEngine.workflow().countEventWaits(query);
}
/**
* 引擎等待记录 → 视图对象。
*
* @param wait 引擎等待记录
* @return 视图对象
*/
private FlowEventWaitVo toVo(EventWait wait) {
FlowEventWaitVo vo = new FlowEventWaitVo();
vo.setId(wait.getId());
vo.setDefinitionId(wait.getDefinitionId());
vo.setInstanceId(wait.getInstanceId());
vo.setNodeCode(wait.getNodeCode());
vo.setEventType(wait.getEventType());
vo.setEventName(wait.getEventName());
vo.setAttachedTaskId(wait.getAttachedTaskId());
vo.setTriggerType(wait.getTriggerType());
vo.setTriggerInterval(wait.getTriggerInterval());
vo.setNextTriggerAt(wait.getNextTriggerAt());
vo.setTimezone(wait.getTimezone());
vo.setRepeatCount(wait.getRepeatCount());
vo.setMaxRepeatCount(wait.getMaxRepeatCount());
vo.setStatus(wait.getStatus());
vo.setRetryCount(wait.getRetryCount());
vo.setMaxRetryCount(wait.getMaxRetryCount());
vo.setRetryAt(wait.getRetryAt());
vo.setPayload(wait.getPayload());
vo.setLastError(wait.getLastError());
vo.setCreateTime(wait.getCreateTime());
vo.setUpdateTime(wait.getUpdateTime());
vo.setEventTypeName(EVENT_TYPE_NAMES.getOrDefault(wait.getEventType(), wait.getEventType()));
vo.setStatusName(STATUS_NAMES.getOrDefault(wait.getStatus(), wait.getStatus()));
if (wait.getDefinitionId() != null) {
var definition = FlowEngine.defService().getById(wait.getDefinitionId());
vo.setFlowName(definition == null ? null : definition.getFlowName());
}
if (wait.getInstanceId() != null) {
var instance = FlowEngine.insService().getById(wait.getInstanceId());
vo.setBusinessId(instance == null ? null : instance.getBusinessId());
}
return vo;
}
}

View File

@ -20,8 +20,12 @@ import org.dromara.common.mybatis.core.query.LambdaJoinQueryBuilder;
import org.dromara.common.mybatis.core.query.QueryBuilder;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.enums.TaskStatus;
import org.dromara.warm.flow.core.workflow.command.RevokeCommand;
import org.dromara.warm.flow.core.workflow.command.TerminateCommand;
import org.dromara.warm.flow.core.workflow.context.OperatorContext;
import org.dromara.warm.flow.core.constant.ExceptionCons;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.workflow.context.WorkflowContext;
import org.dromara.warm.flow.core.entity.Definition;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.warm.flow.core.entity.Task;
@ -36,7 +40,6 @@ import org.dromara.warm.flow.orm.entity.FlowTask;
import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper;
import org.dromara.warm.flow.orm.mapper.FlowInstanceMapper;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.TaskStatusEnum;
import org.dromara.workflow.domain.FlowInstanceBizExt;
import org.dromara.workflow.domain.bo.FlowCancelBo;
import org.dromara.workflow.domain.bo.FlowInstanceBo;
@ -267,13 +270,13 @@ public class FlwInstanceServiceImpl implements IFlwInstanceService {
throw new ServiceException("权限不足,无法撤销流程!");
}
BusinessStatusEnum.checkCancelStatus(instance.getFlowStatus());
FlowParams flowParams = FlowParams.build()
.message(bo.message())
.flowStatus(BusinessStatusEnum.CANCEL.getStatus())
.hisStatus(BusinessStatusEnum.CANCEL.getStatus())
.handler(userIdStr)
.ignore(true);
taskService.revoke(instance.getId(), flowParams);
RevokeCommand command = new RevokeCommand();
command.setOperator(new OperatorContext(userIdStr));
command.getOperator().setIgnore(true);
command.setInstanceId(instance.getId());
command.setMessage(bo.message());
command.setTaskStatus(BusinessStatusEnum.CANCEL.getStatus());
FlowEngine.workflow().revoke(command);
return true;
}
@ -300,15 +303,33 @@ public class FlwInstanceServiceImpl implements IFlwInstanceService {
*/
@Override
public Map<String, Object> flowHisTaskList(String businessId) {
FlowInstance flowInstance = this.selectInstByBusinessId(businessId);
if (ObjectUtil.isNull(flowInstance)) {
return flowHisTaskList(null, businessId);
}
/**
* 按实例 ID、业务 ID 查询流程图及历史办理记录;同时提供时须指向同一实例。
*
* @param instanceId 流程实例 ID,可为空
* @param businessId 业务 ID,可为空
* @return 流程图与办理记录
*/
@Override
public Map<String, Object> flowHisTaskList(Long instanceId, String businessId) {
if (instanceId == null && StringUtils.isBlank(businessId)) {
throw new ServiceException("流程实例 ID 和业务 ID 不能同时为空");
}
FlowInstance flowInstance = instanceId != null
? this.selectInstById(instanceId)
: this.selectInstByBusinessId(businessId);
if (ObjectUtil.isNull(flowInstance)
|| (StringUtils.isNotBlank(businessId) && !businessId.equals(flowInstance.getBusinessId()))) {
throw new ServiceException(ExceptionCons.NOT_FOUNT_INSTANCE);
}
Long instanceId = flowInstance.getId();
Long resolvedInstanceId = flowInstance.getId();
// 先组装待审批任务(运行中的任务)
List<FlowHisTaskVo> runningTaskVos = new ArrayList<>();
List<FlowTask> runningTasks = flwTaskService.selectByInstId(instanceId);
List<FlowTask> runningTasks = flwTaskService.selectByInstId(resolvedInstanceId);
if (CollUtil.isNotEmpty(runningTasks)) {
runningTaskVos = BeanUtil.copyToList(runningTasks, FlowHisTaskVo.class);
@ -317,7 +338,8 @@ public class FlwInstanceServiceImpl implements IFlwInstanceService {
Map<Long, List<User>> taskUserMap = StreamUtils.groupByKey(associatedUsers, User::getAssociated);
for (FlowHisTaskVo vo : runningTaskVos) {
vo.setFlowStatus(TaskStatusEnum.WAITING.getStatus());
// 状态列保留待办任务自身的 flow_status(pending 待审批 / back 退回),
// 不要覆盖成实例维度编码:审批记录用任务字典渲染这一列。
vo.setUpdateTime(null);
vo.setRunDuration(null);
@ -332,7 +354,7 @@ public class FlwInstanceServiceImpl implements IFlwInstanceService {
List<FlowHisTaskVo> hisTaskVos = new ArrayList<>();
List<FlowHisTask> hisTasks = flowHisTaskMapper.selectList(
QueryBuilder.lambda(FlowHisTask.class)
.eq(FlowHisTask::getInstanceId, instanceId)
.eq(FlowHisTask::getInstanceId, resolvedInstanceId)
.eq(FlowHisTask::getNodeType, NodeType.BETWEEN.getKey())
.orderByDesc(FlowHisTask::getUpdateTime)
.build()
@ -346,7 +368,7 @@ public class FlwInstanceServiceImpl implements IFlwInstanceService {
combinedList.addAll(runningTaskVos);
combinedList.addAll(hisTaskVos);
return Map.of("list", combinedList, "instanceId", instanceId);
return Map.of("list", combinedList, "instanceId", resolvedInstanceId);
}
/**
@ -452,12 +474,14 @@ public class FlwInstanceServiceImpl implements IFlwInstanceService {
if (instance != null) {
BusinessStatusEnum.checkInvalidStatus(instance.getFlowStatus());
}
FlowParams flowParams = FlowParams.build()
.message(bo.comment())
.flowStatus(BusinessStatusEnum.INVALID.getStatus())
.hisStatus(TaskStatusEnum.INVALID.getStatus())
.ignore(true);
taskService.terminationByInsId(bo.id(), flowParams);
TerminateCommand command = new TerminateCommand();
command.setOperator(new OperatorContext(LoginHelper.getUserIdStr()));
command.getOperator().setIgnore(true);
command.setInstanceId(bo.id());
command.setMessage(bo.comment());
command.setFlowStatus(BusinessStatusEnum.INVALID.getStatus());
command.setTaskStatus(TaskStatus.INVALID.getKey());
FlowEngine.workflow().terminate(command);
return true;
}
}

View File

@ -11,12 +11,10 @@ import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.utils.CollUtil;
import org.dromara.warm.flow.core.utils.ExpressionUtil;
import org.dromara.warm.flow.ui.service.NodeExtService;
import org.dromara.warm.flow.ui.vo.NodeExt;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.ButtonPermissionEnum;
import org.dromara.workflow.common.enums.CopySettingEnum;
import org.dromara.workflow.common.enums.NodeExtEnum;
import org.dromara.workflow.common.enums.VariablesEnum;
import org.dromara.workflow.domain.vo.ButtonPermissionVo;
@ -45,14 +43,6 @@ public class FlwNodeExtServiceImpl implements NodeExtService, IFlwNodeExtService
static {
CHILD_NODE_MAP = Map.of(
CopySettingEnum.class.getSimpleName(),
Map.of(
"label", "抄送对象",
"type", 5,
"must", false,
"multiple", false,
"desc", "设置该节点的抄送办理人"
),
VariablesEnum.class.getSimpleName(),
Map.of(
"label", "自定义参数",
@ -84,7 +74,7 @@ public class FlwNodeExtServiceImpl implements NodeExtService, IFlwNodeExtService
List<NodeExt> nodeExtList = new ArrayList<>();
// 构建基础设置页面
nodeExtList.add(buildNodeExt("wf_basic_tab", "基础设置", 1,
List.of(CopySettingEnum.class, VariablesEnum.class)));
List.of(VariablesEnum.class)));
// 构建按钮权限页面
nodeExtList.add(buildNodeExt("wf_button_tab", "权限", 2,
List.of(ButtonPermissionEnum.class)));
@ -192,21 +182,19 @@ public class FlwNodeExtServiceImpl implements NodeExtService, IFlwNodeExtService
/**
* 解析扩展属性 JSON 并构建 Node 扩展属性对象
* <p>
* 根据传入的 JSON 字符串,将扩展属性分为三类:
* 根据传入的 JSON 字符串,将扩展属性分为两类:
* 1. ButtonPermissionEnum:解析为按钮权限列表,标记每个按钮是否勾选
* 2. CopySettingEnum:解析为抄送对象 ID 集合
* 3. VariablesEnum:解析为自定义参数 Map
* 2. VariablesEnum:解析为自定义参数 Map
*
* <p>示例 JSON:
* [
* {"code": "ButtonPermissionEnum", "value": "back,termination"},
* {"code": "CopySettingEnum", "value": "1,3,4,#{@spelRuleComponent.selectDeptLeaderById(#deptId", "#roleId)}"},
* {"code": "VariablesEnum", "value": "key1=value1,key2=value2"}
* ]
*
* @param ext 扩展属性 JSON 字符串
* @param variable 流程变量
* @return NodeExtVo 对象,封装按钮权限列表、抄送对象集合和自定义参数 Map
* @return NodeExtVo 对象,封装按钮权限列表和自定义参数 Map
*/
@Override
public NodeExtVo parseNodeExt(String ext, Map<String, Object> variable) {
@ -240,22 +228,6 @@ public class FlwNodeExtServiceImpl implements NodeExtService, IFlwNodeExtService
nodeExtVo.setButtonPermissions(buttonList);
} else if (CopySettingEnum.class.getSimpleName().equals(code)) {
List<String> permissions = spelSmartSplit(value).stream()
.map(s -> {
List<String> result = ExpressionUtil.evalVariable(s, variable);
if (CollUtil.isNotEmpty(result)) {
return result;
}
return Collections.singletonList(s);
}).filter(Objects::nonNull)
.flatMap(List::stream)
.distinct()
.collect(Collectors.toList());
List<String> copySettings = FlowEngine.permissionHandler().convertPermissions(permissions);
// 解析抄送对象 ID 集合
nodeExtVo.setCopySettings(new HashSet<>(copySettings));
} else if (VariablesEnum.class.getSimpleName().equals(code)) {
// 解析自定义参数
// 将 key=value 字符串拆分为 Map
@ -272,93 +244,4 @@ public class FlwNodeExtServiceImpl implements NodeExtService, IFlwNodeExtService
return nodeExtVo;
}
/**
* 按逗号分割字符串,但保留 #{...} 表达式和字符串常量中的逗号
*
* @param str 原始配置字符串
* @return 智能分割后的片段列表
*/
private static List<String> spelSmartSplit(String str) {
List<String> result = new ArrayList<>();
if (str == null || str.trim().isEmpty()) {
return result;
}
StringBuilder token = new StringBuilder();
// #{...} 的嵌套深度
int depth = 0;
// 是否在字符串常量中(" 或 ')
boolean inString = false;
// 当前字符串引号类型
char stringQuote = 0;
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
// 检测进入 SpEL 表达式 #{...}
if (!inString && c == '#' && depth == 0 && checkNext(str, i, '{')) {
depth++;
token.append("#{");
// 跳过 {
i++;
continue;
}
// 在表达式中遇到 { 或 } 改变嵌套深度
if (!inString && depth > 0) {
if (c == '{') {
depth++;
} else if (c == '}') {
depth--;
}
token.append(c);
continue;
}
// 检测字符串开始/结束
if (depth > 0 && (c == '"' || c == '\'')) {
if (!inString) {
inString = true;
stringQuote = c;
} else if (stringQuote == c) {
inString = false;
}
token.append(c);
continue;
}
// 外层逗号才分割
if (c == ',' && depth == 0 && !inString) {
String part = token.toString().trim();
if (!part.isEmpty()) {
result.add(part);
}
token.setLength(0);
continue;
}
token.append(c);
}
// 添加最后一个
String part = token.toString().trim();
if (!part.isEmpty()) {
result.add(part);
}
return result;
}
/**
* 判断指定位置的下一个字符是否匹配预期字符。
*
* @param str 原始字符串
* @param index 当前索引
* @param expected 期望匹配的字符
* @return 匹配成功返回 {@code true}
*/
private static boolean checkNext(String str, int index, char expected) {
return index + 1 < str.length() && str.charAt(index + 1) == expected;
}
}

View File

@ -10,11 +10,12 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.utils.DateUtils;
import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.api.*;
import org.dromara.system.api.domain.DeptDTO;
import org.dromara.system.api.domain.TaskAssigneeDTO;
import org.dromara.system.api.domain.UserDTO;
import org.dromara.system.api.model.TaskAssigneeBody;
import org.dromara.warm.flow.core.spi.PermissionHandler;
import org.dromara.warm.flow.ui.dto.HandlerFunDto;
import org.dromara.warm.flow.ui.dto.HandlerQuery;
import org.dromara.warm.flow.ui.dto.TreeFunDto;
@ -24,7 +25,6 @@ import org.dromara.warm.flow.ui.vo.HandlerSelectVo;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.TaskAssigneeEnum;
import org.dromara.workflow.service.IFlwSpelService;
import org.dromara.workflow.service.IFlwTaskAssigneeService;
import org.springframework.stereotype.Service;
import java.util.*;
@ -39,7 +39,7 @@ import java.util.stream.Collectors;
@Slf4j
@RequiredArgsConstructor
@Service
public class FlwTaskAssigneeServiceImpl implements IFlwTaskAssigneeService, HandlerSelectService {
public class FlwTaskAssigneeServiceImpl implements PermissionHandler, HandlerSelectService {
private static final String DEFAULT_GROUP_NAME = "默认分组";
@ -50,6 +50,69 @@ public class FlwTaskAssigneeServiceImpl implements IFlwTaskAssigneeService, Hand
private final PostService postService;
private final IFlwSpelService spelService;
/**
* 办理人权限标识,比如用户,角色,部门等,用于校验是否有权限办理任务
* 后续在流程上下文中获取。
* 返回当前用户权限集合
*
* @return 当前用户权限集合
*/
@Override
public List<String> permissions() {
return Collections.singletonList(LoginHelper.getUserIdStr());
}
/**
* 获取当前办理人
*
* @return 当前办理人
*/
@Override
public String getHandler() {
return LoginHelper.getUserIdStr();
}
/**
* 将预设办理人转换为实际用户 ID 列表。
*
* @param permissions 预设权限标识列表
* @return 用户 ID 列表
*/
@Override
public List<String> convertPermissions(List<String> permissions) {
if (CollUtil.isEmpty(permissions)) {
return permissions;
}
Map<TaskAssigneeEnum, List<String>> typeIdMap = new EnumMap<>(TaskAssigneeEnum.class);
for (String storageId : permissions) {
Pair<TaskAssigneeEnum, String> parsed = this.parseStorageId(storageId);
if (parsed != null) {
typeIdMap.computeIfAbsent(parsed.getKey(), k -> new ArrayList<>()).add(parsed.getValue());
}
}
return typeIdMap.entrySet()
.stream()
.map(entry -> {
TaskAssigneeEnum type = entry.getKey();
List<Long> longIds = StreamUtils.toList(entry.getValue(), Convert::toLong);
return switch (type) {
case USER -> longIds;
case ROLE -> userService.selectUserIdsByRoleIds(longIds);
case DEPT -> userService.selectUserIdsByDeptIds(longIds);
case POST -> userService.selectUserIdsByPostIds(longIds);
default -> Collections.<Long>emptyList();
};
})
.filter(CollUtil::isNotEmpty)
.flatMap(Collection::stream)
.filter(Objects::nonNull)
.distinct()
.map(Convert::toStr)
.toList();
}
/**
* 获取办理人权限设置列表tabs页签
*
@ -192,45 +255,6 @@ public class FlwTaskAssigneeServiceImpl implements IFlwTaskAssigneeService, Hand
.setCreateTime(assignee -> DateUtils.formatLocalDateTime(assignee.getCreateTime()));
}
/**
* 批量解析多个存储标识符(storageIds),按类型分类并合并查询用户列表
* 输入格式支持多个以逗号分隔的标识(如 "user:123,role:456,789")
* 会自动去重返回结果,非法格式的标识将被忽略
*
* @param storageIds 多个存储标识符字符串(逗号分隔)
* @return 合并后的用户列表,去重后返回,非法格式的标识将被跳过
*/
@Override
public List<UserDTO> fetchUsersByStorageIds(String storageIds) {
if (StringUtils.isEmpty(storageIds)) {
return List.of();
}
Map<TaskAssigneeEnum, List<String>> typeIdMap = new EnumMap<>(TaskAssigneeEnum.class);
for (String storageId : StringUtils.str2List(storageIds, StringUtils.SEPARATOR, true, true)) {
Pair<TaskAssigneeEnum, String> parsed = this.parseStorageId(storageId);
if (parsed != null) {
typeIdMap.computeIfAbsent(parsed.getKey(), k -> new ArrayList<>()).add(parsed.getValue());
}
}
return this.getUsersByTypes(typeIdMap).stream()
.distinct()
.toList();
}
/**
* 按任务分配类型批量查询用户。
*
* @param typeIdMap 任务分配类型与 id 列表映射
* @return 用户列表
*/
private List<UserDTO> getUsersByTypes(Map<TaskAssigneeEnum, List<String>> typeIdMap) {
return typeIdMap.entrySet().stream()
.map(entry -> this.getUsersByType(entry.getKey(), entry.getValue()))
.filter(CollUtil::isNotEmpty)
.flatMap(Collection::stream)
.toList();
}
/**
* 按任务分配类型批量查询名称映射。
*
@ -243,30 +267,6 @@ public class FlwTaskAssigneeServiceImpl implements IFlwTaskAssigneeService, Hand
return nameMap;
}
/**
* 根据指定的任务分配类型(TaskAssigneeEnum)和 ID 列表,获取对应的用户信息列表
*
* @param type 任务分配类型,表示用户、角色、部门或其他(TaskAssigneeEnum 枚举值)
* @param ids 与指定分配类型关联的 ID 列表(例如用户ID、角色ID、部门ID等)
* @return 返回包含用户信息的列表。如果类型为用户(USER),则通过用户ID列表查询;
* 如果类型为角色(ROLE),则通过角色ID列表查询;
* 如果类型为部门(DEPT),则通过部门ID列表查询;
* 如果类型为岗位(POST)或无法识别的类型,则返回空列表
*/
private List<UserDTO> getUsersByType(TaskAssigneeEnum type, List<String> ids) {
if (type == TaskAssigneeEnum.SPEL) {
return new ArrayList<>();
}
List<Long> longIds = StreamUtils.toList(ids, Convert::toLong);
return switch (type) {
case USER -> userService.selectListByIds(longIds);
case ROLE -> userService.selectUsersByRoleIds(longIds);
case DEPT -> userService.selectUsersByDeptIds(longIds);
case POST -> userService.selectUsersByPostIds(longIds);
default -> new ArrayList<>();
};
}
/**
* 根据任务分配类型和对应 ID 列表,批量查询名称映射关系
*

View File

@ -18,20 +18,26 @@ import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.liteflow.utils.LiteFlowUtils;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.query.QueryBuilder;
import org.dromara.common.mybatis.utils.IdGeneratorUtil;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.api.UserService;
import org.dromara.system.api.domain.UserDTO;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.enums.FlowStatus;
import org.dromara.warm.flow.core.enums.TaskStatus;
import org.dromara.warm.flow.core.service.copy.CopyConfig;
import org.dromara.warm.flow.core.workflow.command.RejectCommand;
import org.dromara.warm.flow.core.workflow.command.TerminateCommand;
import org.dromara.warm.flow.core.workflow.context.OperatorContext;
import org.dromara.warm.flow.core.constant.ExceptionCons;
import org.dromara.warm.flow.core.dto.FlowCombine;
import org.dromara.warm.flow.core.dto.FlowParams;
import org.dromara.warm.flow.core.workflow.context.WorkflowContext;
import org.dromara.warm.flow.core.entity.*;
import org.dromara.warm.flow.core.enums.NodeType;
import org.dromara.warm.flow.core.enums.SkipType;
import org.dromara.warm.flow.core.enums.UserType;
import org.dromara.warm.flow.core.exception.FlowException;
import org.dromara.warm.flow.core.service.*;
import org.dromara.warm.flow.core.spi.PermissionHandler;
import org.dromara.warm.flow.core.utils.ExpressionUtil;
import org.dromara.warm.flow.core.utils.MapUtil;
import org.dromara.warm.flow.orm.entity.FlowHisTask;
@ -44,7 +50,6 @@ import org.dromara.warm.flow.orm.mapper.FlowTaskMapper;
import org.dromara.workflow.api.domain.StartProcessReturnDTO;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.TaskAssigneeType;
import org.dromara.workflow.common.enums.TaskStatusEnum;
import org.dromara.workflow.domain.bo.*;
import org.dromara.workflow.domain.context.CompleteTaskContext;
import org.dromara.workflow.domain.context.StartProcessContext;
@ -58,7 +63,6 @@ import org.dromara.workflow.mapper.FlwHisTaskMapper;
import org.dromara.workflow.mapper.FlwTaskMapper;
import org.dromara.workflow.service.IFlwCommonService;
import org.dromara.workflow.service.IFlwNodeExtService;
import org.dromara.workflow.service.IFlwTaskAssigneeService;
import org.dromara.workflow.service.IFlwTaskService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -100,7 +104,6 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
private final FlwHisTaskMapper flwHisTaskMapper;
private final FlwCategoryMapper flwCategoryMapper;
private final FlowNodeMapper flowNodeMapper;
private final IFlwTaskAssigneeService flwTaskAssigneeService;
private final IFlwCommonService flwCommonService;
private final IFlwNodeExtService flwNodeExtService;
@ -145,44 +148,21 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
if (CollUtil.isEmpty(flowCopyList)) {
return;
}
// 添加抄送人记录
List<FlowHisTask> flowHisTasks = flowHisTaskMapper.selectList(
QueryBuilder.lambda(FlowHisTask.class)
.eq(FlowHisTask::getTaskId, task.getId())
.build());
if (CollUtil.isEmpty(flowHisTasks)) {
throw new ServiceException("流程历史任务不存在,无法添加抄送记录");
WorkflowContext context = WorkflowContext.create(LoginHelper.getUserIdStr());
context.setMessage("【抄送给】" + StreamUtils.join(flowCopyList, FlowCopyBo::getNickName));
// 抄送留痕交给引擎:按「同一待办 + 同一人」幂等,抄送人挂在抄送历史主键上,办结后仍可补抄送。
HisTask copyHis = taskService.copy(task.getId()
, StreamUtils.toList(flowCopyList, x -> Convert.toStr(x.getUserId())), context);
if (ObjectUtil.isNull(copyHis)) {
return;
}
FlowHisTask flowHisTask = flowHisTasks.getFirst();
FlowNode flowNode = new FlowNode();
flowNode.setNodeCode(flowHisTask.getTargetNodeCode());
flowNode.setNodeName(flowHisTask.getTargetNodeName());
//生成新的任务id
long taskId = IdGeneratorUtil.nextLongId();
task.setId(taskId);
task.setNodeName("【抄送】" + task.getNodeName());
Date updateTime = new Date(flowHisTask.getUpdateTime().getTime() - 1000);
FlowParams flowParams = FlowParams.build()
.skipType(SkipType.NONE.getKey())
.hisStatus(TaskStatusEnum.COPY.getStatus())
.message("【抄送给】" + StreamUtils.join(flowCopyList, FlowCopyBo::getNickName));
HisTask hisTask = hisTaskService.setSkipHisTask(task, flowNode, flowParams);
hisTask.setCreateTime(updateTime);
hisTask.setUpdateTime(updateTime);
hisTaskService.save(hisTask);
List<User> userList = StreamUtils.toList(flowCopyList, x ->
new FlowUser()
.setType(TaskAssigneeType.COPY.getCode())
.setProcessedBy(Convert.toStr(x.getUserId()))
.setAssociated(taskId));
// 批量保存抄送人员
FlowEngine.userService().saveBatch(userList);
// 抄送消息进入“我的抄送”,不和待办列表混用。
// 抄送消息进入“我的抄送”,不和待办列表混用;只提醒本次真正新增的人。
flwCommonService.sendMessage(
List.of(org.dromara.workflow.common.enums.MessageTypeEnum.SYSTEM_MESSAGE.getCode()),
"您收到一条新的流程抄送,请及时查看。",
"单据抄送提醒",
userService.selectListByIds(StreamUtils.toList(flowCopyList, FlowCopyBo::getUserId)),
userService.selectListByIds(StreamUtils.toList(
StringUtils.str2List(copyHis.getCollaborator(), StringUtils.SEPARATOR, true, true), Convert::toLong)),
PATH_TASK_COPY
);
}
@ -315,15 +295,16 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
variable.put(MESSAGE_TYPE, messageType);
variable.put(MESSAGE_NOTICE, notice);
FlowParams flowParams = FlowParams.build()
.nodeCode(bo.getNodeCode())
.variable(variable)
.message(message)
.skipType(SkipType.REJECT.getKey())
.flowStatus(applyNodeCode.equals(bo.getNodeCode()) ? TaskStatusEnum.BACK.getStatus() : TaskStatusEnum.WAITING.getStatus())
.hisStatus(TaskStatusEnum.BACK.getStatus())
.hisTaskExt(bo.getFileId());
taskService.skip(task.getId(), flowParams);
RejectCommand command = new RejectCommand();
command.setOperator(new OperatorContext(LoginHelper.getUserIdStr()));
command.setTaskId(task.getId());
command.setTargetNodeCode(bo.getNodeCode());
command.setVariables(variable);
command.setMessage(message);
command.setExt(bo.getFileId());
command.setFlowStatus(applyNodeCode.equals(bo.getNodeCode()) ? FlowStatus.BACK.getKey() : FlowStatus.WAITING.getKey());
command.setTaskStatus(TaskStatus.BACK.getKey());
FlowEngine.workflow().reject(command);
return true;
}
@ -479,11 +460,11 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
if (ObjectUtil.isNotNull(instance)) {
BusinessStatusEnum.checkInvalidStatus(instance.getFlowStatus());
}
FlowParams flowParams = FlowParams.build()
.message(bo.comment())
.flowStatus(BusinessStatusEnum.TERMINATION.getStatus())
.hisStatus(TaskStatusEnum.TERMINATION.getStatus());
taskService.termination(taskId, flowParams);
TerminateCommand command = new TerminateCommand();
command.setOperator(new OperatorContext(LoginHelper.getUserIdStr()));
command.setTaskId(taskId);
command.setMessage(bo.comment());
FlowEngine.workflow().terminate(command);
return true;
}
@ -538,12 +519,13 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
} else {
flowTaskVo.setButtonList(new ArrayList<>());
}
if (CollUtil.isNotEmpty(nodeExtVo.getCopySettings())) {
List<FlowCopyVo> list = StreamUtils.toList(nodeExtVo.getCopySettings(), x -> new FlowCopyVo(Convert.toLong(x)));
flowTaskVo.setCopyList(list);
} else {
flowTaskVo.setCopyList(new ArrayList<>());
// 抄送人读引擎节点配置 node_config.copy,展开规则与流转时一致(resolveCopyHandlers)。
List<String> copyHandlers = CopyConfig.parse(flowNode.getNodeConfig()).getHandlers();
PermissionHandler permissionHandler = FlowEngine.permissionHandler();
if (CollUtil.isNotEmpty(copyHandlers) && ObjectUtil.isNotNull(permissionHandler)) {
copyHandlers = permissionHandler.resolveCopyHandlers(copyHandlers);
}
flowTaskVo.setCopyList(StreamUtils.toList(copyHandlers, x -> new FlowCopyVo(Convert.toLong(x))));
if (CollUtil.isNotEmpty(nodeExtVo.getVariables())) {
flowTaskVo.setVarList(nodeExtVo.getVariables());
} else {
@ -582,24 +564,20 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
if (CollUtil.isEmpty(nextNodeList)) {
return new ArrayList<>();
}
List<FlowNode> nextFlowNodes = BeanUtil.copyToList(nextNodeList, FlowNode.class);
// 只获取中间节点
nextFlowNodes = StreamUtils.filter(nextFlowNodes, node -> NodeType.BETWEEN.getKey().equals(node.getNodeType()));
if (CollUtil.isNotEmpty(nextNodeList)) {
//构建以下节点数据
List<Task> buildNextTaskList = StreamUtils.toList(nextNodeList, node -> taskService.addTask(node, instance, definition, FlowParams.build()));
//办理人变量替换
ExpressionUtil.evalVariable(buildNextTaskList, FlowParams.build().variable(mergeVariable));
for (FlowNode flowNode : nextFlowNodes) {
StreamUtils.findFirst(buildNextTaskList, t -> t.getNodeCode().equals(flowNode.getNodeCode()))
.ifPresent(first -> {
List<UserDTO> users;
if (CollUtil.isNotEmpty(first.getPermissionList())
&& CollUtil.isNotEmpty(users = flwTaskAssigneeService.fetchUsersByStorageIds(StringUtils.joinComma(first.getPermissionList())))) {
flowNode.setPermissionFlag(StreamUtils.join(users, e -> Convert.toStr(e.getUserId())));
}
});
}
nextNodeList = StreamUtils.filter(nextNodeList, node -> NodeType.BETWEEN.getKey().equals(node.getNodeType()));
if (CollUtil.isEmpty(nextNodeList)) {
return new ArrayList<>();
}
List<FlowNode> nextFlowNodes = BeanUtil.copyToList(nextNodeList, FlowNode.class);
//构建以下节点数据
List<Task> buildNextTaskList = StreamUtils.toList(nextNodeList, node -> taskService.addTask(node, instance
, definition, WorkflowContext.create(), SkipType.PASS.getKey()));
//办理人变量替换
ExpressionUtil.evalVariable(buildNextTaskList, mergeVariable, null, false);
Map<String, List<String>> map = StreamUtils.toMap(buildNextTaskList, Task::getNodeCode, Task::getPermissionList);
for (FlowNode node : nextFlowNodes) {
node.setPermissionFlag(StringUtils.joinComma(map.get(node.getNodeCode())));
}
return nextFlowNodes;
}
@ -756,6 +734,19 @@ public class FlwTaskServiceImpl implements IFlwTaskService {
if (taskAssignee) {
return;
}
// 抄送人挂在抄送历史主键上(引擎 CopyAdvancer),不挂在待办 id 下,
// 所以要先按待办找到抄送历史,再按历史主键查抄送接收人。
List<HisTask> copyHisList = FlowEngine.hisTaskService()
.list(FlowEngine.newHisTask().setTaskId(task.getId()).setFlowStatus(TaskStatus.COPY.getKey()));
List<Long> copyHisIds = StreamUtils.toList(copyHisList, HisTask::getId);
if (CollUtil.isNotEmpty(copyHisIds)) {
boolean copyReceiver = FlowEngine.userService().getByAssociateds(copyHisIds).stream()
.anyMatch(user -> Objects.equals(userId, user.getProcessedBy())
&& UserType.COPY.getKey().equals(Convert.toStr(user.getType())));
if (copyReceiver) {
return;
}
}
Instance instance = insService.getById(task.getInstanceId());
if (ObjectUtil.isNotNull(instance) && Objects.equals(instance.getCreateBy(), userId)) {
return;

View File

@ -17,6 +17,9 @@ import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.entity.Definition;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.workflow.api.WorkflowService;
import org.dromara.workflow.api.domain.StartProcessDTO;
import org.dromara.workflow.api.event.ProcessDeleteEvent;
@ -28,6 +31,7 @@ import org.dromara.workflow.domain.TestLeave;
import org.dromara.workflow.domain.bo.TestLeaveBo;
import org.dromara.workflow.domain.vo.TestLeaveVo;
import org.dromara.workflow.mapper.TestLeaveMapper;
import org.dromara.workflow.service.IFlwInstanceService;
import org.dromara.workflow.service.ITestLeaveService;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;
@ -52,6 +56,7 @@ public class TestLeaveServiceImpl implements ITestLeaveService {
private final TestLeaveMapper leaveMapper;
private final WorkflowService workflowService;
private final IFlwInstanceService flwInstanceService;
/**
* spel条件表达:判断小于2
@ -71,7 +76,19 @@ public class TestLeaveServiceImpl implements ITestLeaveService {
*/
@Override
public TestLeaveVo queryById(Long id) {
return leaveMapper.selectVoById(id);
TestLeaveVo vo = leaveMapper.selectVoById(id);
if (ObjectUtil.isNull(vo)) {
return null;
}
// 撤回 / 退回后修改再提交走的是已有实例的续提交,前端需要拿到原流程编码
Instance instance = flwInstanceService.selectInstByBusinessId(Convert.toStr(id));
if (ObjectUtil.isNotNull(instance)) {
Definition definition = FlowEngine.defService().getById(instance.getDefinitionId());
if (ObjectUtil.isNotNull(definition)) {
vo.setFlowCode(definition.getFlowCode());
}
}
return vo;
}
/**
@ -205,14 +222,22 @@ public class TestLeaveServiceImpl implements ITestLeaveService {
/**
* 总体流程监听(例如: 草稿,撤销,退回,作废,终止,已完成等)
* 正常使用只需#processEvent.flowCode=='leave1'
* 示例为了方便则使用startsWith匹配了全部示例key
* 示例流程编码已切换为引擎自带的 demo_ 前缀,故这里按 demo 匹配
*
* @param processEvent 参数
*/
@EventListener(condition = "#processEvent.flowCode.startsWith('leave')")
@EventListener(condition = "#processEvent.flowCode.startsWith('demo')")
public void processHandler(ProcessEvent processEvent) {
log.info("当前任务执行了{}", processEvent.toString());
TestLeave testLeave = leaveMapper.selectById(Convert.toLong(processEvent.getBusinessId()));
Long leaveId = Convert.toLong(processEvent.getBusinessId());
if (leaveId == null) {
return;
}
TestLeave testLeave = leaveMapper.selectById(leaveId);
if (ObjectUtil.isNull(testLeave)) {
// 其他演示流程没有对应的请假记录。
return;
}
testLeave.setStatus(processEvent.getStatus());
// 用于例如审批附件 审批意见等 存储到业务表内 自行根据业务实现存储流程
Map<String, Object> params = processEvent.getParams();
@ -247,7 +272,7 @@ public class TestLeaveServiceImpl implements ITestLeaveService {
*
* @param processTaskEvent 参数
*/
@EventListener(condition = "#processTaskEvent.flowCode.startsWith('leave')")
@EventListener(condition = "#processTaskEvent.flowCode.startsWith('demo')")
public void processTaskHandler(ProcessTaskEvent processTaskEvent) {
log.info("当前任务创建了{}", processTaskEvent.toString());
}
@ -255,14 +280,18 @@ public class TestLeaveServiceImpl implements ITestLeaveService {
/**
* 监听删除流程事件
* 正常使用只需#processDeleteEvent.flowCode=='leave1'
* 示例为了方便则使用startsWith匹配了全部示例key
* 示例流程编码已切换为引擎自带的 demo_ 前缀,故这里按 demo 匹配
*
* @param processDeleteEvent 参数
*/
@EventListener(condition = "#processDeleteEvent.flowCode.startsWith('leave')")
@EventListener(condition = "#processDeleteEvent.flowCode.startsWith('demo')")
public void processDeleteHandler(ProcessDeleteEvent processDeleteEvent) {
log.info("监听删除流程事件,当前任务执行了{}", processDeleteEvent.toString());
TestLeave testLeave = leaveMapper.selectById(Convert.toLong(processDeleteEvent.getBusinessId()));
Long leaveId = Convert.toLong(processDeleteEvent.getBusinessId());
if (leaveId == null) {
return;
}
TestLeave testLeave = leaveMapper.selectById(leaveId);
if (ObjectUtil.isNull(testLeave)) {
return;
}

View File

@ -2,13 +2,20 @@ package org.dromara.workflow.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.workflow.context.WorkflowContext;
import org.dromara.warm.flow.orm.entity.FlowInstance;
import org.dromara.workflow.api.WorkflowService;
import org.dromara.workflow.api.domain.CompleteTaskDTO;
import org.dromara.workflow.api.domain.StartMessageEventDTO;
import org.dromara.workflow.api.domain.StartProcessDTO;
import org.dromara.workflow.api.domain.StartProcessReturnDTO;
import org.dromara.workflow.api.domain.StartTimerEventDTO;
import org.dromara.workflow.api.domain.TriggerMessageEventDTO;
import org.dromara.workflow.common.ConditionalOnEnable;
import org.dromara.workflow.common.enums.MessageTypeEnum;
import org.dromara.workflow.domain.FlowInstanceBizExt;
@ -170,4 +177,83 @@ public class WorkflowServiceImpl implements WorkflowService {
taskBo.setHandler(startProcess.getHandler());
return flwTaskService.completeTask(taskBo);
}
/**
* 外部消息按流程编码和事件名称启动新实例。
*
* @param request 消息启动参数,业务 ID 由调用方提供
* @return 新建流程实例 ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long startByMessageEvent(StartMessageEventDTO request) {
if (request == null) {
throw new ServiceException("消息启动参数不能为空");
}
return FlowEngine.insService().startByMessage(request.getBusinessId(), request.getFlowCode(),
request.getEventName(), request.getPayload(), WorkflowContext.create(request.getHandler())).getId();
}
/**
* 触发实例中等待的消息捕获或消息边界事件。重复投递时引擎保持实例不变。
*
* @param request 消息事件参数
* @return 流程实例 ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long triggerMessageEvent(TriggerMessageEventDTO request) {
if (request == null) {
throw new ServiceException("消息事件参数不能为空");
}
return FlowEngine.insService().triggerMessage(request.getInstanceId(), request.getEventName(),
request.getPayload(), WorkflowContext.create(request.getHandler())).getId();
}
/**
* 手动发起定时启动事件,不改变等待记录及下一次自动触发时间。
*
* @param request 定时启动参数,业务 ID 留空时生成数字雪花 ID
* @return 新建流程实例 ID
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Long startByTimerEvent(StartTimerEventDTO request) {
if (request == null) {
throw new ServiceException("定时启动参数不能为空");
}
String businessId = StringUtils.isNotBlank(request.getBusinessId())
? request.getBusinessId().trim() : String.valueOf(IdWorker.getId());
return FlowEngine.insService().startByTimer(businessId, request.getDefinitionId(),
request.getNodeCode(), request.getPayload(), WorkflowContext.create(request.getHandler())).getId();
}
/**
* 从实例级错误捕获节点处理外部错误。
*
* @param instanceId 流程实例 ID
* @param errorCode 错误码,精确匹配优先于通配
* @param errorMessage 错误描述
* @return 错误事件已被处理时返回 true
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean throwInstanceErrorEvent(Long instanceId, String errorCode, String errorMessage) {
return FlowEngine.insService().throwError(instanceId, errorCode, errorMessage);
}
/**
* 从当前待办的错误边界节点处理外部错误。
*
* @param taskId 当前待办任务 ID
* @param errorCode 错误码,精确匹配优先于通配
* @param errorMessage 错误描述
* @return 错误事件已被处理时返回 true
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean throwTaskErrorEvent(Long taskId, String errorCode, String errorMessage) {
return FlowEngine.taskService().throwError(taskId, errorCode, errorMessage);
}
}

View File

@ -25,17 +25,12 @@
办理任务链路:
1. 加载任务实例、补齐变量并解析办理人
2. 构建参数并执行当前任务跳转
3. 开启自动审批时继续办理当前处理人的后续任务
自动审批由引擎按流程定义配置 def_config.autoPass 在办理链内完成,宿主不再单独编排。
-->
<chain name="completeTaskChain">
THEN(
completePrepare,
completeExecute,
IF(
completeNeedAutoPass,
completeAutoPass,
noop
)
completeExecute
);
</chain>

View File

@ -0,0 +1,56 @@
package org.dromara.workflow.demo;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.config.WarmFlow;
import org.dromara.warm.flow.core.entity.Definition;
import org.dromara.warm.flow.core.entity.EventWait;
import org.dromara.warm.flow.core.enums.EventType;
import org.dromara.warm.flow.core.service.DefService;
import org.dromara.warm.flow.core.service.InsService;
import org.dromara.warm.flow.core.workflow.context.WorkflowContext;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class DemoTimerInspectHandlerTest {
@Test
void timerStartUsesNumericBusinessId() {
EventWait wait = mock(EventWait.class);
when(wait.getEventType()).thenReturn(EventType.TIMER_START.getValue());
when(wait.getDefinitionId()).thenReturn(12L);
when(wait.getNodeCode()).thenReturn("timerStart");
Definition definition = mock(Definition.class);
when(definition.getFlowCode()).thenReturn("demo_timer_start");
DefService defService = mock(DefService.class);
when(defService.getById(12L)).thenReturn(definition);
InsService insService = mock(InsService.class);
WarmFlow flowConfig = mock(WarmFlow.class);
when(flowConfig.getSystemHandler()).thenReturn("0");
try (MockedStatic<FlowEngine> engine = mockStatic(FlowEngine.class)) {
engine.when(FlowEngine::defService).thenReturn(defService);
engine.when(FlowEngine::insService).thenReturn(insService);
engine.when(FlowEngine::getFlowConfig).thenReturn(flowConfig);
assertDoesNotThrow(() -> new DemoTimerInspectHandler().handle(wait));
ArgumentCaptor<String> businessId = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<WorkflowContext> context = ArgumentCaptor.forClass(WorkflowContext.class);
verify(insService).startByTimer(businessId.capture(), eq(12L), eq("timerStart"), isNull(), context.capture());
assertTrue(businessId.getValue().matches("[0-9]+"));
assertTrue(Long.parseLong(businessId.getValue()) > 0);
assertEquals("0", context.getValue().getHandler());
}
}
}

View File

@ -0,0 +1,81 @@
package org.dromara.workflow.service.impl;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.common.core.service.DictService;
import org.dromara.system.api.DeptService;
import org.dromara.system.api.UserService;
import org.dromara.system.api.domain.UserDTO;
import org.dromara.warm.flow.core.dto.DefJson;
import org.dromara.warm.flow.core.dto.NodeJson;
import org.dromara.warm.flow.orm.entity.FlowInstance;
import org.dromara.warm.flow.orm.entity.FlowTask;
import org.dromara.warm.flow.orm.entity.FlowUser;
import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper;
import org.dromara.warm.flow.orm.mapper.FlowTaskMapper;
import org.dromara.warm.flow.orm.mapper.FlowUserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FlwChartExtServiceImplTest {
@Test
void showsCurrentAssigneeWithoutApprovalHistory() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), FlowUser.class);
UserService userService = mock(UserService.class);
DeptService deptService = mock(DeptService.class);
FlowHisTaskMapper historyMapper = mock(FlowHisTaskMapper.class);
FlowTaskMapper taskMapper = mock(FlowTaskMapper.class);
FlowUserMapper flowUserMapper = mock(FlowUserMapper.class);
DictService dictService = mock(DictService.class);
FlwChartExtServiceImpl service = new FlwChartExtServiceImpl(
userService, deptService, historyMapper, taskMapper, flowUserMapper, dictService);
ReflectionTestUtils.setField(service, "nodeTooltip", true);
NodeJson node = new NodeJson().setNodeCode("approve").setNodeName("部门审批");
DefJson definition = new DefJson()
.setFlowName("请假申请")
.setInstance(new FlowInstance().setId(1L))
.setNodeList(List.of(node));
service.initPromptContent(definition);
when(historyMapper.selectList(any())).thenReturn(List.of());
when(taskMapper.selectList(any())).thenReturn(List.of(
new FlowTask().setId(10L).setNodeCode("approve")));
when(flowUserMapper.selectList(any())).thenReturn(List.of(
new FlowUser().setAssociated(10L).setType("1").setProcessedBy("7")));
UserDTO user = new UserDTO();
user.setUserId(7L);
user.setDeptId(3L);
user.setNickName("张三");
user.setUserName("zhangsan");
when(userService.selectListByIds(any())).thenReturn(List.of(user));
when(deptService.selectDeptNamesByIds(any())).thenReturn(Map.of(3L, "研发部"));
when(dictService.getAllDictByDictType(any())).thenReturn(Map.of());
service.execute(definition);
var info = node.getPromptContent().getInfo();
assertTrue(info.stream().anyMatch(item -> item.getPrefix().contains("张三")
&& item.getPrefix().contains("研发部")));
assertTrue(info.stream().anyMatch(item -> "用户账号: ".equals(item.getPrefix())
&& "zhangsan".equals(item.getContent())));
assertTrue(info.stream().anyMatch(item -> "审批状态: ".equals(item.getPrefix())
&& "待审批".equals(item.getContent())));
verify(userService).selectListByIds(argThat(ids -> ids.size() == 1 && ids.contains(7L)));
verify(deptService).selectDeptNamesByIds(argThat(ids -> ids.size() == 1 && ids.contains(3L)));
assertEquals(4, info.size());
}
}

View File

@ -0,0 +1,39 @@
package org.dromara.workflow.service.impl;
import org.dromara.warm.flow.core.enums.NodeType;
import org.dromara.warm.flow.orm.entity.FlowNode;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FlwDefinitionServiceImplTest {
@Test
void eventNodesDoNotRequireAssignee() {
for (String eventType : new String[]{"MESSAGE_START", "TIMER_CATCH", "ERROR_BOUNDARY", "TERMINATE_END"}) {
FlowNode node = new FlowNode()
.setNodeType(NodeType.BETWEEN.getKey())
.setNodeCode("event")
.setEventType(eventType);
assertFalse(FlwDefinitionServiceImpl.requiresAssignee(node, "apply"), eventType);
}
}
@Test
void manualApprovalStillRequiresAssignee() {
FlowNode approval = new FlowNode()
.setNodeType(NodeType.BETWEEN.getKey())
.setNodeCode("approve");
assertTrue(FlwDefinitionServiceImpl.requiresAssignee(approval, "apply"));
assertFalse(FlwDefinitionServiceImpl.requiresAssignee(approval, "approve"));
}
@Test
void serviceTaskDoesNotRequireAssignee() {
FlowNode service = new FlowNode()
.setNodeType(NodeType.SERVICE.getKey())
.setNodeCode("service");
assertFalse(FlwDefinitionServiceImpl.requiresAssignee(service, "apply"));
}
}

View File

@ -0,0 +1,62 @@
package org.dromara.workflow.service.impl;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.constant.EventCons;
import org.dromara.warm.flow.core.entity.EventWait;
import org.dromara.warm.flow.core.enums.EventType;
import org.dromara.warm.flow.core.service.EventWaitService;
import org.dromara.warm.flow.core.service.impl.EventWaitServiceImpl;
import org.dromara.warm.flow.core.workflow.WorkflowServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
class FlwEventWaitServiceImplTest {
@ParameterizedTest
@ValueSource(strings = {"WAITING", "RETRY", "RUNNING", "CANCELLED", "SUCCESS"})
void recoveryRejectsRecordsThatAreNotDead(String status) {
ServiceException error = recover(status, EventType.TIMER_CATCH);
assertTrue(error.getMessage().contains("DEAD"), error.getMessage());
}
@Test
void deadTimerStartMustBeRebuiltFromDefinition() {
ServiceException error = recover(EventCons.STATUS_DEAD, EventType.TIMER_START);
assertTrue(error.getMessage().contains("定时启动"), error.getMessage());
}
@Test
void deadEventCannotResumeWithoutItsInstance() {
ServiceException error = recover(EventCons.STATUS_DEAD, EventType.MESSAGE_CATCH);
assertTrue(error.getMessage().contains("实例"), error.getMessage());
}
/**
* 使用实际 2.1.1 门面和事件服务验证恢复约束,避免宿主绕过引擎直接改状态。
*/
private ServiceException recover(String status, EventType type) {
EventWait wait = mock(EventWait.class);
when(wait.getStatus()).thenReturn(status);
when(wait.getEventType()).thenReturn(type.getValue());
when(wait.getAttachedTaskId()).thenReturn(null);
when(wait.getInstanceId()).thenReturn(null);
EventWaitService eventService = spy(new EventWaitServiceImpl());
doReturn(wait).when(eventService).getById(12L);
try (MockedStatic<FlowEngine> engine = mockStatic(FlowEngine.class)) {
engine.when(FlowEngine::workflow).thenReturn(new WorkflowServiceImpl());
engine.when(FlowEngine::eventWaitService).thenReturn(eventService);
return assertThrows(ServiceException.class, () -> new FlwEventWaitServiceImpl().revive(12L));
}
}
}

View File

@ -0,0 +1,80 @@
package org.dromara.workflow.service.impl;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.warm.flow.core.service.DefService;
import org.dromara.warm.flow.core.service.InsService;
import org.dromara.warm.flow.core.service.TaskService;
import org.dromara.warm.flow.orm.entity.FlowInstance;
import org.dromara.warm.flow.orm.mapper.FlowHisTaskMapper;
import org.dromara.warm.flow.orm.mapper.FlowInstanceMapper;
import org.dromara.workflow.mapper.FlwCategoryMapper;
import org.dromara.workflow.mapper.FlwInstanceMapper;
import org.dromara.workflow.service.IFlwTaskService;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class FlwInstanceProgressTest {
@Test
void instanceIdAloneLoadsProgress() {
FlowInstanceMapper instanceMapper = mock(FlowInstanceMapper.class);
FlowHisTaskMapper historyMapper = mock(FlowHisTaskMapper.class);
IFlwTaskService taskService = mock(IFlwTaskService.class);
FlwInstanceServiceImpl service = service(instanceMapper, historyMapper, taskService);
FlowInstance instance = mock(FlowInstance.class);
when(instance.getId()).thenReturn(12L);
when(instanceMapper.selectById(12L)).thenReturn(instance);
when(taskService.selectByInstId(12L)).thenReturn(List.of());
when(historyMapper.selectList(any())).thenReturn(List.of());
assertEquals(12L, service.flowHisTaskList(12L, null).get("instanceId"));
verify(instanceMapper).selectById(12L);
}
@Test
void rejectsMismatchedIdsBeforeLoadingTasks() {
FlowInstanceMapper instanceMapper = mock(FlowInstanceMapper.class);
FlowHisTaskMapper historyMapper = mock(FlowHisTaskMapper.class);
IFlwTaskService taskService = mock(IFlwTaskService.class);
FlwInstanceServiceImpl service = service(instanceMapper, historyMapper, taskService);
FlowInstance instance = mock(FlowInstance.class);
when(instance.getBusinessId()).thenReturn("business-1");
when(instanceMapper.selectById(12L)).thenReturn(instance);
assertThrows(ServiceException.class, () -> service.flowHisTaskList(12L, "business-2"));
verifyNoInteractions(taskService, historyMapper);
}
@Test
void matchingIdsLoadSameInstance() {
FlowInstanceMapper instanceMapper = mock(FlowInstanceMapper.class);
FlowHisTaskMapper historyMapper = mock(FlowHisTaskMapper.class);
IFlwTaskService taskService = mock(IFlwTaskService.class);
FlwInstanceServiceImpl service = service(instanceMapper, historyMapper, taskService);
FlowInstance instance = mock(FlowInstance.class);
when(instance.getId()).thenReturn(12L);
when(instance.getBusinessId()).thenReturn("business-1");
when(instanceMapper.selectById(12L)).thenReturn(instance);
when(taskService.selectByInstId(12L)).thenReturn(List.of());
when(historyMapper.selectList(any())).thenReturn(List.of());
assertEquals(12L, service.flowHisTaskList(12L, "business-1").get("instanceId"));
verify(instanceMapper).selectById(12L);
}
private FlwInstanceServiceImpl service(FlowInstanceMapper instanceMapper,
FlowHisTaskMapper historyMapper, IFlwTaskService taskService) {
return new FlwInstanceServiceImpl(mock(InsService.class), mock(DefService.class),
mock(TaskService.class), historyMapper, instanceMapper, taskService,
mock(FlwInstanceMapper.class), mock(FlwCategoryMapper.class));
}
}

View File

@ -0,0 +1,131 @@
package org.dromara.workflow.service.impl;
import org.dromara.warm.flow.core.FlowEngine;
import org.dromara.warm.flow.core.config.WarmFlow;
import org.dromara.warm.flow.core.entity.Instance;
import org.dromara.warm.flow.core.service.InsService;
import org.dromara.warm.flow.core.service.TaskService;
import org.dromara.warm.flow.core.workflow.context.WorkflowContext;
import org.dromara.workflow.api.domain.StartMessageEventDTO;
import org.dromara.workflow.api.domain.StartTimerEventDTO;
import org.dromara.workflow.api.domain.TriggerMessageEventDTO;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class WorkflowServiceImplEventTest {
private final WorkflowServiceImpl service = new WorkflowServiceImpl(null, null);
@Test
void messageStartPassesPayloadAndHandler() {
StartMessageEventDTO request = new StartMessageEventDTO();
request.setBusinessId("order-1");
request.setFlowCode("order_flow");
request.setEventName("paid");
request.setPayload(Map.of("amount", 20));
request.setHandler("42");
InsService insService = mock(InsService.class);
Instance instance = mock(Instance.class);
when(instance.getId()).thenReturn(100L);
when(insService.startByMessage(eq("order-1"), eq("order_flow"), eq("paid"), eq(request.getPayload()),
any(WorkflowContext.class))).thenReturn(instance);
try (MockedStatic<FlowEngine> engine = mockStatic(FlowEngine.class)) {
engine.when(FlowEngine::insService).thenReturn(insService);
assertEquals(100L, service.startByMessageEvent(request));
ArgumentCaptor<WorkflowContext> context = ArgumentCaptor.forClass(WorkflowContext.class);
verify(insService).startByMessage(eq("order-1"), eq("order_flow"), eq("paid"),
eq(request.getPayload()), context.capture());
assertEquals("42", context.getValue().getHandler());
}
}
@Test
void timerStartGeneratesNumericBusinessIdAndUsesSystemHandler() {
StartTimerEventDTO request = new StartTimerEventDTO();
request.setDefinitionId(10L);
request.setNodeCode("timerStart");
InsService insService = mock(InsService.class);
Instance instance = mock(Instance.class);
when(instance.getId()).thenReturn(200L);
when(insService.startByTimer(any(String.class), eq(10L), eq("timerStart"),
any(), any(WorkflowContext.class))).thenReturn(instance);
WarmFlow config = mock(WarmFlow.class);
when(config.getSystemHandler()).thenReturn("system");
try (MockedStatic<FlowEngine> engine = mockStatic(FlowEngine.class)) {
engine.when(FlowEngine::insService).thenReturn(insService);
engine.when(FlowEngine::getFlowConfig).thenReturn(config);
assertEquals(200L, service.startByTimerEvent(request));
ArgumentCaptor<String> businessId = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<WorkflowContext> context = ArgumentCaptor.forClass(WorkflowContext.class);
verify(insService).startByTimer(businessId.capture(), eq(10L), eq("timerStart"),
any(), context.capture());
assertTrue(businessId.getValue().matches("[0-9]+"));
assertEquals("system", context.getValue().getHandler());
}
}
@Test
void timerStartKeepsProvidedBusinessIdAndHandler() {
StartTimerEventDTO request = new StartTimerEventDTO();
request.setBusinessId("manual-42");
request.setDefinitionId(10L);
request.setNodeCode("timerStart");
request.setHandler("operator");
request.setPayload(Map.of("reason", "retry"));
InsService insService = mock(InsService.class);
Instance instance = mock(Instance.class);
when(instance.getId()).thenReturn(201L);
when(insService.startByTimer(eq("manual-42"), eq(10L), eq("timerStart"),
eq(request.getPayload()), any(WorkflowContext.class))).thenReturn(instance);
try (MockedStatic<FlowEngine> engine = mockStatic(FlowEngine.class)) {
engine.when(FlowEngine::insService).thenReturn(insService);
assertEquals(201L, service.startByTimerEvent(request));
ArgumentCaptor<WorkflowContext> context = ArgumentCaptor.forClass(WorkflowContext.class);
verify(insService).startByTimer(eq("manual-42"), eq(10L), eq("timerStart"),
eq(request.getPayload()), context.capture());
assertEquals("operator", context.getValue().getHandler());
}
}
@Test
void messageAndErrorEventsDelegateToEngine() {
TriggerMessageEventDTO request = new TriggerMessageEventDTO();
request.setInstanceId(300L);
request.setEventName("confirmed");
request.setHandler("42");
InsService insService = mock(InsService.class);
TaskService taskService = mock(TaskService.class);
Instance instance = mock(Instance.class);
when(instance.getId()).thenReturn(300L);
when(insService.triggerMessage(eq(300L), eq("confirmed"), any(), any(WorkflowContext.class)))
.thenReturn(instance);
when(insService.throwError(300L, "external_error", "failed")).thenReturn(true);
when(taskService.throwError(400L, "external_error", "failed")).thenReturn(true);
try (MockedStatic<FlowEngine> engine = mockStatic(FlowEngine.class)) {
engine.when(FlowEngine::insService).thenReturn(insService);
engine.when(FlowEngine::taskService).thenReturn(taskService);
assertEquals(300L, service.triggerMessageEvent(request));
ArgumentCaptor<WorkflowContext> context = ArgumentCaptor.forClass(WorkflowContext.class);
verify(insService).triggerMessage(eq(300L), eq("confirmed"), any(), context.capture());
assertEquals("42", context.getValue().getHandler());
assertTrue(service.throwInstanceErrorEvent(300L, "external_error", "failed"));
assertTrue(service.throwTaskErrorEvent(400L, "external_error", "failed"));
}
}
}

View File

@ -0,0 +1,111 @@
{
"version": "1",
"category": "1762300000000000100",
"flowCode": "demo_event_cooldown",
"flowName": "账号注销-冷静期示例",
"formPath": "/workflow/leaveEdit/index",
"nodeList": [
{
"ext": "[]",
"nodeCode": "start",
"nodeName": "开始",
"nodeType": 0,
"skipList": [
{
"skipType": "PASS",
"coordinate": "85,200;160,200",
"nowNodeCode": "start",
"nextNodeCode": "apply"
}
],
"nodeRatio": "0",
"coordinate": "60,200|60,200",
"formCustom": "N"
},
{
"ext": "[{\"code\":\"ButtonPermissionEnum\",\"value\":\"copy,back,termination,file\"}]",
"nodeCode": "apply",
"nodeName": "提交注销申请",
"nodeType": 1,
"skipList": [
{
"skipType": "PASS",
"coordinate": "260,200;310,200",
"nowNodeCode": "apply",
"nextNodeCode": "cooldown"
}
],
"nodeRatio": "0",
"coordinate": "210,200|210,200",
"formCustom": "N",
"permissionFlag": "warmFlowInitiator"
},
{
"ext": "[{\"code\":\"ButtonPermissionEnum\",\"value\":\"copy,back,termination,file\"}]",
"nodeCode": "confirm",
"nodeName": "注销终审确认",
"nodeType": 1,
"skipList": [
{
"skipType": "PASS",
"coordinate": "560,200;610,200",
"nowNodeCode": "confirm",
"nextNodeCode": "msgThrow"
}
],
"nodeRatio": "0",
"coordinate": "510,200|510,200",
"formCustom": "N",
"permissionFlag": "1761100000000000001"
},
{
"ext": "[]",
"nodeCode": "cooldown",
"nodeName": "冷静期 2 分钟",
"nodeType": 1,
"skipList": [
{
"skipType": "PASS",
"coordinate": "410,200;460,200",
"nowNodeCode": "cooldown",
"nextNodeCode": "confirm"
}
],
"eventType": "TIMER_CATCH",
"nodeRatio": "0",
"coordinate": "360,200|360,200",
"formCustom": "N",
"nodeConfig": "{\"event\":{\"timerType\":\"AFTER\",\"timerValue\":\"PT2M\"}}"
},
{
"ext": "[]",
"nodeCode": "msgThrow",
"nodeName": "注销结果推送",
"nodeType": 1,
"skipList": [
{
"skipType": "PASS",
"coordinate": "710,200;755,200",
"nowNodeCode": "msgThrow",
"nextNodeCode": "end"
}
],
"eventName": "membership.cancelled",
"eventType": "MESSAGE_THROW",
"nodeRatio": "0",
"coordinate": "660,200|660,200",
"formCustom": "N"
},
{
"ext": "[]",
"nodeCode": "end",
"nodeName": "结束",
"nodeType": 2,
"nodeRatio": "0",
"coordinate": "780,200|780,200",
"formCustom": "N"
}
],
"formCustom": "N",
"modelValue": "CLASSICS"
}

Some files were not shown because too many files have changed in this diff Show More