Spring Boot 实战(四十一):集成 BPMN(Flowable+bpmn-js)

目录

    • 相关框架官方资源
    • 写在前面:我们要做什么?
    • [一、5 个核心概念](#一、5 个核心概念)
    • 二、环境准备
    • [三、搭建后端(Spring Boot + Flowable)](#三、搭建后端(Spring Boot + Flowable))
      • [3.1 创建 Maven 工程](#3.1 创建 Maven 工程)
      • [3.2 `pom.xml`(完整)](#3.2 pom.xml(完整))
      • [3.3 启动类](#3.3 启动类)
      • [3.4 `application.properties`(完整)](#3.4 application.properties(完整))
      • [3.5 请假审批 BPMN 流程(完整)](#3.5 请假审批 BPMN 流程(完整))
      • [3.6 请求 DTO(3 个类)](#3.6 请求 DTO(3 个类))
      • [3.7 核心 Controller(完整)](#3.7 核心 Controller(完整))
      • [3.8 跨域与异常处理](#3.8 跨域与异常处理)
      • [3.9 启动后端并验证](#3.9 启动后端并验证)
    • [四、搭建前端(bpmn-js 在线建模)](#四、搭建前端(bpmn-js 在线建模))
      • [4.1 初始化 Vite + Vue 项目](#4.1 初始化 Vite + Vue 项目)
      • [4.2 安装后的 `package.json` 依赖段](#4.2 安装后的 package.json 依赖段)
      • [4.3 `vite.config.ts`(完整)](#4.3 vite.config.ts(完整))
      • [4.4 入口文件](#4.4 入口文件)
      • [4.5 API 封装 `src/api/workflow.ts`(完整)](#4.5 API 封装 src/api/workflow.ts(完整))
      • [4.6 默认 BPMN 模板 `src/bpmn/defaultDiagram.ts`](#4.6 默认 BPMN 模板 src/bpmn/defaultDiagram.ts)
      • [4.7 bpmn-js 建模器 `src/components/BpmnModelerView.vue`(完整)](#4.7 bpmn-js 建模器 src/components/BpmnModelerView.vue(完整))
      • [4.8 主页面 `src/App.vue`(完整)](#4.8 主页面 src/App.vue(完整))
      • [4.9 样式文件](#4.9 样式文件)
      • [4.10 启动前端](#4.10 启动前端)
    • 五、页面操作:走完审批闭环
    • [六、从 Demo 到生产的 4 个建议](#六、从 Demo 到生产的 4 个建议)
    • 写在最后

后端 :Spring Boot 2.4 + Flowable 6.8 + H2

前端 :Vite + Vue 3 + bpmn-js

阅读本文并照做,无需下载任何源码仓库,即可从零搭建一套「在线画流程 → 部署 → 发起 → 审批」的完整 Demo。

相关框架官方资源

Flowable(后端)

Flowable 官网:https://www.flowable.com/open-source

Flowable 官方文档:https://www.flowable.com/open-source/docs/

Flowable GitHub:https://github.com/flowable/flowable-engine

bpmn-js(前端)

bpmn-js 官网:https://bpmn.io/toolkit/bpmn-js/

bpmn-js 官方文档:https://github.com/bpmn-io/bpmn-js#documentation

bpmn-js GitHub:https://github.com/bpmn-io/bpmn-js

Vite 官网:https://vitejs.dev/

Vite 官方文档:https://cn.vitejs.dev/guide/

Vite GitHub:https://github.com/vitejs/vite


写在前面:我们要做什么?

企业里审批场景很多:请假、报销、采购......如果流转逻辑全写在 if/else 里,每次改流程都要发版。

BPMN + Flowable 的做法是:

  1. 用 BPMN 标准把流程画成图,保存为 XML
  2. Flowable 引擎负责解析 XML、驱动流程运转
  3. 业务系统只对接:部署、发起、查待办、提交审批

本文用「请假审批」串联整条链路:

复制代码
bpmn-js 在线建模 → 部署到 Flowable → 发起流程 → 查待办 → 审批完成

最终你会得到两个工程:

工程 技术 端口
后端 Spring Boot + Flowable + H2 8088
前端 Vite + Vue 3 + bpmn-js 5173

后端框架选型说明

Java 侧常见工作流引擎均支持 BPMN 2.0,本文后端选用 Flowable,主要因为:它由 Activiti 原核心团队维护,API 与 Activiti 5/6 相近、迁移成本低;官方提供 Spring Boot Starter,几行配置即可跑起来;社区活跃、文档齐全,适合作为审批类系统的入门选型。

引擎 维护背景 Spring Boot 集成 特点简述 本文选用
Flowable Activiti 原团队 fork Starter 开箱即用 轻量、上手快,XML 兼容性好
Activiti 社区分裂,7.x 架构变化大 有 Starter,版本需注意 存量项目多,新项需谨慎选型 ---
Camunda Camunda 公司主导 集成成熟 功能强、生态完善,部分能力偏商业 ---
jBPM Red Hat 系 偏 JBoss 体系 常与 Drools 规则引擎配合使用 ---

前端框架选型说明

流程建模前端的核心需求是:在浏览器里画 BPMN 图、导出标准 XML、交给后端部署 。本文选用 Vue 3 + bpmn-js :bpmn-js 是 Camunda 开源的 BPMN 2.0 专用库,与 Flowable 使用同一套 XML 标准;Vue 3 组合式 API 便于在 onMounted 中初始化建模器、在 onUnmounted 中销毁实例,比桌面建模器更适合嵌入 Web 审批平台。

方案 类型 输出格式 嵌入 Web 本文选用
bpmn-js + Vue 3 浏览器建模库 + 前端框架 标准 BPMN 2.0 XML
bpmn-js + React 同上,换用 React 封装 标准 BPMN 2.0 XML ---
Camunda Modeler 桌面建模工具 标准 BPMN 2.0 XML ❌ 独立应用 ---
Activiti Modeler 浏览器建模(较老) BPMN XML ✅ 可嵌入 ---
LogicFlow / AntV X6 通用图编辑库 自定义 JSON ✅ 灵活 ---

通用图编辑库自由度更高,但需自行映射 BPMN 语义;若目标是直接对接 Flowable,bpmn-js + Vue 3 是最省事的选择


一、5 个核心概念

概念 含义 本文示例
BPMN 流程建模标准,落地为 .bpmn20.xml 请假审批流程图
Process Definition 已部署的流程定义 leaveApproval
Process Instance 一次真实业务流转 张三的请假单
Task 待用户处理的任务 经理审批
Variables 流程变量,驱动分支 applicantapproved

主线:XML → 部署 → 启动实例 → 产生任务 → 变量决定走向


二、环境准备

工具 版本建议
JDK 1.8+
Maven 3.6+
Node.js 18+(推荐 20+)
IDE IntelliJ IDEA / VS Code 均可

三、搭建后端(Spring Boot + Flowable)

在写代码之前,先弄清 Flowable 在后端扮演的角色。

Flowable 是一个 BPMN 2.0 流程引擎,核心思路是:把 BPMN XML 解析为可执行的流程定义,运行时按节点顺序推进,遇到 UserTask 就生成待办任务,由业务系统查询并完成。

图 5:Flowable 核心服务分工 --- RepositoryService 管部署,RuntimeService 管运行,TaskService 管待办

几个关键 Service 的职责:

Service 作用 本文中的用法
RepositoryService 部署 BPMN、管理流程定义 /deploy-xml 部署 XML
RuntimeService 启动流程实例、管理运行时变量 /start 发起请假
TaskService 查询待办、完成任务 /tasks/tasks/complete
HistoryService 查询已结束的流程实例 /history/process-instances

一条数据流转路径:

复制代码
BPMN XML → 部署 → ProcessDefinition(流程定义)
                        ↓ startProcessInstanceByKey + variables
                  ProcessInstance(流程实例)
                        ↓ 到达 UserTask
                     Task(待办任务)→ complete + variables → 继续流转或结束

Spring Boot 通过 flowable-spring-boot-starter-process 自动注入上述 Service,我们只需在 Controller 中调用即可。

3.1 创建 Maven 工程

用 IDEA 新建 Spring Boot 项目,或手动创建如下目录结构:

java 复制代码
flowable-demo/
├── pom.xml
└── src/main/
    ├── java/com/demo/
    │   ├── FlowableDemoApplication.java
    │   └── bpmn/
    │       ├── api/
    │       │   ├── FlowableWorkflowController.java
    │       │   ├── ApiExceptionHandler.java
    │       │   └── dto/
    │       │       ├── DeployXmlRequest.java
    │       │       ├── StartProcessRequest.java
    │       │       └── CompleteTaskRequest.java
    │       └── config/
    │           └── CorsConfig.java
    └── resources/
        ├── application.properties
        └── processes/
            └── leave-approval.bpmn20.xml

3.2 pom.xml(完整)

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.demo</groupId>
    <artifactId>flowable-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>1.8</java.version>
        <flowable.version>6.8.0</flowable.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.4.5</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.flowable</groupId>
            <artifactId>flowable-spring-boot-starter-process</artifactId>
            <version>${flowable.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Demo 使用 H2 内存库,零配置即可启动。生产环境把数据源换成 MySQL,Flowable 配置基本不变。

3.3 启动类

java 复制代码
package com.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class FlowableDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(FlowableDemoApplication.class, args);
    }
}

3.4 application.properties(完整)

properties 复制代码
server.port=8088

# H2 内存数据库(Demo 用,重启后数据清空)
spring.datasource.url=jdbc:h2:mem:flowable-demo;DB_CLOSE_DELAY=-1;MODE=MySQL
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# Flowable:自动建表 + 自动部署 classpath 下的 BPMN
flowable.database-schema-update=true
flowable.async-executor-activate=false
flowable.process-definition-location-prefix=classpath:/processes/
flowable.process-definition-location-suffixes=**.bpmn20.xml,**.bpmn

三个配置项的含义:

  • database-schema-update=true:首次启动自动创建 Flowable 所需表
  • async-executor-activate=false:关闭异步执行器,Demo 日志更干净
  • process-definition-location-*:启动时自动部署 processes/ 目录下的 BPMN 文件

3.5 请假审批 BPMN 流程(完整)

src/main/resources/processes/ 下新建 leave-approval.bpmn20.xml

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xmlns:flowable="http://flowable.org/bpmn"
             targetNamespace="http://demo.bpmn/leave">

    <process id="leaveApproval" name="Leave Approval" isExecutable="true">

        <startEvent id="start" name="Start"/>
        <sequenceFlow id="flow_start_apply" sourceRef="start" targetRef="apply"/>

        <!-- 第一步:申请人提交,指派给 ${applicant} -->
        <userTask id="apply" name="Submit Leave Request" flowable:assignee="${applicant}">
            <extensionElements>
                <flowable:formProperty id="days" name="Days" type="long" required="true"/>
                <flowable:formProperty id="reason" name="Reason" type="string" required="true"/>
            </extensionElements>
        </userTask>
        <sequenceFlow id="flow_apply_mgr" sourceRef="apply" targetRef="managerApprove"/>

        <!-- 第二步:经理审批,指派给 ${manager} -->
        <userTask id="managerApprove" name="Manager Approval" flowable:assignee="${manager}">
            <extensionElements>
                <flowable:formProperty id="approved" name="Approved" type="boolean" required="true"/>
                <flowable:formProperty id="comment" name="Comment" type="string"/>
            </extensionElements>
        </userTask>
        <sequenceFlow id="flow_mgr_gateway" sourceRef="managerApprove" targetRef="approvedGateway"/>

        <!-- 排他网关:根据 approved 变量分支 -->
        <exclusiveGateway id="approvedGateway" name="Approved?"/>
        <sequenceFlow id="flow_gateway_ok" sourceRef="approvedGateway" targetRef="endApproved">
            <conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression>
        </sequenceFlow>
        <sequenceFlow id="flow_gateway_reject" sourceRef="approvedGateway" targetRef="endRejected">
            <conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression>
        </sequenceFlow>

        <endEvent id="endApproved" name="Approved"/>
        <endEvent id="endRejected" name="Rejected"/>
    </process>
</definitions>

流程说明:

  1. 启动时注入变量 applicantmanager,分别作为两个 UserTask 的处理人
  2. 经理审批节点写入 approved 变量
  3. 排他网关根据 approved == true/false 走向不同结束事件

图 1:请假审批流程 --- 提交 → 经理审批 → 网关分支 → 通过/驳回

3.6 请求 DTO(3 个类)

DeployXmlRequest.java

java 复制代码
package com.demo.bpmn.api.dto;

import javax.validation.constraints.NotBlank;

public class DeployXmlRequest {
    @NotBlank private String name;
    @NotBlank private String bpmnXml;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getBpmnXml() { return bpmnXml; }
    public void setBpmnXml(String bpmnXml) { this.bpmnXml = bpmnXml; }
}

StartProcessRequest.java

java 复制代码
package com.demo.bpmn.api.dto;

import javax.validation.constraints.NotBlank;
import java.util.HashMap;
import java.util.Map;

public class StartProcessRequest {
    @NotBlank private String processDefinitionKey;
    @NotBlank private String applicant;
    @NotBlank private String manager;
    private Long days;
    private String reason;

    public String getProcessDefinitionKey() { return processDefinitionKey; }
    public void setProcessDefinitionKey(String key) { this.processDefinitionKey = key; }
    public String getApplicant() { return applicant; }
    public void setApplicant(String applicant) { this.applicant = applicant; }
    public String getManager() { return manager; }
    public void setManager(String manager) { this.manager = manager; }
    public Long getDays() { return days; }
    public void setDays(Long days) { this.days = days; }
    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }

    public Map<String, Object> toVariables() {
        Map<String, Object> vars = new HashMap<>();
        vars.put("applicant", applicant);
        vars.put("manager", manager);
        if (days != null) vars.put("days", days);
        if (reason != null) vars.put("reason", reason);
        return vars;
    }
}

CompleteTaskRequest.java

java 复制代码
package com.demo.bpmn.api.dto;

import javax.validation.constraints.NotBlank;

public class CompleteTaskRequest {
    @NotBlank private String taskId;
    @NotBlank private String userId;
    private Boolean approved;
    private String comment;

    public String getTaskId() { return taskId; }
    public void setTaskId(String taskId) { this.taskId = taskId; }
    public String getUserId() { return userId; }
    public void setUserId(String userId) { this.userId = userId; }
    public Boolean getApproved() { return approved; }
    public void setApproved(Boolean approved) { this.approved = approved; }
    public String getComment() { return comment; }
    public void setComment(String comment) { this.comment = comment; }
}

3.7 核心 Controller(完整)

新建 FlowableWorkflowController.java,统一前缀 /api/workflow

java 复制代码
package com.demo.bpmn.api;

import com.demo.bpmn.api.dto.*;
import org.flowable.engine.*;
import org.flowable.engine.history.HistoricProcessInstance;
import org.flowable.engine.repository.Deployment;
import org.flowable.engine.repository.ProcessDefinition;
import org.flowable.task.api.Task;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;

@Validated
@RestController
@RequestMapping("/api/workflow")
public class FlowableWorkflowController {

    private final RepositoryService repositoryService;
    private final RuntimeService runtimeService;
    private final TaskService taskService;
    private final HistoryService historyService;

    public FlowableWorkflowController(RepositoryService repositoryService,
                                      RuntimeService runtimeService,
                                      TaskService taskService,
                                      HistoryService historyService) {
        this.repositoryService = repositoryService;
        this.runtimeService = runtimeService;
        this.taskService = taskService;
        this.historyService = historyService;
    }

    /** 查询已部署的流程定义 */
    @GetMapping("/process-definitions")
    public List<Map<String, Object>> listProcessDefinitions() {
        List<ProcessDefinition> list = repositoryService.createProcessDefinitionQuery()
                .latestVersion().orderByProcessDefinitionKey().asc().list();
        List<Map<String, Object>> out = new ArrayList<>();
        for (ProcessDefinition pd : list) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("id", pd.getId());
            row.put("key", pd.getKey());
            row.put("name", pd.getName());
            row.put("version", pd.getVersion());
            out.add(row);
        }
        return out;
    }

    /** 部署前端传来的 BPMN XML */
    @PostMapping("/deploy-xml")
    public Map<String, Object> deployXml(@Valid @RequestBody DeployXmlRequest req) {
        String resourceName = req.getName().endsWith(".bpmn20.xml")
                ? req.getName() : req.getName() + ".bpmn20.xml";
        Deployment deployment = repositoryService.createDeployment()
                .name(req.getName())
                .addBytes(resourceName, req.getBpmnXml().getBytes(StandardCharsets.UTF_8))
                .deploy();
        Map<String, Object> out = new LinkedHashMap<>();
        out.put("deploymentId", deployment.getId());
        out.put("name", deployment.getName());
        return out;
    }

    /** 导出流程 XML */
    @GetMapping(value = "/process-definition/{id}/xml", produces = MediaType.APPLICATION_XML_VALUE)
    public String getProcessDefinitionXml(@PathVariable("id") String processDefinitionId) throws IOException {
        ProcessDefinition pd = repositoryService.createProcessDefinitionQuery()
                .processDefinitionId(processDefinitionId).singleResult();
        if (pd == null) throw new IllegalArgumentException("ProcessDefinition not found");
        try (InputStream in = repositoryService.getProcessModel(pd.getId())) {
            return new String(readAllBytes(in), StandardCharsets.UTF_8);
        }
    }

    /** 发起流程实例 */
    @PostMapping("/start")
    public Map<String, Object> start(@Valid @RequestBody StartProcessRequest req) {
        org.flowable.engine.runtime.ProcessInstance pi = runtimeService.startProcessInstanceByKey(
                req.getProcessDefinitionKey(), req.toVariables());
        Map<String, Object> out = new LinkedHashMap<>();
        out.put("processInstanceId", pi.getId());
        out.put("processDefinitionKey", pi.getProcessDefinitionKey());
        return out;
    }

    /** 查询待办 */
    @GetMapping("/tasks")
    public List<Map<String, Object>> listTasks(@RequestParam String assignee) {
        List<Task> list = taskService.createTaskQuery()
                .taskAssignee(assignee).orderByTaskCreateTime().desc().list();
        List<Map<String, Object>> out = new ArrayList<>();
        for (Task t : list) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("id", t.getId());
            row.put("name", t.getName());
            row.put("processInstanceId", t.getProcessInstanceId());
            row.put("createTime", t.getCreateTime());
            out.add(row);
        }
        return out;
    }

    /** 查看任务变量 */
    @GetMapping("/tasks/{taskId}/variables")
    public Map<String, Object> getTaskVariables(@PathVariable String taskId) {
        Task task = taskService.createTaskQuery().taskId(taskId).singleResult();
        if (task == null) throw new IllegalArgumentException("Task not found: " + taskId);
        Map<String, Object> vars = taskService.getVariables(taskId);
        return vars == null ? Collections.emptyMap() : vars;
    }

    /** 完成 / 审批任务 */
    @PostMapping("/tasks/complete")
    public Map<String, Object> complete(@Valid @RequestBody CompleteTaskRequest req) {
        Task task = taskService.createTaskQuery().taskId(req.getTaskId()).singleResult();
        if (task == null) throw new IllegalArgumentException("Task not found");
        if (task.getAssignee() != null && !Objects.equals(task.getAssignee(), req.getUserId())) {
            throw new IllegalArgumentException("Task is not assigned to: " + req.getUserId());
        }
        Map<String, Object> vars = new HashMap<>();
        if (req.getApproved() != null) vars.put("approved", req.getApproved());
        if (req.getComment() != null) vars.put("comment", req.getComment());
        taskService.complete(req.getTaskId(), vars);

        Map<String, Object> out = new LinkedHashMap<>();
        out.put("completed", true);
        out.put("taskId", req.getTaskId());
        return out;
    }

    /** 查询历史流程实例 */
    @GetMapping("/history/process-instances")
    public List<Map<String, Object>> listHistory() {
        List<HistoricProcessInstance> list = historyService.createHistoricProcessInstanceQuery()
                .orderByProcessInstanceStartTime().desc().listPage(0, 50);
        List<Map<String, Object>> out = new ArrayList<>();
        for (HistoricProcessInstance h : list) {
            Map<String, Object> row = new LinkedHashMap<>();
            row.put("id", h.getId());
            row.put("processDefinitionKey", h.getProcessDefinitionKey());
            row.put("startTime", h.getStartTime());
            row.put("endTime", h.getEndTime());
            out.add(row);
        }
        return out;
    }

    private static byte[] readAllBytes(InputStream in) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        byte[] buf = new byte[4096];
        int n;
        while ((n = in.read(buf)) >= 0) out.write(buf, 0, n);
        return out.toByteArray();
    }
}

图 2:Vue 3 前端 bpmn-ui 通过 REST API 调用 Flowable 引擎,数据持久化到 H2 数据库

3.8 跨域与异常处理

前端开发时需要跨域支持,新建 CorsConfig.java

java 复制代码
package com.demo.bpmn.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.*;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;

@Configuration
public class CorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.setAllowedOriginPatterns(Arrays.asList("*"));
        config.setAllowedHeaders(Arrays.asList("*"));
        config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

新建 ApiExceptionHandler.java,统一返回错误格式:

java 复制代码
package com.demo.bpmn.api;

import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.*;

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
    public ResponseEntity<Map<String, Object>> handleBadRequest(RuntimeException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorBody("BAD_REQUEST", ex.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleInvalid(MethodArgumentNotValidException ex) {
        String msg = ex.getBindingResult().getAllErrors().isEmpty()
                ? "Validation failed"
                : ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorBody("VALIDATION_ERROR", msg));
    }

    private static Map<String, Object> errorBody(String code, String message) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("code", code);
        body.put("message", message);
        return body;
    }
}

3.9 启动后端并验证

bash 复制代码
mvn spring-boot:run

浏览器访问 http://localhost:8088/api/workflow/process-definitions,应返回包含 leaveApproval 的 JSON。

也可以用 curl 走一遍审批:

bash 复制代码
# 1. 发起流程
curl -X POST http://localhost:8088/api/workflow/start \
  -H "Content-Type: application/json" \
  -d '{"processDefinitionKey":"leaveApproval","applicant":"alice","manager":"bob","days":1,"reason":"年假"}'

# 2. 查 bob 的待办(注意:发起后第一个任务是 alice 的,需 alice 先完成提交任务)
curl "http://localhost:8088/api/workflow/tasks?assignee=alice"

# 3. 完成 alice 的任务(把 {taskId} 替换为上一步返回的 id)
curl -X POST http://localhost:8088/api/workflow/tasks/complete \
  -H "Content-Type: application/json" \
  -d '{"taskId":"{taskId}","userId":"alice"}'

# 4. 再查 bob 的待办,此时应出现经理审批任务
curl "http://localhost:8088/api/workflow/tasks?assignee=bob"

# 5. bob 审批通过
curl -X POST http://localhost:8088/api/workflow/tasks/complete \
  -H "Content-Type: application/json" \
  -d '{"taskId":"{taskId}","userId":"bob","approved":true,"comment":"同意"}'

四、搭建前端(bpmn-js 在线建模)

前端侧,bpmn-js 是 Camunda 开源的 BPMN 2.0 建模与渲染库,让我们在浏览器里直接画流程图、导入/导出 XML。

图 6:bpmn-js 分层架构 + Vue 3 组件封装(onMounted 初始化 / onUnmounted 销毁)

bpmn-js 的分层结构:

层级 模块 职责
数据层 bpmn-moddle BPMN XML ↔ 内存对象模型的解析与序列化
渲染层 diagram-js 在 Canvas 上用 SVG 绘制流程图元素
交互层 BpmnModeler 提供拖拽建模、连线、属性编辑等能力
事件层 EventBus + CommandStack 监听编辑变更、支持撤销/重做

与我们 Demo 相关的封装方式(Vue 3 组合式 API):

ts 复制代码
// onMounted:初始化建模器并加载 XML
onMounted(() => {
  modeler = new BpmnModeler({ container: containerRef.value })
  await modeler.importXML(bpmnXml)
})

// commandStack.changed:编辑后导出 XML
const { xml } = await modeler.saveXML({ format: true })

// onUnmounted:销毁实例,防止内存泄漏
onUnmounted(() => modeler?.destroy())

前端画好的 XML 通过 /deploy-xml 发给后端,由 Flowable 的 RepositoryService 完成部署------前端负责「画」,后端负责「跑」

4.1 初始化 Vite + Vue 项目

bash 复制代码
npm create vite@4.4.1 flowable-ui -- --template vue-ts
cd flowable-ui
npm install
npm install bpmn-js axios

Node 18 请使用 create-vite@4.4.1;Node 20+ 可用最新版。

4.2 安装后的 package.json 依赖段

确认 dependencies / devDependencies 包含:

json 复制代码
{
  "dependencies": {
    "axios": "^1.16.1",
    "bpmn-js": "^18.16.1",
    "vue": "^3.4.21"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.6.2",
    "typescript": "~5.3.3",
    "vite": "^4.4.5",
    "vue-tsc": "^1.8.27"
  }
}

4.3 vite.config.ts(完整)

配置 Vue 插件与开发代理,把 /api 转发到后端 8088:

ts 复制代码
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:8088',
        changeOrigin: true,
      },
    },
  },
})

4.4 入口文件

index.html

html 复制代码
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Flowable + bpmn-js 审批工作流</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

src/main.ts

ts 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

createApp(App).mount('#app')

4.5 API 封装 src/api/workflow.ts(完整)

ts 复制代码
import axios from 'axios';

const http = axios.create({ baseURL: '/api/workflow' });

export async function listProcessDefinitions() {
  const { data } = await http.get('/process-definitions');
  return data;
}

export async function deployXml(name: string, bpmnXml: string) {
  const { data } = await http.post('/deploy-xml', { name, bpmnXml });
  return data;
}

export async function startProcess(req: {
  processDefinitionKey: string;
  applicant: string;
  manager: string;
  days?: number;
  reason?: string;
}) {
  const { data } = await http.post('/start', req);
  return data;
}

export async function listTasks(assignee: string) {
  const { data } = await http.get('/tasks', { params: { assignee } });
  return data;
}

export async function getTaskVariables(taskId: string) {
  const { data } = await http.get(`/tasks/${taskId}/variables`);
  return data;
}

export async function completeTask(req: {
  taskId: string; userId: string; approved?: boolean; comment?: string;
}) {
  const { data } = await http.post('/tasks/complete', req);
  return data;
}

4.6 默认 BPMN 模板 src/bpmn/defaultDiagram.ts

ts 复制代码
export const DEFAULT_BPMN_XML = `<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
  xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
  xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
  xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
  xmlns:flowable="http://flowable.org/bpmn"
  id="Definitions_1" targetNamespace="http://bpmn.io/schema/bpmn">
  <bpmn:process id="demoProcess" name="Demo Process" isExecutable="true">
    <bpmn:startEvent id="StartEvent_1" name="Start" />
  </bpmn:process>
  <bpmndi:BPMNDiagram id="BPMNDiagram_1">
    <bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="demoProcess">
      <bpmndi:BPMNShape id="StartEvent_1_di" bpmnElement="StartEvent_1">
        <dc:Bounds x="173" y="102" width="36" height="36" />
      </bpmndi:BPMNShape>
    </bpmndi:BPMNPlane>
  </bpmndi:BPMNDiagram>
</bpmn:definitions>`;

4.7 bpmn-js 建模器 src/components/BpmnModelerView.vue(完整)

Vue 组件中用 onMounted 初始化 bpmn-js,用 onUnmounted 销毁实例:

vue 复制代码
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import BpmnModeler from 'bpmn-js/lib/Modeler'
import { DEFAULT_BPMN_XML } from '../bpmn/defaultDiagram'

import 'bpmn-js/dist/assets/diagram-js.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-codes.css'
import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css'

const props = defineProps<{ initialXml?: string }>()
const emit = defineEmits<{ xmlChanged: [xml: string] }>()

const containerRef = ref<HTMLDivElement | null>(null)
const error = ref<string | null>(null)
let modeler: BpmnModeler | null = null

onMounted(() => {
  if (!containerRef.value) return
  modeler = new BpmnModeler({ container: containerRef.value, keyboard: { bindTo: window } })
  void modeler.importXML(props.initialXml ?? DEFAULT_BPMN_XML)
  const eventBus = modeler.get('eventBus') as { on: (e: string, fn: () => void) => void }
  eventBus.on('commandStack.changed', async () => {
    const { xml } = await modeler!.saveXML({ format: true })
    if (typeof xml === 'string') emit('xmlChanged', xml)
  })
})

onUnmounted(() => { modeler?.destroy(); modeler = null })
</script>

<template>
  <div class="modelerRoot">
    <div v-if="error" class="modelerError">{{ error }}</div>
    <div ref="containerRef" class="modelerCanvas" />
  </div>
</template>

4.8 主页面 src/App.vue(完整)

页面分为左侧建模画布 + 右侧操作面板。使用 :key="modelerKey" 在「新建 / 导入」时重建建模器,避免编辑中重复挂载:

vue 复制代码
<template>
  <div class="appShell">
    <div class="topbar">
      <div class="title">Flowable + bpmn-js 审批工作流 Demo</div>
      <div class="btnRow">
        <button class="btn" @click="resetDiagram">新建</button>
        <button class="btn" @click="onImportXml">导入XML</button>
        <button class="btn" @click="downloadText(`${deployName}.bpmn20.xml`, xml)">导出XML</button>
      </div>
    </div>
    <div class="body">
      <div class="canvasWrap">
        <BpmnModelerView :key="modelerKey" :initial-xml="xml" @xml-changed="xml = $event" />
      </div>
      <div class="side">
        <!-- 部署 / 发起 / 待办审批 三个 card,调用 workflow.ts 中的 API -->
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import BpmnModelerView from './components/BpmnModelerView.vue'
import { completeTask, deployXml, getTaskVariables, listProcessDefinitions, listTasks, startProcess } from './api/workflow'
import { DEFAULT_BPMN_XML } from './bpmn/defaultDiagram'

const xml = ref(DEFAULT_BPMN_XML)
const modelerKey = ref(0)
const deployName = ref('leave-approval')
// ... applicant、manager、taskUser 等 ref 状态

function resetDiagram() {
  xml.value = DEFAULT_BPMN_XML
  modelerKey.value += 1
}

onMounted(() => { listProcessDefinitions().then(/* 填充流程定义下拉框 */) })
</script>

<style src="./App.css"></style>

完整的 App.vue 逻辑(部署、发起、待办刷新、审批完成)与 React 版一致,只是状态管理改为 Vue 的 ref / computed / watch。照抄上文第三节后端 API,把 axios 调用填入即可。

图 3:Vue 3 + bpmn-js 前端 --- 左侧在线建模,右侧部署 / 发起 / 待办审批

4.9 样式文件

src/index.css --- 让页面铺满视口:

css 复制代码
html, body, #app {
  width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden;
}
body { min-width: 320px; font-family: Inter, system-ui, sans-serif; }

src/App.css --- 布局与暗色主题(完整):

css 复制代码
*, *::before, *::after { box-sizing: border-box; }

.appShell {
  width: 100%; height: 100%; display: grid;
  grid-template-rows: minmax(56px, auto) 1fr;
  background: #0b1220; color: #e5e7eb; overflow: hidden;
}
.topbar {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; padding: 0 16px;
  border-bottom: 1px solid rgba(255,255,255,0.08);
}
.title { font-weight: 700; }
.btnRow { display: flex; gap: 8px; flex-wrap: wrap; }
.btn {
  border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06);
  color: #e5e7eb; padding: 8px 10px; border-radius: 10px; font-size: 12px; cursor: pointer;
}
.btnPrimary { background: #2563eb; border-color: #2563eb; }
.body {
  display: grid; grid-template-columns: minmax(0,1fr) minmax(280px,360px);
  min-height: 0; overflow: hidden;
}
.canvasWrap { min-width: 0; min-height: 0; background: #111827; }
.side {
  border-left: 1px solid rgba(255,255,255,0.08); padding: 12px;
  display: flex; flex-direction: column; gap: 12px; overflow: auto;
}
.card {
  background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
  border-radius: 14px; padding: 12px;
}
.cardTitle { font-weight: 700; font-size: 13px; margin-bottom: 8px; }
.input, .select {
  width: 100%; height: 34px; margin-top: 6px; border-radius: 10px;
  border: 1px solid rgba(255,255,255,0.12); background: rgba(0,0,0,0.25);
  color: #e5e7eb; padding: 0 10px;
}
.hint { font-size: 12px; color: rgba(229,231,235,0.7); }
.mono {
  font-family: monospace; font-size: 12px; white-space: pre-wrap;
  margin-top: 8px; color: rgba(229,231,235,0.8);
}
@media (max-width: 960px) {
  .body { grid-template-columns: 1fr; grid-template-rows: minmax(280px,1fr) auto; }
  .side { max-height: 42vh; border-left: none; border-top: 1px solid rgba(255,255,255,0.08); }
}

4.10 启动前端

bash 复制代码
npm run dev

浏览器打开 http://localhost:5173,即可看到左侧建模器 + 右侧操作面板。


五、页面操作:走完审批闭环

按以下顺序在页面上操作:

步骤 操作人 页面操作 预期
1 --- 确认流程定义列表中有 leaveApproval 后端启动时已自动部署
2 alice 发起流程:applicant=alicemanager=bob 提示发起成功
3 alice 待办:assignee=alice → 刷新 → 完成提交任务 任务消失
4 bob 待办:assignee=bob → 刷新 出现「Manager Approval」
5 bob 选择「通过」→ 审批/完成 任务消失,流程结束

若 bob 选择「驳回」,流程走到 Rejected 结束节点。

图 4:经理审批后,根据 approved 变量走通过或驳回结束节点


六、从 Demo 到生产的 4 个建议

  1. 身份体系 :Demo 用字符串 assignee,生产应接入用户 / 角色 / 候选组
  2. 业务关联 :启动时写入 businessKey(如请假单 ID),串联业务表与流程实例
  3. 变量规范 :前后端约定统一变量命名,避免 approved / isApproved 漂移
  4. 权限校验tasks/complete 必须校验当前用户是否为任务处理人

写在最后

本文从零搭建了一套完整的审批工作流 Demo:

  • 后端:Flowable 引擎 + REST API,H2 零配置启动
  • 前端:bpmn-js 在线建模 + 一键部署 + 发起审批
  • 流程:请假审批,两个 UserTask + 排他网关

实际项目中你还可以继续扩展:接入 Camunda Modeler 画复杂流程、用监听器发消息通知、把 H2 换成 MySQL 并对接现有用户体系。

难的不是引擎本身,而是把流程、表单、权限三件事设计清楚。 希望这篇实战能帮你快速上手。

相关推荐
ljs6482739511 小时前
智慧商城(Smart Mall):基于 Spring Boot + Vue 3 的现代化电商平台实战
linux·vue.js·spring boot·后端
yeffky1 小时前
Vibe Coding一个能办事的 AI Agent:Spring Boot + DeepSeek + LangGraph4j 六节点 ReAct 项目实战
人工智能·后端·agent
乒乓狂魔14786739970001 小时前
OpenClaw 终于可以读懂 OpenTelemetry APM 了
后端
Rain的Java大神之路1 小时前
高并发下如何防止商品超卖少卖
后端
张三丰21 小时前
Fiber 节点 —— 一个数据结构如何承载整个 React 运行时
后端
AINative软件工程1 小时前
AI Agent 不是会重试就可靠:长任务的断点恢复与 Durable Execution 设计
人工智能·后端·ai编程
卷无止境1 小时前
SFML 深度解读:一个教科书级 C++ 多媒体库的内功心法
c++·后端
GuangdongTong1 小时前
基于RabbitMQ实现延迟任务
后端·rabbitmq
学渣超1 小时前
微服务日志智能诊断系统(九) 让 LLM 成为日志分析专家
java·后端·agent