给若依加审批流,不用 Flowable

引言

若依是国内最流行的快速开发框架之一,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:

xml 复制代码
<!-- ruoyi-admin/pom.xml -->
<dependency>
    <groupId>com.mldong.jeeflow</groupId>
    <artifactId>jeeflow-spring-boot4-starter</artifactId>
    <version>1.8.27</version>
</dependency>

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

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

3.2 只补两个 Bean

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

java 复制代码
@Configuration
public class WfJeeflowConfig {

    @Bean
    @ConditionalOnMissingBean
    public IProcessExtRepository jeeflowExtRepository(DataSource dataSource) {
        return new JdbcProcessExtRepository(dataSource);
    }

    @Bean
    @ConditionalOnMissingBean
    public JeeflowFacade jeeflowFacade(JeeflowEngine engine,
                                       IProcessRepository repository,
                                       IProcessExtRepository extRepository,
                                       ObjectProvider<IUserSearchProvider> userSearchProvider) {
        JeeflowFacade facade = new JeeflowFacade(engine, repository, extRepository);
        userSearchProvider.ifAvailable(facade::setUserSearchProvider);
        return facade;
    }
}

3.3 一个 Controller 吃下所有请求

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

java 复制代码
@RestController
@RequestMapping("/wf")
public class WfFlowController {

    @Autowired
    private JeeflowFacade jeeflowFacade;

    @PostMapping("/**")
    public Map<String, Object> flow(HttpServletRequest request,
                                    @RequestBody(required = false) Map<String, Object> body) {
        String uri = request.getRequestURI();
        int idx = uri.indexOf("/wf/");
        String action = uri.substring(idx + "/wf/".length());

        checkPermission(action);                       // ① 权限校验

        if (body == null) body = new LinkedHashMap<>();
        body.put("operator", SecurityUtils.getUserId().toString());  // ② 注入操作人

        return jeeflowFacade.flow(action, body);       // ③ 门面透传
    }

    private void checkPermission(String action) {
        if (SecurityUtils.isAdmin()) return;           // admin 万能放行
        IActionPermissionProvider provider = ServiceContext.find(IActionPermissionProvider.class);
        String[] codes = provider == null ? null : provider.permissionCodes(action);
        if (codes != null && codes.length > 0) {
            for (String code : codes) {
                if (SecurityUtils.hasPermi(code)) return;   // 任一权限码命中即可
            }
            throw new ServiceException("无权限执行操作:" + action, 403);
        }
    }
}

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

  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 链路,不是直接插表):

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

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

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

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

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

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

json 复制代码
{
  "name": "leave",
  "displayName": "请假申请",
  "type": "approval",
  "enableFieldPerm": true,
  "__schema__": {
    "columns": [
      { "fieldName": "reason",    "remark": "请假事由", "component": "Textarea", "ext": { "required": 1, "placeholder": "请说明请假原因" } },
      { "fieldName": "days",      "remark": "请假天数", "component": "Number",   "ext": { "required": 1 } },
      { "fieldName": "leaveType", "remark": "请假类型", "component": "Select",
        "ext": { "required": 1, "options": [
          { "label": "年假", "value": "annual" },
          { "label": "事假", "value": "personal" },
          { "label": "病假", "value": "sick" } ] } },
      { "fieldName": "startDate", "remark": "开始日期", "component": "Date" },
      { "fieldName": "endDate",   "remark": "结束日期", "component": "Date" }
    ]
  },
  "nodes": [
    { "id": "start",   "type": "snaker:start", "text": { "value": "开始" } },
    { "id": "apply",   "type": "snaker:task",
      "properties": { "form": "leave-form", "assignee": "applicant" },
      "text": { "value": "填写申请" } },
    { "id": "approve", "type": "snaker:task",
      "properties": { "form": "leave-form", "assignee": "1",
        "field": { "PERMISSION_f_reason": 1, "PERMISSION_f_days": 1, "PERMISSION_f_leaveType": 1 } },
      "text": { "value": "上级审批" } },
    { "id": "end",     "type": "snaker:end", "text": { "value": "结束" } }
  ],
  "edges": [
    { "sourceNodeId": "start",   "targetNodeId": "apply" },
    { "sourceNodeId": "apply",   "targetNodeId": "approve" },
    { "sourceNodeId": "approve", "targetNodeId": "end" }
  ]
}

几个值得注意的点:

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

4.2 purchase:金额网关分流

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

json 复制代码
{ "id": "decision1", "type": "snaker:decision",
  "properties": { "expr": "#f_amount > 5000" },
  "text": { "value": "金额>5000?" } }

两条出边的表达式:

json 复制代码
{ "sourceNodeId": "decision1", "targetNodeId": "deptApprove",
  "properties": { "expr": "#f_amount <= 5000" }, "text": { "value": "≤5000" } },
{ "sourceNodeId": "decision1", "targetNodeId": "gmApprove",
  "properties": { "expr": "#f_amount > 5000" },  "text": { "value": ">5000" } }

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

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

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

4.3 baoxiao:并行会签

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

json 复制代码
{ "id": "countersign", "type": "snaker:task",
  "properties": {
    "form": "baoxiao-form",
    "assignee": "1,2",
    "performType": "1",
    "countersignType": "PARALLEL",
    "field": { "PERMISSION_f_reason": 1, "PERMISSION_f_amount": 1,
               "PERMISSION_f_invoiceCount": 1, "PERMISSION_f_expenseDate": 1 } },
  "text": { "value": "经理财务会签" } }

三个属性缺一不可: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:引一次样式

js 复制代码
import '@mldong/jeeflow-ui/dist/style.css' // jeeflow 流程中心组件样式

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

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

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

vue 复制代码
<template>
  <JeeflowUiProvider :config="jfConfig">
    <component :is="page" @goto="onGoto" />
  </JeeflowUiProvider>
</template>

<script setup>
import { useRouter } from 'vue-router'
import { JeeflowUiProvider } from '@mldong/jeeflow-ui'
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import useUserStore from '@/store/modules/user'

defineProps({
  page: { type: [Object, Function], required: true },
})

const router = useRouter()
const userStore = useUserStore()

// 通配权限匹配(对齐若依后端 PatternMatchUtils.simpleMatch)
function globMatch(pattern, str) {
  if (!pattern || !str) return false
  if (pattern === '*:*:*') return true
  if (pattern === str) return true
  const re = '^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
  return new RegExp(re).test(str)
}

// hasPermission(codes[]):任一命中即放行
function hasPermission(codes) {
  const list = Array.isArray(codes) ? codes : [codes]
  if (!list.length) return false
  const mine = userStore.permissions || []
  return list.some(code => mine.some(perm => globMatch(perm, code)))
}

// 宿主 adapter:把若依 REST 喂给流程中心(选人/字典/上传)
const adapters = {
  getDict: async (code) => {
    const res = await request.get(`/system/dict/data/type/${code}`)
    return (res.data || []).map(d => ({ value: d.dictValue, label: d.dictLabel }))
  },
  upload: async (file) => {
    const fd = new FormData()
    fd.append('file', file)
    const res = await request.post('/common/upload', fd,
      { headers: { 'Content-Type': 'multipart/form-data' } })
    return res.url || res.fileName
  },
  listUsers: async (keyword) => {
    const res = await request.get('/system/user/list',
      { params: { pageNum: 1, pageSize: 50, userName: keyword || undefined } })
    return (res.rows || []).map(u => ({
      userId: String(u.userId), realName: u.nickName, deptName: u.dept?.deptName || ''
    }))
  },
}

const jfConfig = {
  baseUrl: import.meta.env.VITE_APP_BASE_API,   // 经 vite proxy → :8080
  getToken: () => getToken(),                   // 若依 JWT(js-cookie Admin-Token)
  getOperator: () => (userStore.id != null ? String(userStore.id) : ''),
  hasPermission,
  adapters,
}

// 组件内页间跳转(key 由组件 emit)→ 若依路由
const routeMap = {
  workbench: '/workflow/workbench',
  apply: '/workflow/apply',
  todo: '/workflow/todo',
  done: '/workflow/done',
  mine: '/workflow/mine',
  cc: '/workflow/cc',
  define: '/workflow/define',
  design: '/workflow/design',
  surrogate: '/workflow/surrogate',
}

function onGoto(key) {
  if (routeMap[key]) router.push(routeMap[key])
}
</script>

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 组件",以"我的待办"为例:

vue 复制代码
<template>
  <JfHost :page="JfTodoPage" />
</template>

<script setup>
import JfHost from '../wf-host.vue'
import { JfTodoPage } from '@mldong/jeeflow-ui'
</script>

其余八个(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 的权限收口:

sql 复制代码
-- 清旧段(2000-2099),保证可重复导入
delete from sys_role_menu where menu_id between 2000 and 2099;
delete from sys_menu where menu_id between 2000 and 2099;

-- 一级目录:工作流
insert into sys_menu values('2000', '工作流', 0, 5, 'workflow', null, '', '', 1, 0, 'M', '0', '0', '', 'tree', 'admin', sysdate(), '', null, 'jeeflow 工作流');

-- 九个页面菜单(动态路由,component 相对 views/)
insert into sys_menu values('2001', '工作台',   2000, 1, 'workbench', 'workflow/workbench/index', '', '', 1, 0, 'C', '0', '0', 'wf:workbench',                'dashboard',     'admin', sysdate(), '', null, 'jeeflow 工作台');
insert into sys_menu values('2002', '发起申请', 2000, 2, 'apply',     'workflow/apply/index',     '', '', 1, 0, 'C', '0', '0', 'wf:processDesign:listByType', 'form',          'admin', sysdate(), '', null, 'jeeflow 发起申请');
insert into sys_menu values('2003', '我的待办', 2000, 3, 'todo',      'workflow/todo/index',      '', '', 1, 0, 'C', '0', '0', 'wf:processTask:todoList',     'checkbox',      'admin', sysdate(), '', null, 'jeeflow 我的待办');
insert into sys_menu values('2004', '我的已办', 2000, 4, 'done',      'workflow/done/index',      '', '', 1, 0, 'C', '0', '0', 'wf:processTask:doneList',     'time',          'admin', sysdate(), '', null, 'jeeflow 我的已办');
insert into sys_menu values('2005', '我发起的', 2000, 5, 'mine',      'workflow/mine/index',      '', '', 1, 0, 'C', '0', '0', 'wf:processInstance:page',     'list',          'admin', sysdate(), '', null, 'jeeflow 我发起的');
insert into sys_menu values('2006', '我的抄送', 2000, 6, 'cc',        'workflow/cc/index',        '', '', 1, 0, 'C', '0', '0', 'wf:processInstance:ccList',   'message',       'admin', sysdate(), '', null, 'jeeflow 我的抄送');
insert into sys_menu values('2007', '流程定义', 2000, 7, 'define',    'workflow/define/index',    '', '', 1, 0, 'C', '0', '0', 'wf:processDefine:page',       'documentation', 'admin', sysdate(), '', null, 'jeeflow 流程定义');
insert into sys_menu values('2008', '流程设计', 2000, 8, 'design',    'workflow/design/index',    '', '', 1, 0, 'C', '0', '0', 'wf:processDesign:page',       'build',         'admin', sysdate(), '', null, 'jeeflow 流程设计');
insert into sys_menu values('2009', '我的委托', 2000, 9, 'surrogate', 'workflow/surrogate/index', '', '', 1, 0, 'C', '0', '0', 'wf:processSurrogate:page',    'peoples',       'admin', sysdate(), '', null, 'jeeflow 我的委托');

-- 五个通配按钮(权限码收口:引擎细粒度码由若依 simpleMatch 通配命中)
insert into sys_menu values('2020', '流程发起', 2000, 20, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processDesign:*',   '#', 'admin', sysdate(), '', null, '发起/设计/上架/发布');
insert into sys_menu values('2021', '任务办理', 2000, 21, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processTask:*',     '#', 'admin', sysdate(), '', null, '待办/已办/执行/转办');
insert into sys_menu values('2022', '实例查看', 2000, 22, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processInstance:*', '#', 'admin', sysdate(), '', null, '实例分页/详情/抄送');
insert into sys_menu values('2023', '定义管理', 2000, 23, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processDefine:*',   '#', 'admin', sysdate(), '', null, '定义增删改/版本');
insert into sys_menu values('2024', '流程委托', 2000, 24, '', '', '', '', 1, 0, 'F', '0', '0', 'wf:processSurrogate:*','#', 'admin', sysdate(), '', null, '委托增删改查');

-- 授权普通角色(common, role_id=2):9 个页面 + 5 个按钮
-- (按需裁剪:管理页 2007/2008/2009 与对应按钮可不给普通角色)
insert into sys_role_menu values ('2','2000'),('2','2001'),('2','2002'),('2','2003'),
  ('2','2004'),('2','2005'),('2','2006'),('2','2007'),('2','2008'),('2','2009'),
  ('2','2020'),('2','2021'),('2','2022'),('2','2023'),('2','2024');

能这么收,靠的是若依 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):一条链走通

bash 复制代码
# 1. 登录拿 token
curl -s -X POST http://localhost:8080/login -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"admin123","code":"13","uuid":"<验证码uuid>"}'
# → {"code":200,"token":"eyJhbGciOiJIUzUxMiJ9..."}

# 2. 发起(processDefine/startAndExecute,表单字段 f_ 前缀)
curl -s -X POST http://localhost:8080/wf/processDefine/startAndExecute \
  -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
  -d '{"processDefineId":15328901147922432,"f_reason":"家里有事","f_days":2,"f_leaveType":"personal"}'
# → {"code":0,"msg":"成功","data":{"processInstanceId":15328916897009664}}

# 3. 查待办(processTask/todoList)
# 4. 审批同意(processTask/execute,submitType=1)
curl -s -X POST http://localhost:8080/wf/processTask/execute \
  -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
  -d '{"processTaskId":15328926470901760,"submitType":1,"tf_approvalComment":"同意,注意工作交接"}'
# → {"code":0,"msg":"成功","data":null}

# 5. 审批记录(processInstance/approvalRecord)
# → apply 节点 + approve 节点,中文意见原样落库

⚠️ 一个 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"的难题。它应该是"加个依赖,配个菜单,半天跑通"的常规操作。

(全文完)

相关推荐
Wang's Blog3 小时前
Java框架快速入门: Spring Security+OAuth2之数据库和实体类的RBAC改造
java·数据库·spring
讳疾忌医丶3 小时前
深度拆解 RocksDB 内核:基于 C++17 的 FIFO 调度状态机与温度阶梯自愈设计
java·c++·算法·架构
2601_963749103 小时前
越华环保集团污水站曝气系统边缘闭环控制架构与能耗优化实现
人工智能·架构
郑州光合科技余经理3 小时前
国际版外卖系统:税率字段怎么和订单主流程解耦
android·java·开发语言·前端·后端·php·ai编程
毅炼3 小时前
Rover-Suite 开源发布:轻量服务注册中心与网关一体化方案
java·后端·系统架构·gateway
梦想平凡4 小时前
百游棋牌源代码开发搭建教程(十):隔离部署、备份恢复与双端验收
java·前端·javascript·数据库·源代码管理
天远数科5 小时前
零信任架构实战:基于天远全能消金报告构建自动化信贷评估网关
运维·人工智能·架构·自动化
sunshine22 girl5 小时前
Idea中如何搜索
java·ide·intellij-idea
猫吻鱼5 小时前
【AI 01】【Spring AI 基础使用】
java·spring·spring ai