给若依加审批流,不用 Flowable

作者:mldong日期:2026/9/16

引言

若依是国内最流行的快速开发框架之一,GitHub 镜像 4 万多 star,几乎每个 Java 后台项目都从它起步。但它有个大家都知道的"缺口":官方免费版不内置审批流

用若依的人,迟早要碰审批——请假、报销、合同、采购。怎么补?

这篇文章给一个不绕弯子的答案:别上 Flowable,用 jeeflow。后端一个依赖一个薄壳,前端一个宿主组件九个薄页,配一条菜单 SQL,半天跑通一个带工作台、发起、待办、审批、流程设计器的完整流程中心。

我把整个过程真机走了一遍(若依 3.9.2 master 分支,Spring Boot 4.1.0 + JDK 17),造了一批发起、会签、网关分流、退回重提、拒绝、抄送的真实演示数据,下面全是实录截图。

一、先看清现状:若依到底有没有审批流

先把话说清楚,免得被标题党带节奏:

若依官方是活跃的。 master 分支最近还在升级 Spring Boot 4.1.0、poi 5.5.1(2026-08 的提交),项目本身没死。

但"活跃"和"有审批流"是两回事。若依官方只有三条主线:master(Boot 4)、springboot3springboot2没有工作流/审批流模块,也没有相关分支。它的定位是"用户管理、部门岗位、菜单权限、字典参数、代码生成"这一套基础脚手架,审批从来不在范围内。

所以给若依加审批流,实际上只有三条路:

路线做法代价
① 找付费/二开版用 RuoYi-Vue-Plus、某些商业增强版里的工作流绑定特定衍生版,升级跟着它走
② 自集成 Flowable引 Flowable 引擎,自己写业务适配学习曲线陡、BPMN 模型重、和若依权限体系要硬接
③ 轻量引擎薄集成用一个的工作流引擎,只写映射层引擎得够轻、契约得够清

本文走第三条。选 jeeflow 的理由就一句话:它是为"快速开发框架集成"而生的薄引擎,门面契约、权限码、选人字典这些和后台框架的对接点都做好了,你只写"若依侧"那一薄层。

后面会看到,这一薄层有多薄。

二、为什么不用 Flowable

不踩一捧一,但对比是客观的。Flowable 是成熟的 BPMN 引擎,能力强,但它的"重量"和若依这种"薄脚手架"场景是错配的:

  • 模型重。BPMN XML + 图形建模器,一个请假流程要维护一整套模型文件。jeeflow 的流程定义就是一段 JSON(本文第四节有完整示例),设计器也是 JSON 驱动。
  • 学习曲线。Flowable 的 Process / Task / Execution / 监听器 / Delegate 那一套,上手要花时间。jeeflow 对外只有一个门面,45 个 action,POST 一下就完事。
  • 权限对接。Flowable 自带一套 identity 用户体系,跟若依的 sys_user / sys_role / sys_menu 是两套账本,得写桥。jeeflow 把"用户是谁""有没有权限""按什么角色找人"全做成 SPI 钩子,你填若依的 Service 就行(第三节)。
  • 前端。Flowable 官方前端要自己拼。jeeflow 有个 npm 组件包 @mldong/jeeflow-ui,工作台/发起/待办/已办/我发起的/抄送/流程定义/流程设计/委托九个页面开箱即用,而且每个页面都能单独挂进宿主的菜单体系(第五节),不是塞给你一个iframe或者一套独立布局。

如果你的项目要上复杂 BPMN(会签矩阵、动态路由、跨系统编排),Flowable 依然是对的。但"若依加个请假/报销审批"这种 80% 的场景,jeeflow 的薄集成更省。

三、后端:一个依赖 + 一个薄壳

3.1 加依赖

若依 master 是 Spring Boot 4,对应 jeeflow 的 spring-boot4 starter:

1<!-- ruoyi-admin/pom.xml -->
2<dependency>
3    <groupId>com.mldong.jeeflow</groupId>
4    <artifactId>jeeflow-spring-boot4-starter</artifactId>
5    <version>1.8.27</version>
6</dependency>
7

这一个依赖进去,starter 自动装配好引擎核心:JeeflowEngine、JDBC 仓储、雪花 ID 生成、Jackson(Long→String,前端不丢精度)、事务模板、SpEL 表达式求值器、默认用户 Provider。全部分 @ConditionalOnMissingBean,你想覆盖哪个就自己 @Bean 哪个。

注意:不用覆盖 ID 生成器。若依没引 MyBatis-Plus,没有"雪花 vs 自增主键"的对齐问题,starter 内置雪花直接能用。这是"薄"的体现——少写一个 Bean。

3.2 只补两个 Bean

starter 不自动装配的就两样:设计/历史/委托三表的扩展仓储,和统一门面 JeeflowFacade。一个配置类搞定:

1@Configuration
2public class WfJeeflowConfig {
3
4    @Bean
5    @ConditionalOnMissingBean
6    public IProcessExtRepository jeeflowExtRepository(DataSource dataSource) {
7        return new JdbcProcessExtRepository(dataSource);
8    }
9
10    @Bean
11    @ConditionalOnMissingBean
12    public JeeflowFacade jeeflowFacade(JeeflowEngine engine,
13                                       IProcessRepository repository,
14                                       IProcessExtRepository extRepository,
15                                       ObjectProvider<IUserSearchProvider> userSearchProvider) {
16        JeeflowFacade facade = new JeeflowFacade(engine, repository, extRepository);
17        userSearchProvider.ifAvailable(facade::setUserSearchProvider);
18        return facade;
19    }
20}
21

3.3 一个 Controller 吃下所有请求

jeeflow 的门面是"一个入口,45 个 action"——POST /wf/{action},body 是参数 map,返回 {code: 0, msg, data}。所以若依侧只需要一个 Controller

1@RestController
2@RequestMapping("/wf")
3public class WfFlowController {
4
5    @Autowired
6    private JeeflowFacade jeeflowFacade;
7
8    @PostMapping("/**")
9    public Map<String, Object> flow(HttpServletRequest request,
10                                    @RequestBody(required = false) Map<String, Object> body) {
11        String uri = request.getRequestURI();
12        int idx = uri.indexOf("/wf/");
13        String action = uri.substring(idx + "/wf/".length());
14
15        checkPermission(action);                       //  权限校验
16
17        if (body == null) body = new LinkedHashMap<>();
18        body.put("operator", SecurityUtils.getUserId().toString());  //  注入操作人
19
20        return jeeflowFacade.flow(action, body);       //  门面透传
21    }
22
23    private void checkPermission(String action) {
24        if (SecurityUtils.isAdmin()) return;           // admin 万能放行
25        IActionPermissionProvider provider = ServiceContext.find(IActionPermissionProvider.class);
26        String[] codes = provider == null ? null : provider.permissionCodes(action);
27        if (codes != null && codes.length > 0) {
28            for (String code : codes) {
29                if (SecurityUtils.hasPermi(code)) return;   // 任一权限码命中即可
30            }
31            throw new ServiceException("无权限执行操作:" + action, 403);
32        }
33    }
34}
35

三件事,全是若依现成的能力:

  1. 权限:引擎默认 Provider 把每个 action 映射成 wf:{action} 权限码(如 processTask/todoList → wf:processTask:todoList),SecurityUtils.hasPermi 走若依的 RBAC(sys_menu 按钮)。
  2. 操作人SecurityUtils.getUserId() 从若依登录态(Spring Security 的 LoginUser)取,塞进 args.operator——门面靠它判断"我的待办/我发起的"。
  3. 透传:返回的是 jeeflow 门面原生结构 code: 0不包若依的 AjaxResult(code: 200。这是有意的——前端 jeeflow-ui 只认门面契约,包一层反而要拆。

3.4 三个 Provider:把若依的用户体系喂给引擎

引擎通过 SPI 问宿主三个问题:用户是谁、怎么搜人、按部门/角色怎么找人。各写一个 Provider 接若依 Service:

  • RyUserProviderIUserProvider):selectUserByIdnickName 当姓名、取部门名。
  • RyUserSearchProviderIUserSearchProvider):候选人搜索,按昵称/账号模糊匹配。
  • RyOrgUserProviderIOrgUserProvider):findDeptLeaders(部门负责人)、findByRole(按角色找成员)——审批流里"派给部门主管""派给某角色"就靠它。

这三个是"若依侧"唯一要写的业务代码。没有之一。

3.5 种子流程:让系统开箱就有个流程

若依首次启动,我加了个 ApplicationRunner 幂等地上架一个"请假申请"流程(走的是和设计器完全相同的 processDesign/save → deploy 链路,不是直接插表):

1// WfFlowSeedRunner:启动时幂等上架 leave 流程
2// 1.  processDesign/page,name=leave 已发布就跳过
3// 2. processDesign/save(content = 流程 JSON 字符串)→  designId
4// 3. processDesign/deploy(designId)
5

为什么走门面而不是直接 insert?因为 listByType(发起页的数据源)读的是 design 表 + 历史表,不是 define 表。直接插 define 表,发起页看不到。走 save→deploy 才能保证和人工在界面上设计、发布走的是同一条路。

开箱只有请假一条还不够演示。我又写了三个流程定义(报销/采购/加班,JSON 见第四节),用同一条 save→deploy 链路上架——脚本调门面和在"流程设计"页面上点发布,后端是同一个动作。最终发起页有四张流程卡片:

四、四个流程定义:从直线到网关和会签

流程定义就是 JSON。四个流程由简到繁,正好把 jeeflow 的核心概念全覆盖:直线审批、决策网关、并行会签、字段权限、抄送

4.1 leave:最简直线审批(全文)

1{
2  "name": "leave",
3  "displayName": "请假申请",
4  "type": "approval",
5  "enableFieldPerm": true,
6  "__schema__": {
7    "columns": [
8      { "fieldName": "reason",    "remark": "请假事由", "component": "Textarea", "ext": { "required": 1, "placeholder": "请说明请假原因" } },
9      { "fieldName": "days",      "remark": "请假天数", "component": "Number",   "ext": { "required": 1 } },
10      { "fieldName": "leaveType", "remark": "请假类型", "component": "Select",
11        "ext": { "required": 1, "options": [
12          { "label": "年假", "value": "annual" },
13          { "label": "事假", "value": "personal" },
14          { "label": "病假", "value": "sick" } ] } },
15      { "fieldName": "startDate", "remark": "开始日期", "component": "Date" },
16      { "fieldName": "endDate",   "remark": "结束日期", "component": "Date" }
17    ]
18  },
19  "nodes": [
20    { "id": "start",   "type": "snaker:start", "text": { "value": "开始" } },
21    { "id": "apply",   "type": "snaker:task",
22      "properties": { "form": "leave-form", "assignee": "applicant" },
23      "text": { "value": "填写申请" } },
24    { "id": "approve", "type": "snaker:task",
25      "properties": { "form": "leave-form", "assignee": "1",
26        "field": { "PERMISSION_f_reason": 1, "PERMISSION_f_days": 1, "PERMISSION_f_leaveType": 1 } },
27      "text": { "value": "上级审批" } },
28    { "id": "end",     "type": "snaker:end", "text": { "value": "结束" } }
29  ],
30  "edges": [
31    { "sourceNodeId": "start",   "targetNodeId": "apply" },
32    { "sourceNodeId": "apply",   "targetNodeId": "approve" },
33    { "sourceNodeId": "approve", "targetNodeId": "end" }
34  ]
35}
36

几个值得注意的点:

  • __schema__.columns 就是表单,前端零代码渲染(第五节)。不用为"请假"写一个 Vue 表单组件。
  • assignee: "applicant" = 发起人本人填;assignee: "1" = 字面 userId(这里指 admin)。还能写 deptLeaderroleCode 这种,由 RyOrgUserProvider 解析。
  • field 里的 PERMISSION_f_xxx: 1 = 该字段在审批节点只读(1=只读 / 2=可编辑 / 3=隐藏)。审批人能看到"请假事由"但改不了。

4.2 purchase:金额网关分流

采购申请,预算 5000 是分水岭:以下部门审批,以上总经理审批。核心就是一个决策节点 + 两条带表达式的边

1{ "id": "decision1", "type": "snaker:decision",
2  "properties": { "expr": "#f_amount > 5000" },
3  "text": { "value": "金额>5000?" } }
4

两条出边的表达式:

1{ "sourceNodeId": "decision1", "targetNodeId": "deptApprove",
2  "properties": { "expr": "#f_amount <= 5000" }, "text": { "value": "≤5000" } },
3{ "sourceNodeId": "decision1", "targetNodeId": "gmApprove",
4  "properties": { "expr": "#f_amount > 5000" },  "text": { "value": ">5000" } }
5

这里有个真机踩出来的坑,值得单独说:表达式里引用表单字段,必须写成 #f_amount——井号引用 + f_ 前缀,两个都不能少。

  • f_ 前缀:jeeflow-ui 发起时给所有表单字段统一加 f_ 前缀提交(f_amountf_reason),这是引擎和前端约定好的业务参数命名空间,避免和引擎内置参数撞名。
  • # 引用:jeeflow 默认的 SpEL 求值器按 #key 从流程变量里取值。

我第一次写成了裸的 amount <= 5000,发起 86000 的采购单,引擎直接报"decision节点无法确定下一步执行路线"——两条边都不命中。对着求值器源码才看清替换规则。写文章之前替你们踩了。

4.3 baoxiao:并行会签

报销申请,经理和财务并行会签,两人全部同意才过:

1{ "id": "countersign", "type": "snaker:task",
2  "properties": {
3    "form": "baoxiao-form",
4    "assignee": "1,2",
5    "performType": "1",
6    "countersignType": "PARALLEL",
7    "field": { "PERMISSION_f_reason": 1, "PERMISSION_f_amount": 1,
8               "PERMISSION_f_invoiceCount": 1, "PERMISSION_f_expenseDate": 1 } },
9  "text": { "value": "经理财务会签" } }
10

三个属性缺一不可:assignee: "1,2" 逗号分隔多个审批人;performType: "1" 表示会签(每人一票);countersignType: "PARALLEL" 表示并行(任务同时产生)。改成 SEQUENTIAL 就是串行会签——按顺序逐个审。

4.4 overtime:最简审批 + 抄送

加班申请走"发起 → 上级审批"直线,抄送不占节点——发起时带 ccActors 参数就行,抄送对象会在发起瞬间收到一条抄送记录(我的抄送页可见)。

四个流程,定义文件加起来不到 400 行 JSON,没有一个 Java 类、没有一个 Vue 组件

五、前端:一个宿主组件 + 九个薄页

若依前端是 RuoYi-Vue3(Vite + Vue3 + Element Plus + Pinia)。jeeflow-ui 的九个页面(工作台/发起/待办/已办/我发起的/抄送/流程定义/流程设计/委托)不是塞给你一坨独立布局,而是九个可以独立挂载的页面组件——正确姿势是把它们挂进若依自己的菜单体系,让"工作流"成为若依左侧菜单里的一级目录,跟"系统管理"平起平坐。

前端要写的东西分三块:main.js 两行、一个宿主组件、九个 8 行的薄页

5.1 main.js:引一次样式

1import '@mldong/jeeflow-ui/dist/style.css' // jeeflow 流程中心组件样式
2

就一行。但这一行有个故事——见第八节,这行在 1.0.0 时代写了会报错,因为包漏导出了这个文件,1.0.1 才修。

5.2 wf-host.vue:全集成唯一值得细看的文件

九个页面共用一个宿主组件,职责就四件事:注入若依的身份与接口、桥接权限、映射页间跳转

1<template>
2  <JeeflowUiProvider :config="jfConfig">
3    <component :is="page" @goto="onGoto" />
4  </JeeflowUiProvider>
5</template>
6
7<script setup>
8import { useRouter } from 'vue-router'
9import { JeeflowUiProvider } from '@mldong/jeeflow-ui'
10import request from '@/utils/request'
11import { getToken } from '@/utils/auth'
12import useUserStore from '@/store/modules/user'
13
14defineProps({
15  page: { type: [Object, Function], required: true },
16})
17
18const router = useRouter()
19const userStore = useUserStore()
20
21// 通配权限匹配(对齐若依后端 PatternMatchUtils.simpleMatch)
22function globMatch(pattern, str) {
23  if (!pattern || !str) return false
24  if (pattern === '*:*:*') return true
25  if (pattern === str) return true
26  const re = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
27  return new RegExp(re).test(str)
28}
29
30// hasPermission(codes[]):任一命中即放行
31function hasPermission(codes) {
32  const list = Array.isArray(codes) ? codes : [codes]
33  if (!list.length) return false
34  const mine = userStore.permissions || []
35  return list.some(code => mine.some(perm => globMatch(perm, code)))
36}
37
38// 宿主 adapter:把若依 REST 喂给流程中心(选人/字典/上传)
39const adapters = {
40  getDict: async (code) => {
41    const res = await request.get([`/system/dict/data/type/${code}`](https://xplanc.org/primers/document/zh/02.Python/EX.%E5%86%85%E5%BB%BA%E5%87%BD%E6%95%B0/EX.dict.md))
42    return (res.data || []).map(d => ({ value: d.dictValue, label: d.dictLabel }))
43  },
44  upload: async (file) => {
45    const fd = new FormData()
46    fd.append('file', file)
47    const res = await request.post('/common/upload', fd,
48      { headers: { 'Content-Type': 'multipart/form-data' } })
49    return res.url || res.fileName
50  },
51  listUsers: async (keyword) => {
52    const res = await request.get('/system/user/list',
53      { params: { pageNum: 1, pageSize: 50, userName: keyword || undefined } })
54    return (res.rows || []).map(u => ({
55      userId: String(u.userId), realName: u.nickName, deptName: u.dept?.deptName || ''
56    }))
57  },
58}
59
60const jfConfig = {
61  baseUrl: import.meta.env.VITE_APP_BASE_API,   //  vite proxy  :8080
62  getToken: () => getToken(),                   // 若依 JWT(js-cookie Admin-Token)
63  getOperator: () => (userStore.id != null ? String(userStore.id) : ''),
64  hasPermission,
65  adapters,
66}
67
68// 组件内页间跳转(key 由组件 emit)→ 若依路由
69const routeMap = {
70  workbench: '/workflow/workbench',
71  apply: '/workflow/apply',
72  todo: '/workflow/todo',
73  done: '/workflow/done',
74  mine: '/workflow/mine',
75  cc: '/workflow/cc',
76  define: '/workflow/define',
77  design: '/workflow/design',
78  surrogate: '/workflow/surrogate',
79}
80
81function onGoto(key) {
82  if (routeMap[key]) router.push(routeMap[key])
83}
84</script>
85

JeeflowUiProvider 是个依赖注入壳——它不绑死任何宿主 REST,token、当前用户、权限判断、选人/字典/上传全是你从外面塞进去的函数。你塞若依的,它就打若依的接口;塞别的系统的,它就打别的。

三个细节:

  • token:若依用 js-cookieAdmin-Token,JWT 格式 Bearer <token>,和 jeeflow-ui 要的一致,直接 getToken() 透传。
  • 权限:若依前端 plugins/auth.js精确匹配,没有通配;而菜单上我们把引擎细粒度码收成了通配按钮(wf:processDesign:*,第六节)。所以宿主里做了个 globMatch 桥接,让 wf:processDesign:* 能匹配 wf:processDesign:listByType
  • 跳转:九个页面之间会互相跳(比如工作台点"待办数"跳待办页),jeeflow-ui 只 emit 一个 goto 事件带页面 key,落地的路由由宿主决定——这就是它能"寄生"在任何宿主菜单体系里的关键。

5.3 九个薄页:每个 8 行

每个页面就是"宿主 + 对应的 Page 组件",以"我的待办"为例:

1<template>
2  <JfHost :page="JfTodoPage" />
3</template>
4
5<script setup>
6import JfHost from '../wf-host.vue'
7import { JfTodoPage } from '@mldong/jeeflow-ui'
8</script>
9

其余八个(workbench/apply/done/mine/cc/define/design/surrogate)一模一样,只是 import 的 Page 组件不同。九个文件合计 70 行出头。

路由都不用手写——若依的动态路由会读 sys_menucomponent 字段,指向 views/workflow/todo/index 就自动加载。所以挂菜单就是第六节那条 SQL 的事。

为什么前端这么少?因为表单也不归前端管——发起表单、审批回显全部由流程 JSON 的 __schema__ 驱动渲染,业务侧零表单代码

六、RBAC:1 个目录 + 9 个页面 + 5 个通配按钮

菜单结构完全按若依的习惯来:一级目录"工作流",下面九个菜单项各挂一个页面,权限码用引擎的细粒度码(页面级入口),再配 5 个 F 型通配按钮把一族 action 的权限收口:

1-- 清旧段(2000-2099),保证可重复导入
2delete from sys_role_menu where menu_id between 2000 and 2099;
3delete from sys_menu where menu_id between 2000 and 2099;
4
5-- 一级目录:工作流
6insert into sys_menu values('2000', '工作流', 0, 5, 'workflow', null, '', '', 1, 0, 'M', '0', '0', '', 'tree', 'admin', sysdate(), '', null, 'jeeflow 工作流');
7
8-- 九个页面菜单(动态路由,component 相对 views/)
9insert into sys_menu values('2001', '工作台',   2000, 1, 'workbench', 'workflow/workbench/index', '', '', 1, 0, 'C', '0', '0', 'wf:workbench',                'dashboard',     'admin', sysdate(), '', null, 'jeeflow 工作台');
10insert into sys_menu values('2002', '发起申请', 2000, 2, 'apply',     'workflow/apply/index',     '', '', 1, 0, 'C', '0', '0', 'wf:processDesign:listByType', 'form',          'admin', sysdate(), '', null, 'jeeflow 发起申请');
11insert into sys_menu values('2003', '我的待办', 2000, 3, 'todo',      'workflow/todo/index',      '', '', 1, 0, 'C', '0', '0', 'wf:processTask:todoList',     'checkbox',      'admin', sysdate(), '', null, 'jeeflow 我的待办');
12insert into sys_menu values('2004', '我的已办', 2000, 4, 'done',      'workflow/done/index',      '', '', 1, 0, 'C', '0', '0', 'wf:processTask:doneList',     'time',          'admin', sysdate(), '', null, 'jeeflow 我的已办');
13insert into sys_menu values('2005', '我发起的', 2000, 5, 'mine',      'workflow/mine/index',      '', '', 1, 0, 'C', '0', '0', 'wf:processInstance:page',     'list',          'admin', sysdate(), '', null, 'jeeflow 我发起的');
14insert into sys_menu values('2006', '我的抄送', 2000, 6, 'cc',        'workflow/cc/index',        '', '', 1, 0, 'C', '0', '0', 'wf:processInstance:ccList',   'message',       'admin', sysdate(), '', null, 'jeeflow 我的抄送');
15insert into sys_menu values('2007', '流程定义', 2000, 7, 'define',    'workflow/define/index',    '', '', 1, 0, 'C', '0', '0', 'wf:processDefine:page',       'documentation', 'admin', sysdate(), '', null, 'jeeflow 流程定义');
16insert into sys_menu values('2008', '流程设计', 2000, 8, 'design',    'workflow/design/index',    '', '', 1, 0, 'C', '0', '0', 'wf:processDesign:page',       'build',         'admin', sysdate(), '', null, 'jeeflow 流程设计');
17insert into sys_menu values('2009', '我的委托', 2000, 9, 'surrogate', 'workflow/surrogate/index', '', '', 1, 0, 'C', '0', '0', 'wf:processSurrogate:page',    'peoples',       'admin', sysdate(), '', null, 'jeeflow 我的委托');
18
19-- 五个通配按钮(权限码收口:引擎细粒度码由若依 simpleMatch 通配命中)
20insert into sys_menu values('2020', '流程发起', 2000, 20, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processDesign:*',   '#', 'admin', sysdate(), '', null, '发起/设计/上架/发布');
21insert into sys_menu values('2021', '任务办理', 2000, 21, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processTask:*',     '#', 'admin', sysdate(), '', null, '待办/已办/执行/转办');
22insert into sys_menu values('2022', '实例查看', 2000, 22, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processInstance:*', '#', 'admin', sysdate(), '', null, '实例分页/详情/抄送');
23insert into sys_menu values('2023', '定义管理', 2000, 23, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processDefine:*',   '#', 'admin', sysdate(), '', null, '定义增删改/版本');
24insert into sys_menu values('2024', '流程委托', 2000, 24, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processSurrogate:*','#', 'admin', sysdate(), '', null, '委托增删改查');
25
26-- 授权普通角色(common, role_id=2):9 个页面 + 5 个按钮
27-- (按需裁剪:管理页 2007/2008/2009 与对应按钮可不给普通角色)
28insert into sys_role_menu values ('2','2000'),('2','2001'),('2','2002'),('2','2003'),
29  ('2','2004'),('2','2005'),('2','2006'),('2','2007'),('2','2008'),('2','2009'),
30  ('2','2020'),('2','2021'),('2','2022'),('2','2023'),('2','2024');
31

能这么收,靠的是若依 SecurityUtils.hasPermi 底层用的 PatternMatchUtils.simpleMatch——天然支持 * 通配。引擎给 wf:processTask:todoList,按钮配 wf:processTask:*,一命中就放行。45 个 action 一个按钮都没漏。

真机验证了三种情况:

场景账号结果
正向:全流程admin(超级管理员)发起→待办→审批→记录 全通
正向:普通角色ry(common 角色)发起成功,走 admin 审批
负向:无权限noperm(无角色用户)listByType / startAndExecute 返回 403,approvalRecord(免权限)放行

负向那条尤其关键——它证明了权限不是"登录就能全干",而是真落在若依 RBAC 上。

七、跑通实录

环境:若依 3.9.2(master,Boot 4.1.0 / JDK 17),MySQL 8(jeeflow 引擎 8 张 wf_* 表),Redis。

7.1 造一批真数据

空系统的截图是没有说服力的。我参考 jeeflow 商业演示站的数据语义,用脚本走真实 API(登录→发起→审批,一步没有跳过)造了一批演示数据——4 个流程、8 条实例,把审批流最常见的几种生命周期全覆盖:

#流程剧本终态
S1请假ry 发起 → admin 同意✅ 走完
S2请假admin 自己发起自己审🔵 在途
S3报销ry 发起 → 经理+财务并行会签,两人全同意✅ 走完
S4请假ry 发起 → admin 退回发起人 → ry 改理由重新提交 → admin 同意✅ 走完
S5采购admin 发 4200 元(小额)→ 走部门审批🔵 在途(ry 待办)
S6采购ry 发 86000 元(大额)→ 走总经理审批🔵 在途(admin 待办)
S7加班ry 发起(抄送 admin)→ admin 拒绝❌ 被拒
S8加班ry 发起 → admin 同意✅ 走完

(S4 那条是故意演的:退回重提是审批系统里最考验数据完整性的场景,审批记录的时间线必须把"退回→重提→同意"全程记下来,见 7.3。)

7.2 接口层(curl):一条链走通

1# 1. 登录拿 token
2curl -s -X POST http://localhost:8080/login -H "Content-Type: application/json" \
3  -d '{"username":"admin","password":"admin123","code":"13","uuid":"<验证码uuid>"}'
4#  {"code":200,"token":"eyJhbGciOiJIUzUxMiJ9..."}
5
6# 2. 发起(processDefine/startAndExecute,表单字段 f_ 前缀)
7curl -s -X POST http://localhost:8080/wf/processDefine/startAndExecute \
8  -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
9  -d '{"processDefineId":15328901147922432,"f_reason":"家里有事","f_days":2,"f_leaveType":"personal"}'
10#  {"code":0,"msg":"成功","data":{"processInstanceId":15328916897009664}}
11
12# 3. 查待办(processTask/todoList)
13# 4. 审批同意(processTask/execute,submitType=1)
14curl -s -X POST http://localhost:8080/wf/processTask/execute \
15  -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
16  -d '{"processTaskId":15328926470901760,"submitType":1,"tf_approvalComment":"同意,注意工作交接"}'
17#  {"code":0,"msg":"成功","data":null}
18
19# 5. 审批记录(processInstance/approvalRecord)
20#  apply 节点 + approve 节点,中文意见原样落库
21

⚠️ 一个 Windows 上的坑:用 curl -d '{...含中文...}' 直接传中文,MSYS 的命令行参数编码会把中文变 ?(库里落一堆问号)。改用 --data-binary @file.json 从文件读,就正常了。跟若依、跟 jeeflow 都没关系,是 MSYS 的事——但如果你在 Windows 上用 curl 测,会遇到,先排掉这个再怀疑框架。

7.3 界面层:一个完整的流程中心

登录进首页,左侧菜单多了一级目录"工作流",九个菜单项全部就位:

工作台——在办/办结/发起/抄送四个统计卡、近 7 日发起趋势、热点流程 Top5、流程状态分布。这不是占位图表,数据全部来自引擎真实统计接口:

发起申请——四张流程卡片,还在途的流程会标"N 条在办"徽标。点开"报销申请",__schema__ 表单自动渲染:事由、金额、发票张数、费用日期,必填校验齐全:

我的待办——admin 登录,待办里躺着"总经理审批"(S6 采购 86000 大额)和"上级审批"(S2 自发起的请假):

点"办理",抽屉上半屏是申请信息只读回显——采购内容"测试服务器 2 台"、预算 86000、供应商、期望日期,审批人看得到但改不了(字段权限 PERMISSION_f_xxx: 1 在起作用);下半屏是审批意见和动作:

动作不止"同意/拒绝"——六种审批动作开箱即用:同意、拒绝、退回上一步、退回发起人、跳转指定节点、转办:

审批记录——S4 那条"退回重提"的时间线,四步全程可溯:发起 → 上级审批(退回发起人,带退回意见)→ 发起人重新提交 → 上级审批(同意)。审批系统最怕"口说无凭",这条时间线就是凭据:

流程定义——四个流程、版本号、发布状态一目了然:

流程设计——每个流程的设计快照可进可视化设计器调整再发布(本文的流程 JSON 就是在这里管理的):

我发起的——ry 视角看自己发起的 5 条,在途/完成/拒绝状态分明:

我的抄送——S7 加班单发起时抄送了 admin,这里多了一条带"抄送"标记的记录:

从零到这一屏,后端一个依赖 + 一个薄壳 + 三个 Provider + 一个种子 Runner,前端一个宿主组件 + 九个 8 行薄页 + 一行样式引入 + 一条菜单 SQL。没有写过一个表单,没有写过一个列表页。

八、首次第三方集成,反哺了上游三个问题

说个背景:jeeflow 此前的所有集成(十几个框架)都是我自己维护的 mldong 系列,"自家的引擎配自家的框架",很多坑是暴露不出来的。这次若依集成是第一次以第三方视角从零接一遍,一天之内挖出了三个问题——两个在 ui 组件包,当场修了发版;一个在引擎,记了 issue。

① ui 包漏导出样式文件。 @mldong/jeeflow-uidist/style.css 文件在包里,但 package.jsonexports 字段没导出它——宿主 import '@mldong/jeeflow-ui/dist/style.css' 直接报 Missing specifier。Node 的 exports 语义下"包里有文件"和"允许被引用"是两回事。没有自己家的约定俗成兜底,第三方一接就现形。

② 办理抽屉画出重复的空表单。 审批人点"办理",抽屉下半屏本该只有审批意见框,却多画了一整份绑着空数据的业务表单——因为任务表单组件找不到宿主注册的审批表单时,回落渲染了流程级 __schema__ 表单,而 __schema__ 早就该由上半屏"申请信息"按字段权限渲染了。自家集成里表单注册习惯一致,这问题从来没冒过头。

这两个都在 jeeflow-ui 源头修复,发了 1.0.1(本文所有截图就是 1.0.1 的效果)。若依侧只需要 npm i @mldong/jeeflow-ui@^1.0.1

③ 引擎的并行会签+退回组合缺陷。 并行会签节点上,一人执行"退回发起人"后,兄弟审批人的会签任务不自动作废——僵尸任务继续挂在别人待办里,还会卡死会签的"全员完成"判定,实例永远走不到结束。这个是引擎级问题,集成层修不了,已提 issue 记录在案,留待引擎侧修。演示数据里我把"退回重提"闭环放在单人节点上演(S4),绕开了这个组合——该有的场景一样不少。

这大概是第三方集成最大的价值:它是一台天然的模糊测试机。所有"我们一直都这么用"的隐式约定,在第一个真正的外人面前全部现形。

九、这个集成,我删了

写到这里,集成是通的、验证是齐的。但得跟你说清楚定位,免得有人真拿它当产品用。

jeeflow 是一个"多语言联邦"的工作流引擎,我维护的下游有一长串:Java 的 boot2/3/4、FastAPI、NestJS、Laravel、Go 的 goframe/gin/hertz、Rust 的 salvo、C#、Python 的 Flask/Django……每个框架都有一套"基础 + 1 commit"的集成壳,全部进了我的验收矩阵,长期维护。

若依不在这个列表里。

上面这套若依集成,是我为了写这篇文章,在一个临时目录里从零走一遍流程做的实践示例——它证明了"jeeflow 能接进任何主流框架,前后端各一两个文件"这件事本身。它不会进我的维护矩阵,没有持续升级承诺,文章发完我会把临时环境清掉。

所以:

  • 如果你用 jeeflow,去看我维护的 mldong 快速开发框架系列(boot4 / FastAPI / goframe 等),那才是有长期保障的集成。
  • 若依这套,当作"怎么给一个存量 Spring 项目接 jeeflow"的参考样本,代码逻辑是通的,照搬到你的若依项目能跑;但它不是成品,别直接当生产依赖。

一个第三方集成,最大的价值不是"我维护它",而是"它证明了接入成本有多低"。若依这套的价值就在最后——前后端加起来,真正要写的业务代码,就那么三个 Provider、一个宿主组件和九个薄页。

审批流这个东西,不该是若依用户"要不要上 Flowable"的难题。它应该是"加个依赖,配个菜单,半天跑通"的常规操作。

(全文完)


给若依加审批流,不用 Flowable》 是转载文章,点击查看原文


相关推荐


Elasticsearch 中的查询重写规则:通配符扫描速度提升 2.3 倍
Elasticsearch2026/9/8

作者:来自 Elastic Parker Timmins, Martijn Van Groningen 第二条规则使空字符串过滤器的速度提升了 1.6 倍。它直接从偏移数组中读取字符串长度,完全不会访问压缩后的字节。这两条规则都源于同一种习惯:运行真实查询,并寻找特殊情况。 想获得 Elastic 认证?了解下一期 Elasticsearch 工程师培训何时开课!你现在可以开始免费云试用,或者立即在你的本地机器上试用 Elastic。 Lucene 查询重写规则让 Elasticsearch


六语言引擎实现对比:同构背后的妥协与差异
mldong2026/8/31

系列定位:jeeflow 系列第 11 篇(第三季「多语言联邦」第 3 篇 · 季终) 平台:掘金(深度对比)/ 公众号(故事线) 素材版本:引擎 Java 1.8.19 / Go·Python·Node 1.8.21 / PHP 1.3.6 / Rust 1.0.6(2026-08-30 六语言同日发版) 一、先看今天发生的一件事 写这篇的时候(2026-08-30),jeeflow 刚完成一次六语言同步发版:


Linux + Docker + FastAPI 工程化实践指南
卷无止境2026/8/23

一套稳健的 FastAPI 工程,不是把 API、Redis 和 PostgreSQL 塞进同一个 Compose 文件就算完事。更合理的做法是把应用设计成无状态服务,数据库迁移、配置管理、健康检查和测试各自承担清晰职责;开发环境追求反馈速度,生产环境则强调镜像可复现、最小权限和可观测性。 下面给出一套可以直接落地的项目骨架。 一、推荐架构 开发环境可以使用 Docker Compose 编排所有依赖,代码通过挂载目录实现热更新。生产环境仍然使用相同镜像,但不再挂载源代码,也不启用 --relo


把《天龙八部》装进向量数据库:EPUB加载、文本分块与RAG问答全链路实战
浮生望2026/8/10

摘要 以《天龙八部》EPUB拆解RAG全链路:EPubLoader章节加载、RecursiveCharacterTextSplitter分块、Milvus流式入库,实现语义问答。 一、一本百万字小说,如何让AI读懂它 上一篇文章我们用AI日记助手演示了RAG的基本流程:5篇日记、手动构造数据、一次插入。但真实场景中,数据源不是手动构造的,而是各种格式的文档——PDF、EPUB、CSV、Markdown。数据量也不是5条,而是百万字级别的小说。 这篇文章以金庸的《天龙八部》EPUB电子书为样本,


前端框架vue3,vite 开发前端项目实践步骤指南
慧一居士2026/8/1

Vue 3 + Vite 前端项目实践指南 适用版本(截至 2026 年 7 月):Vue 3.5+ | Vite 6/7 | TypeScript 5.6+ | Node.js ^20.19.0 || >=22.12.0 目标:从零搭建一个可投入生产的工程化项目,覆盖「初始化 → 架构 → 规范 → 联调 → 构建部署」全流程。 全景路线图 环境准备 ──▶ 脚手架创建 ──▶ 目录规划 ──▶ Vite 配置 ──▶ 路由/状态/请求层


Java Jersey 实战指南:用 JAX-RS 注解写清晰的 REST API
唐青枫2026/7/24

简介 Jersey 是 Jakarta RESTful Web Services 规范的一种实现。 老名字常叫 JAX-RS,新包名是: jakarta.ws.rs Jersey 自己的核心包名通常是: org.glassfish.jersey 简单理解: Jakarta REST / JAX-RS 是规范 Jersey 是实现 Spring Boot 提供 Jersey 自动配置和 starter Jersey 的开发方式是用注解把 Java 类声明成 HTTP 资源: @Path("/


数据结构之双链表
无忧.芙桃2026/7/16

本篇目标: 1. 学会关于双链表的相关操作 2. 了解链表和顺序表的区别和优点 一、双链表接口实现 1. 双向链表的结构 • 单链表的结点中保存了指向后继结点的地址,所以单链表中找当前结点的后继结点很容易,但要获取当前结点的前驱结点就很麻烦,就只能从头开始往后遍历获取,时间复杂度为 O(n);所以单链表中只有当前结点的指针 pos 时(没有头指针),想要在 pos 之前插入结点和删除 pos 位置结点都是无法实现的。 • 双向链表相比单链表最大的特征是每个结点中多了一个前驱指针,


Android 面试系列:Kotlin 协程的 delay 到底发生在哪个线程?
潜龙勿用之化骨龙2026/7/8

在开发中,我们经常写出这样的代码: mainScope.launch { log("start") delay(1000) log("end") } 这段代码看起来非常简单,但它隐藏了一个非常经典的问题: delay 这 1 秒到底发生在哪个线程? 主线程前后都在执行,那中间谁在“等时间”? 结论 delay 从来不占用线程等待,它是一次“挂起 + 时间注册 + 调度恢复 + 状态机推进”的过程。 中间没有任何业务线程在 sleep,也没有线程在阻塞计时。


别再只会用 cron:Linux systemd Timer 定时任务实战详解
唐青枫2026/6/30

简介 Linux 上提到定时任务,最先想到的通常是 cron。 cron 足够简单,也足够稳定,但任务一旦涉及日志、启动依赖、超时控制、错过后补跑、运行用户和资源限制,单独一行 crontab 很快就会变得难以维护。 systemd Timer 提供了另一套方案: .timer 负责决定什么时候执行 .service 负责决定执行什么、以什么方式执行 例如,每天凌晨备份一次应用数据,可以拆成两个单元: myapp-backup.timer | | 到达触发时间


火山 DTS 正式支持 MySQL 同步到 Milvus , 解决业务库到向量库最后一公里
火山引擎Agent社区2026/6/21

这两年,大模型、智能问答越来越多地落到实际业务里。很多企业在推进过程中慢慢发现,影响 AI 应用落地效率的,除了模型本身能力之外,数据链路是否能顺畅跑通,也同样非常关键。 目前,企业大部分的业务数据库依然在关系型数据库中,而AI应用对支撑语义检索、相似召回的向量数据库有着更强的依赖。怎么把结构化业务数据稳定、持续地同步到向量数据库,正在成为不少企业建设 AI 数据底座时绕不开的问题。 现在,火山引擎 DTS 正式支持 MySQL 同步到 Milvus,帮助企业快速打通从业务数据库到向量数据库的数

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读