☰
基于SpringBoot+Vue+MySQL的工作流程管理系统设计与实现
2026/10/13 20:29:27 网站建设 项目流程

去年我完整做了一套前后端分离的工作流程管理系统,技术栈就是标题里写的这套:SpringBoot + Vue + MySQL + MyBatis。业务上覆盖请假、报销、合同审批这些最典型的场景,代码上把流程定义、流程启动、任务审批、驳回、会签、转办全部跑通。我这次不打算只贴几个页面截图,而是把从需求拆解到数据库设计、从后端接口到前端交互,再到本地部署和排障,完整讲一遍。如果你正准备做OA类项目、毕业设计选题,或者第一次独立开发这种“有状态流转”的系统,这份笔记可以直接当参考。

整套系统的核心思路是:不用重量级工作流引擎,用 Java + MySQL 自己实现一套轻量级流程引擎,核心只有流程定义、任务、实例几张表。这种方式的最大优势是可控,出问题你能直接看SQL分析,而不是被引擎的表结构和内部机制带偏。

1. 项目概述:为什么需要一套工作流程管理系统

1.1 先看业务:审批场景到底缺什么

日常工作里审批几乎是绕不开的。大多数人传统处理方式是什么?纸质审批单、聊天软件传文件、邮件往来、共享表格记录。这些方式单独看都能用,一旦流程复杂就出问题。最典型的几个痛点:

  • 审批进度不可见,发起人不知道卡在哪个环节。
  • 没有统一的任务池,审批人可能漏看、误看。
  • 审批意见分散在各个聊天记录里,事后追溯困难。
  • 流程规则靠人记,换个人处理就容易出现“以前不是这么审的”这种问题。

工作流程管理系统要解决的就是这类问题。它把审批规则固化到系统里,让每一步都由流程引擎自动判断下一节点;所有任务进入待办池,处理完留痕;审批人、发起人、管理员各自看到不同的视图。说得直白一点,就是把“谁申请、谁审批、按什么顺序审批、不通过怎么办”这几件事,用代码和数据库固化下来。

1.2 系统角色和功能范围

我做的这套系统划分了三个角色:

角色主要功能
普通员工发起申请、查看我的申请、撤回未处理申请
审批人处理待办、查看已办、驳回、转办、会签
管理员流程定义、节点配置、用户角色管理、全流程监控

功能上覆盖了几个核心模块:

  • 流程管理:流程定义的创建、启用、停用。
  • 节点配置:审批节点、条件节点、开始和结束节点的维护。
  • 任务中心:待办列表、已办列表、我的申请列表。
  • 审批操作:同意、驳回、转办、撤回、加签。
  • 权限系统:基于角色的登录认证和菜单权限控制。

从实际使用来看,这套范围已经能覆盖绝大多数中小团队的审批需求。复杂到需要自由流程、子流程、会签权重之类的,可以在这个骨架上继续扩展,但不建议一上来就做太重。

2. 技术选型与系统架构设计

2.1 为什么是 SpringBoot + Vue,而不是别的组合

选择这套技术栈不是因为它最先进,而是因为它在中小型系统里综合成本最低。

后端用 SpringBoot,理由很简单:生态成熟、内嵌容器、配置简化,和 MyBatis 配合又很自然。SpringBoot 的自动配置能省掉大量 XML 配置,业务代码只需要关注 Controller、Service、Mapper 三层。对于流程管理系统这种“业务规则重、CRUD 多”的项目,MyBatis 比 JPA 更直观,SQL 可控性也更强。尤其是流程节点查询、子任务统计这种偏复杂的联表查询,用 MyBatis 写 SQL 反而清晰。

前端用 Vue,组件化和响应式数据模型非常适合做后台管理。Vue Router 做页面路由,Vuex 或 Pinia 做全局状态,配合 Element UI 组件库,待办列表、审批弹窗、步骤条这些页面开发效率很高。相比 React 生态,Vue 的中文资料更多、上手门槛更低,如果是团队项目或者毕设项目,这个优势更明显。

2.2 前后端分离架构和请求链路

系统整体是典型的前后端分离结构:

浏览器 + Vue 前端发起 HTTP 请求,后端是 SpringBoot 提供 REST API,MyBatis 负责数据库访问,MySQL 存储所有业务数据。前端开发时用 Vite 或 Webpack 代理转发接口请求,生产环境可以用 Nginx 托管前端静态文件并反向代理后端地址。

身份认证用 JWT Token。用户在登录接口拿到 Token 后,前端每次请求都放在 Header 里,后端写一个拦截器解析 Token,并把当前用户信息放到 ThreadLocal 或者请求上下文,后续 Service 层直接取“当前操作人”即可。

这个架构的好处是前后端可以分开开发、分开部署。我在实际开发过程中,前端和后端甚至可以用不同的开发机器进行联调,只要接口约定好,基本不互相阻塞。

2.3 流程引擎:自己写还是用 Activiti/Flowable

很多人在做工作流项目时会纠结要不要引入 Activiti 或 Flowable。我的个人选择是:这套系统不引入重量级流程引擎,自己实现一个轻量引擎,原因很直接。

Activiti 和 Flowable 功能强大,支持 BPMN 规范、子流程、多实例、边界事件,但随之而来的是大量数据表和复杂概念。如果只是做请假、报销、合同审批这类线性流程,引入这些引擎就像杀鸡用牛刀。你需要去理解它的部署、流程实例、任务、 executions 体系,出了问题排查链路很长。

自研方案的核心表其实很少:流程定义表、节点表、连线表、流程实例表、任务表、审批记录表。代码上用一个状态机思路去驱动任务流转,规则写在配置数据里,扩展也方便。下面是两种方案的简单对比。

维度自研轻量引擎Activiti/Flowable
表结构6 张左右,容易理解几十张表,学习成本高
部署成本低,纯业务代码高,需要熟悉引擎 API
复杂流程能力适合顺序、条件、会签支持子流程、事件、多实例等
出问题排查直接查业务表和 SQL需要结合引擎内部表分析
推荐场景中小型内部系统大型复杂流程平台

如果你做的项目未来一定会涉及复杂流程编排,那直接用 Flowable 没错。但如果需求边界很明确,就是固定表单、固定顺序、少量条件分支,自研引擎能让项目整体质量更可控。

3. 数据库设计:核心表结构与状态流转

3.1 实体关系和表清单

流程管理系统数据模型围绕“流程定义、实例、任务、审批记录”四个核心概念来建。用户和角色表用于支撑权限,这里不过度展开。主要业务表如下:

表名作用
sys_user用户表
sys_role角色表
wf_process_define流程定义表
wf_node流程节点表
wf_node_edge节点连线表
wf_process_instance流程实例表
wf_task任务表
wf_task_opinion审批记录表

流程定义描述“有哪些节点、节点怎么办”,流程实例描述“某一次具体申请走到哪了”,任务描述“当前这个节点谁处理”。理解这三者关系,整个系统的数据模型就通了。

3.2 关键表 DDL 与设计理由

这里直接给核心表结构,你可以把 SQL 用来建表参考。

CREATE TABLE wf_process_define ( id BIGINT PRIMARY KEY AUTO_INCREMENT, process_code VARCHAR(64) NOT NULL COMMENT '流程编码', process_name VARCHAR(128) NOT NULL COMMENT '流程名称', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_process_code (process_code) ) COMMENT '流程定义表'; CREATE TABLE wf_node ( id BIGINT PRIMARY KEY AUTO_INCREMENT, process_id BIGINT NOT NULL COMMENT '所属流程定义ID', node_code VARCHAR(64) NOT NULL COMMENT '节点编码', node_name VARCHAR(128) NOT NULL COMMENT '节点名称', node_type VARCHAR(32) NOT NULL COMMENT 'start/approval/condition/end', assignee_type VARCHAR(32) COMMENT '角色编码或指定用户', condition_expr VARCHAR(500) COMMENT '条件规则JSON', sort_no INT NOT NULL DEFAULT 0, UNIQUE KEY uk_process_node (process_id, node_code) ) COMMENT '流程节点表'; CREATE TABLE wf_process_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, process_id BIGINT NOT NULL, applicant_id BIGINT NOT NULL COMMENT '发起人', business_key VARCHAR(128) COMMENT '业务单据编号', status VARCHAR(32) NOT NULL COMMENT 'running/completed/terminated', current_node_code VARCHAR(64), variables TEXT COMMENT '流程变量JSON', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, end_time DATETIME ) COMMENT '流程实例表'; CREATE TABLE wf_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, instance_id BIGINT NOT NULL, node_code VARCHAR(64) NOT NULL, node_name VARCHAR(128) NOT NULL, assignee_id BIGINT NOT NULL COMMENT '处理人ID', status VARCHAR(32) NOT NULL COMMENT 'todo/processed/transferred', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, finish_time DATETIME, KEY idx_instance (instance_id), KEY idx_assignee (assignee_id, status) ) COMMENT '流程任务表'; CREATE TABLE wf_task_opinion ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, instance_id BIGINT NOT NULL, operator_id BIGINT NOT NULL, action VARCHAR(32) NOT NULL COMMENT 'approve/reject/transfer', comment VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '审批记录表';

设计的时候有一条原则:状态字段用字符串,不搞难看的数字魔法值。todo、processed这种值一看就懂,排查数据的时候不用查字典表。

3.3 节点连线与条件表达式设计

节点表负责定义“有哪些节点”,连线表负责定义“节点与节点之间怎么走”。这是一张非常关键的表,实际结构类似下面这样:

CREATE TABLE wf_node_edge ( id BIGINT PRIMARY KEY AUTO_INCREMENT, process_id BIGINT NOT NULL, from_node_code VARCHAR(64) NOT NULL, to_node_code VARCHAR(64) NOT NULL, condition_expr VARCHAR(500) COMMENT '条件表达式JSON', sort_no INT NOT NULL DEFAULT 0 ) COMMENT '节点连线表';

条件表达式的存放方式我用了 JSON 文本。比如一个审批节点有两条出线,一条条件是“金额小于等于5000”到主管审批,另一条条件是“金额大于5000”到总监审批。判断规则就存在condition_expr字段里,格式大致是:

{"field": "amount", "op": ">", "value": 5000}

Service 层拿到流程变量 Map 后,按这个表达式解析。至于更复杂的多条件组合,可以在 JSON 结构里加and/or嵌套,但中小型系统里单条件判断已经够用,不必做成规则引擎。

4. 后端核心实现:SpringBoot + MyBatis 工作流业务闭环

4.1 认证授权:JWT 拦截器与用户上下文

后端采用拦截器统一做登录校验。登录接口校验用户名密码后生成 Token,其余接口在请求头携带 Token,拦截器校验通过后放行。核心类大概是:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token) || !JwtUtil.verify(token)) { response.setStatus(401); return false; } UserContext.setCurrentUserId(JwtUtil.getUserId(token)); return true; } }

使用 ThreadLocal 保存当前用户 ID,Service 层在处理任务时可以直接拿到操作人,不需要每个接口都传一遍用户 ID。这里我没有引入 Spring Security,因为登录模型简单、权限粒度用角色已经足够,拦截器加角色校验反而更直观。

4.2 流程启动:从流程定义到生成首个任务

启动流程是核心入口。发起人提交表单后,后端要做三件事:创建流程实例、解析开始节点、创建第一个审批任务。

@Transactional public Long startProcess(String processCode, Long applicantId, Map<String, Object> variables) { // 1. 查询启用的流程定义 WfProcessDefine define = processDefineMapper.findByCode(processCode); if (define == null || define.getStatus() != 1) { throw new BusinessException("流程不存在或已停用"); } // 2. 创建流程实例 WfProcessInstance instance = new WfProcessInstance(); instance.setProcessId(define.getId()); instance.setApplicantId(applicantId); instance.setStatus("running"); instance.setCurrentNodeCode(null); instance.setVariables(JSON.toJSONString(variables)); processInstanceMapper.insert(instance); // 3. 找到开始节点出线指向的第一个审批节点 WfNode startNode = nodeMapper.findByProcessAndType(define.getId(), "start"); String nextNodeCode = routeEngine.findNextNodeCode(define.getId(), startNode.getNodeCode(), variables); WfNode nextNode = nodeMapper.findByProcessAndCode(define.getId(), nextNodeCode); // 4. 生成待办任务 Long assigneeId = assigneeResolver.resolve(nextNode, variables); taskMapper.insert(buildTask(instance.getId(), nextNode, assigneeId)); return instance.getId(); }

这个方法加事务很重要。流程实例、任务插入必须同时成功或失败,避免出现“实例建了但没有待办任务”这种脏状态。

4.3 任务审批:推进下一节点

审批动作是所有操作里最容易出错的环节。我从一开始就要求自己把“同意”和“驳回”收敛到同一个 Service 方法里,代码结构大致如下:

@Transactional public void completeTask(Long taskId, Long operatorId, boolean approved, String comment) { // 1. 行锁锁定当前任务,防止并发重复处理 WfTask task = taskMapper.selectForUpdate(taskId); if (!task.getAssigneeId().equals(operatorId)) { throw new BusinessException("当前任务不属于该用户"); } if (!"todo".equals(task.getStatus())) { throw new BusinessException("任务已处理,请勿重复操作"); } // 2. 写审批意见 taskOpinionMapper.insert(buildOpinion(task, operatorId, approved ? "approve" : "reject", comment)); if (!approved) { // 3. 驳回:回退到上一节点或指定节点 String backNodeCode = task.getNodeCode(); // 这里根据驳回类型决定回退到哪 TaskUtils.backToNode(task, backNodeCode); return; } // 4. 标记当前任务完成 taskMapper.updateStatus(taskId, "processed"); // 5. 路由到下一个节点 WfProcessInstance instance = processInstanceMapper.findById(task.getInstanceId()); Map<String, Object> variables = JSON.parseObject(instance.getVariables()); String nextNodeCode = routeEngine.findNextNodeCode(instance.getProcessId(), task.getNodeCode(), variables); if (routeEngine.isEndNode(instance.getProcessId(), nextNodeCode)) { processInstanceMapper.updateStatus(instance.getId(), "completed"); } else { WfNode nextNode = nodeMapper.findByProcessAndCode(instance.getProcessId(), nextNodeCode); Long assigneeId = assigneeResolver.resolve(nextNode, variables); taskMapper.insert(buildTask(instance.getId(), nextNode, assigneeId)); } }

注意这里用到了selectForUpdate,这是我在实际测试中踩过坑之后加的。没有锁的情况下,连续点击两次“同意”按钮,可能同一任务被处理两次,产生两个下一节点任务。加上行锁之后,第二个请求会在第一步卡住,等第一个事务提交后,任务状态已经更新,后续就会抛出“任务已处理”的异常。

4.4 驳回、会签、转办与撤回的进阶处理

基础审批跑通后,我开始补常规流程里必须的进阶功能。

驳回逻辑需要区分“驳回上一节点”和“驳回到指定节点”。我实现的方案是在任务审批弹窗里允许选择驳回目标节点,后端记录驳回节点编码,然后生成一个新的待办任务给目标节点负责人。这里要特别注意任务生成数量:驳回不应该产生多条并行任务,只创建一条目标节点的任务。

会签稍微复杂一点。节点配置里会标记multi=true,启动时给多个会签人都创建任务。每个人审批都只把自己的任务变为 processed,同时去查同节点下还有没有未完成任务;如果没有,流程才继续推进。用一句简单 SQL 就能判断:

SELECT COUNT(*) FROM wf_task WHERE instance_id = ? AND node_code = ? AND status = 'todo'

转办最简单,本质就是修改任务处理人。我会额外写一条转办记录,记录原处理人、新处理人和转办原因,方便后续追溯。

撤回功能只允许发起人在节点还没被处理时使用。撤回前会检查当前任务状态必须是 todo,同时校验操作人等于实例申请人。

5. 前端实现:Vue 页面与接口对接

5.1 页面框架与路由设计

前端项目基于 Vue CLI 创建,路由分成两类:菜单页面和操作页面。基础路由结构如下:

/login /home /process/list 流程列表 /process/design 流程设计 /apply/start 发起申请 /task/todo 待办中心 /task/done 已办中心 /instance/my 我的申请

菜单不是写死的,而是登录后根据角色动态生成。前端在路由守卫里判断用户是否登录,再根据后端返回的权限数据过滤菜单列表。

5.2 发起申请模块的实现方式

发起申请页面的挑战在于:不同流程需要不同表单字段。我最初想得很简单,每个流程写一个页面对应一个组件,但后面发现流程数量一多,代码重复特别严重。

后来改成“动态表单”方案。后端在流程定义里维护字段配置,比如请假流程有“请假类型、开始时间、结束时间、事由”,报销流程有“报销金额、报销明细、发票附件”。前端根据字段配置循环渲染表单控件,配置字段的数据结构类似:

[ { "label": "请假类型", "field": "leaveType", "type": "select", "options": ["事假", "病假", "年假"] }, { "label": "开始时间", "field": "startTime", "type": "date" }, { "label": "请假事由", "field": "reason", "type": "textarea" } ]

这样新增流程只需要在后台配字段,前端不用动代码,实用性明显更强。

5.3 待办中心与审批操作

待办中心是一个表格页,接口返回当前用户待处理的任务。主要三列:流程名称、发起人、到达时间。点击“处理”按钮弹出审批窗口。

审批窗口里我会展示两个关键模块:上面的表单详情(根据变量 JSON 回显)和下方的审批记录。提交时把审批意见、是否通过、任务 ID 传给后端接口:

async function handleApprove(taskId, approved, comment) { submitLoading.value = true try { await axios.post('/api/task/complete', { taskId, approved, comment }) ElMessage.success('操作成功') fetchTodoList() } finally { submitLoading.value = false } }

提交按钮的 loading 状态很重要。审批操作后端已经加了行锁,但前端如果允许重复点击,用户体验会很差,而且会产生一连串无效请求。这是我在实际联调时特别注意的细节。

5.4 流程进度展示

流程实例详情页里,我用步骤条组件展示当前流程所处阶段。前端把后端返回的节点列表、已完成节点、当前节点换算成步骤条数据,就可以让用户直观看到“申请走到第几步、卡在谁那里”。

做了这个功能以后,内部使用反馈明显变好。很多用户关心的不是流程怎么设计,而是“我的单子到底到谁手里了”。这也是工作流系统体验提升的关键点。

6. 完整源码结构与本地部署步骤

6.1 项目目录结构说明

后端工程遵循 SpringBoot 标准分包,前端遵循 Vue 标准结构。核心目录大致如下:

backend/ src/main/java/com/workflow/ controller/ REST接口层 service/ 业务逻辑和工作流核心引擎 mapper/ MyBatis Mapper接口 model/ 实体类 common/ 通用返回结果、异常、JWT工具 src/main/resources/ mapper/ MyBatis XML文件 application.yml 项目配置 frontend/ src/ api/ 接口封装 views/ 登录、首页、流程管理、任务中心等页面 router/ 路由配置 store/ 全局状态管理 main.js 入口

源码里我按这个结构组织,就是为了让核心流程引擎相关代码都集中在 service 包下,改动起来不用到处找。

6.2 环境准备与本地启动步骤

整套系统本地启动需要以下环境:

  • JDK 1.8 或更高版本
  • Maven 3.6+
  • MySQL 5.7 或 8.0
  • Node.js 14+
  • npm 或 yarn

启动步骤我按顺序列一下:

  1. 创建数据库并导入初始化脚本:
mysql -uroot -p -e "CREATE DATABASE wfms DEFAULT CHARACTER SET utf8mb4" mysql -uroot -p wfms < init.sql
  1. 修改后端配置application.yml,重点改数据库地址、账号、密码。

  2. 启动后端:

mvn spring-boot:run
  1. 启动前端:
cd frontend npm install npm run dev

本地开发时,前端通过代理访问后端接口。Vite 或 Vue CLI 里配置一个 proxy,把/api开头的请求转发到http://localhost:8080,这样不会出现跨域问题。

6.3 初始化数据与测试账号

初始化脚本里会预置管理员、普通用户、角色以及一条请假流程定义。测试账号是:

账号密码角色
admin123456管理员
alice123456普通员工/审批人
bob123456普通员工/审批人

登录后,用 alice 发起一条请假流程,节点会走到主管 bob 那里。bob 登录后能在待办中心看到这条任务,处理完以后流程进入下一个节点或结束。

7. 常见问题与排查技巧实录

7.1 后端启动失败速查表

我在开发调试中遇到过的几类启动问题,整理成了表格:

现象原因处理方式
报端口被占用8080 被其他进程占用修改server.port或杀掉占用进程
数据库连接失败数据库地址、密码配置错误检查application.yml和 MySQL 服务状态
找不到数据源驱动Maven 依赖未下载完整mvn clean install后重新启动
LocalDateTime 序列化报错Jackson 没配置时间格式引入jackson-datatype-jsr310并配置格式

启动问题大多是环境问题,排查逻辑从控制台日志往上看第一行报错就够了,不用从头到尾读日志。

7.2 待办任务重复生成

这是工作流系统最常见的隐患。我在刚开始写completeTask时,没有加行锁,也没有在更新任务状态时加条件判断。前端连续点击“同意”后,同一个任务被两个事务先后读取,两个事务都认定任务未处理,于是生成两条下一节点任务。

解决方式就是前面提到的两点:第一步用selectForUpdate锁定任务行;更新状态时用条件更新:

int rows = taskMapper.updateStatusIfTodo(taskId, "processed"); if (rows == 0) { throw new BusinessException("任务已处理,请勿重复操作"); }

这个updateStatusIfTodo的 SQL 是:

<update id="updateStatusIfTodo"> UPDATE wf_task SET status = #{newStatus}, finish_time = NOW() WHERE id = #{taskId} AND status = 'todo' </update>

两层保险,并发下不会产生重复任务。

7.3 前端跨域与 Token 失效

本地开发跨域通过 Vite 代理解决。生产环境如果前后端分开部署,通常用 Nginx 把/api反向代理到后端服务,前端静态资源和后端接口共用同一个对外域名,也不会跨域。

Token 失效后,后端返回 401。前端需要在 axios 响应拦截器里统一处理,遇到 401 时清除本地登录状态并跳转登录页。否则用户会看到一堆报错弹窗,体验非常差。

7.4 流程状态和业务数据不一致

比如报销流程,审批通过后需要把报销单状态改成“已通过”。如果先修改业务单状态,再调用流程审批接口,流程失败时业务单已经更新,两边就乱了。反过来先审批通过,再更新业务单,业务单更新失败,流程状态又和实际不一致。

我建议的做法是把业务状态更新和流程推进放在同一个事务里。流程审批接口接收一个可选回调,比如通过 SPI 或事件机制执行业务逻辑;或者业务系统在流程审批通过后,自己通过监听流程事件来更新业务单。中小型系统里,最简单可靠的方式是把业务更新写进审批的 Service 事务里,保证强一致。

整套系统做下来,我最大的感受是:工作流系统最难的不是写界面,也不是写增删改查,而是把任务状态和流程跃迁在并发和回退场景下搞对。只要把“任务从哪来、处理完以后往哪走、并发时怎么办”这几个问题想清楚,整个系统的骨架就稳了。

如果以后要扩展,我大概率会从消息通知和子流程入手。当前版本的消息提醒还是靠待办中心轮询,后面可以引入 WebSocket 推送;子流程则需要在节点配置里增加子流程类型和映射关系。核心表结构我预留了扩展位,改起来不会伤筋动骨。这套系统的设计思路,也希望能帮你少走一点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询