SpringBoot+Vue工作流管理系统:审批流程设计与实战解析

发布时间:2026/10/13 20:28:39
SpringBoot+Vue工作流管理系统:审批流程设计与实战解析
说起工作流管理系统这几年我经手的企业级项目里十个有八个都跟审批流程沾边。请假要审批、报销要审批、合同要会签、采购要分级确认业务部门提需求时张嘴就是“就是个简单的审批”可真做起来流程自定义、多级审批、驳回重提、流程追溯、待办提醒每一条背后都是完整的代码落地工程。我这次整理的“基于SpringBootVue的工作流程管理系统”就是把这些年实际项目中反复打磨的方案沉淀成一套可运行的完整工程后端用Java生态的SpringBoot做基石MySQL存业务数据MyBatis作为持久层框架前端用Vue搭建交互界面完整源码是真正从数据库建表到页面渲染全链路打通了。这篇博文主要面向三类人正在准备毕业设计或者课程项目的学生、想在中小企业快速搭一套内部审批系统的开发者、对“流程引擎到底怎么落地”有困惑的技术爱好者。我会从需求和技术选型说起再拆数据库设计、后端核心服务、前端页面实现最后把实际开发里踩过的坑一并整理出来这些内容在官方文档里通常找不到。1. 项目背景与技术选型拆解1.1 工作流管理系统的真实业务场景开始写代码之前我习惯先想清楚一件事这个系统到底在解决谁的什么问题。工作流管理系统本质上是在处理“一件需要多人协作、分阶段确认、并且结果要留痕的事务”。最常见的例子就是请假审批员工提交申请部门主管先审人事部门再确认假期余额最后归档生效。这个过程如果靠线下签字或者聊天软件传达最大的痛点就是“流程不可见”——下一步该谁处理、卡在哪个环节、历史审批意见是什么全靠人工问。工作流管理系统把这些步骤搬到线上把审批链路的每一步都变成系统里的状态和记录于是任何人随时都能看到当前进度这就是这类系统的核心价值。我在这套系统里把功能拆成了六个核心模块用户与权限管理、流程定义管理、流程实例管理、待办任务处理、流程历史与监控、统计报表。用户管理解决“谁在用系统”权限模块解决“谁能审批”流程定义解决“审批链路长什么样”流程实例解决“某一张单据现在走到哪一步”待办任务处理解决“我该处理哪些审批”统计报表则是给管理层看的数据视图比如各环节审批时效、部门积压单量。1.2 技术栈选型背后的取舍逻辑技术选型我是围绕“快速交付、部署简单、找人接手不难”三个原则做的。后端框架候选有SpringBoot、SpringCloud甚至更轻量的Node.js方案但这类系统是典型的内部管理场景单体内聚、业务集中没有分布式诉求硬上微服务只会让运维和代码复杂度成倍增加。SpringBoot本身解决了Spring框架配置繁琐的问题内置Tomcat打一个jar包就能跑非常适合这个体量。前端选Vue原因是Vue的中文社区活跃、上手曲线平缓配合Element Plus这类组件库后台管理系统的表格、表单、弹窗、布局基本可以做到拿来即用。数据持久层我特意选了MyBatis而不是Spring Data JPA。不是说JPA不好而是审批流系统里动态SQL出现的频率太高了。比如待办列表的查询条件要按当前用户的角色和部门动态拼接流程表单的查询字段每个流程都不一样MyBatis的XML里用 、 标签写动态SQL可控性非常强。加上国内团队对MyBatis普遍熟悉后续维护方上手成本也低。数据库用MySQL在这个量级的系统里是最稳的选择。1.3 角色划分与权限模型设计权限模型用的是经典RBAC也就是用户-角色-权限三层结构。系统里我预设了几类角色普通员工、部门主管、人事管理员、系统管理员。普通员工能发起流程、看到的是自己相关的菜单部门主管除了提交申请还能处理本部门的审批任务系统管理员负责用户管理、角色分配和流程配置。权限控制不只是影响前端菜单显示后端接口层面也做了同样的拦截校验。这个细节很重要很多初学者项目只做前端路由守卫Postman里直接调接口就能绕过权限这在企业内部系统里是不能接受的漏洞。2. 数据库设计与流程抽象2.1 核心数据表结构全景数据库设计是地基我花在上面的时间比业务代码还多。整套系统我规划了九张核心表按业务域展开讲。第一块是组织权限域包括用户表、角色表、用户角色关联表。用户表除了账号密码基础字段我还保留了部门ID因为审批规则经常按部门维度来定比如部门主管只能看本部门的审批单。密码字段用BCrypt加密后的密文存储即使数据库泄露原始密码也不会暴露。第二块是流程模型域包括流程定义表和流程节点配置表。流程定义表一条记录对应一种业务类型比如“请假流程”“报销流程”核心字段有流程编码、流程名称、是否启用。流程节点配置表把一条流程拆成多个节点每个节点记录节点名称、节点类型、审批人规则、是否会签。这两张表配合起来管理员在后台就能配置出新审批流程不用改一行代码。第三块是流程实例域包括流程实例表、审批记录表和表单数据表。流程实例表存某一次具体申请的核心信息比如流程定义ID、发起人ID、当前节点、实例状态。审批记录表把每次审批动作留痕谁在什么时间审批了哪个节点、意见是什么、动作是同意还是驳回。表单数据表用来解决不同流程字段不一致的问题具体方案在第5章展开。第四块是待办任务表。很多轻量级实现里待办可以由流程实例表实时推导即“当前节点等于某个审批人的流程就是他的待办”。但我还是单独建了待办任务表因为待办列表是访问频率最高的接口之一直接查大表会越来越慢而且任务表可以保存流程名称、发起人、发起时间这类冗余快照列表查询避免大量join。2.2 流程状态机设计流程状态设计我见过不少系统做成了“一锤子买卖”只有未审批和已审批两个状态后面想加驳回功能痛苦不堪。这次我从一开始就用了状态机思维。以请假流程为例流程实例的状态设计为待提交、审批中、已通过、已驳回、已撤回、已终止。待提交是表单起草未正式发起审批中是提交后正在流转已通过表示全部节点审批结束已驳回是某个节点驳回了已撤回是发起人在审批过程中主动撤回已终止由管理员强制作废异常单据。状态机的好处是逻辑清晰。每个状态下系统允许的操作集合不同。比如待提交状态只允许发起人提交或删除审批中状态只允许当前审批人同意或驳回发起人可以撤回管理员可以终止。我在代码里写了一个状态流转校验方法任何状态变更都会先校验“从当前状态到目标状态”是否在合法流转表里从机制上杜绝非法操作。2.3 审批节点与审批人配置的抽象这是整套系统里最有意思的部分。工作流类产品复杂很大程度在于审批人的确定规则多种多样。同样一个请假流程A部门要求主管审批B部门可能要求总监先批。我在节点配置表里设计了审批人规则字段用规则编码加参数的方式存储支持四种规则指定用户、指定角色、发起人部门主管、发起人本人。指定用户是流程发起时就固定审批人指定角色由系统自动匹配该角色下所有启用用户发起人部门主管通过发起人ID反查部门再查部门主管发起人本人用于那种提交即归档的通知类流程。审批人确定之后还有两个常见问题多候选人和多级审批。多候选人意味着同一节点可被多个有权限的人处理但只需要其中一个人处理完就算通过。我用待办任务加处理状态标记谁先处理了就把其他待办标记为“已失效”。多级审批则是节点表里的序号字段从小到大依次激活前一个节点审批结束才生成下一节点的新待办任务。3. SpringBoot后端核心实现3.1 工程初始化与核心依赖后端工程用的是SpringBoot 2.7.x版本稳定性和社区资料丰富度都比较合适兼容JDK8和JDK11部署时不用纠结服务器JDK版本。核心依赖包括spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java、spring-boot-starter-validation、lombok、jjwt。密码加密这块我用的是spring-boot-starter-security里的BCrypt实现但认证方式是自己定义的拦截器没有引入整套安全框架避免过度设计。application.yml里最值得说的是MyBatis配置。我开启了驼峰映射map-underscore-to-camel-case: true数据库字段dept_id自动映射实体类的deptId属性省去在XML里写大量resultMap。同时指定mapper-locations路径mapper接口与XML同包同名。数据源配置加上了connection-init-sql: set names utf8mb4保证中文和特殊字符正常存储这个细节容易被忽略。spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/workflow_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: xxxxxx connection-init-sql: set names utf8mb4 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl3.2 统一响应结构与全局异常处理接口设计从一开始就定了规矩所有接口返回统一结构。我写了一个Result 泛型类包含code、message、data三个字段。登录成功返回Result.success(userInfo)操作失败返回Result.error(500, 审批失败)。前端axios的响应拦截器只解析这一种结构无论后端哪个接口出错前端都能统一处理。除了统一响应全局异常处理也关键。每个接口写try-catch的话代码会乱成一锅粥。我用RestControllerAdvice加ExceptionHandler做了全局异常处理器把业务异常比如流程状态不对时抛出的自定义BizException、参数校验异常MethodArgumentNotValidException、未知异常分开处理返回不同的HTTP状态码和提示信息。这样后端代码可以放心抛业务异常提示语原样返回给前端展示不用层层处理。3.3 MyBatis持久层动态SQL实践MyBatis的使用分两种情况简单的单表查询写在Mapper接口注解里复杂查询写在XML里。比如用户表按账号查询直接Select注解就能搞定。但审批历史列表、待办列表这种多条件多表关联的查询XML里的动态SQL就派上用场了。待办列表的XML写法需要根据当前用户角色动态决定过滤条件。我先查出当前用户拥有哪些角色和部门信息然后把“当前待办任务的assignee等于当前用户ID或者任务审批角色包含当前用户拥有的角色”作为where条件。这里用 标签判断不同角色类型避免拼接出SQL注入风险的字符串。列表查询和总数查询要拆成两个方法PageHelper才能分别正确执行limit语句和count语句这个坑后面会单独提。以流程列表查询为例核心的XML片段大概是这样的结构select idselectTodoList resultTypecom.demo.workflow.entity.TodoTask SELECT t.id, t.process_instance_id, p.process_name, u.real_name AS creator_name, t.node_name, t.create_time FROM todo_task t LEFT JOIN process_instance p ON t.process_instance_id p.id LEFT JOIN sys_user u ON p.creator_id u.id where t.status 0 if testuserId ! null AND t.assignee_id #{userId} /if if testroleIds ! null and roleIds.size() 0 AND (t.assignee_id #{userId} OR t.assignee_role_id IN foreach collectionroleIds itemrid open( separator, close) #{rid} /foreach) /if /where ORDER BY t.create_time DESC /select这种写法规避了不可控的多表关联也能让数据库索引正确使用。3.4 审批任务处理服务实现思路审批任务处理是核心服务我拆成了一个ApprovalService类主要包含approve、reject、revoke、terminate、delegate方法。approve的大概逻辑分三步。第一步按任务ID查待办任务校验任务状态是待处理校验当前操作人有处理权限。第二步在数据库事务里完成三个操作待办任务状态改为“已处理”审批记录表插入“同意”记录流程实例表的当前节点指针更新为下一节点。第三步按下一节点的审批人规则生成新待办任务如果下一节点是结束节点流程实例状态更新为“已通过”。这里必须加Transactional注解否则第二步里生成任务失败前面的状态修改会回滚不完整出现“审批记录显示已同意、流程还停在原节点”的脏数据。reject逻辑相对简单流程实例状态改为“已驳回”清空当前所有未处理待办任务写入驳回意见。revoke我做了限制只有流程实例当前节点还在第一级审批时发起人才能撤回避免审批环节已在多个部门间流转后又被发起人一声不吭撤掉导致流程混乱。Transactional(rollbackFor Exception.class) public void approve(String taskId, String userId, String comment) { TodoTask task todoTaskMapper.selectById(taskId); if (task null || task.getStatus() ! 0) { throw new BizException(待办任务不存在或已处理); } if (!checkPermission(task, userId)) { throw new BizException(当前用户无审批权限); } // 乐观锁更新待办状态防止并发重复审批 int updated todoTaskMapper.updateStatusWithVersion(taskId, 1, task.getVersion()); if (updated 0) { throw new BizException(该任务已被其他人处理); } ApprovalRecord record new ApprovalRecord(); record.setTaskId(taskId); record.setProcessInstanceId(task.getProcessInstanceId()); record.setApproverId(userId); record.setAction(approve); record.setComment(comment); approvalRecordMapper.insert(record); advanceProcess(task.getProcessInstanceId(), task.getNodeId()); }3.5 基于JWT的登录认证与操作权限控制认证方案用了JWT。登录成功后签发token里面包含用户ID、用户名、角色列表用密钥签名并设置过期时间。前端每次请求在Header里带Authorization: Bearer token后端通过拦截器解析并放行。选JWT而不是Session一是后端服务可以无状态化后续多实例部署不需要做Session共享二是token里能放有效信息解析出来就能知道当前操作人不用每接口都查库。拦截器实现顺序要注意先放行登录接口、静态资源接口再对剩余接口做token校验。校验时不仅判断token是否有效还要做权限映射token里的角色列表是否包含接口所需权限标识。我封装了RequirePermission注解标注在Controller方法上审批接口需要workflow:approve菜单管理接口需要system:menu:list拦截器统一检查。权限判断逻辑集中一处业务代码里不会散落if判断。4. Vue前端工程实现4.1 项目结构划分与路由设计前端用Vue3搭配Vue Router 4和Pinia状态管理。管理系统页面结构高度相似我没有把页面拆成平铺的独立路由而是做了一层布局路由。根路由指向Layout组件Layout内部通过嵌套路由渲染不同业务页面左侧菜单栏、顶部导航栏是公共组件中间内容区随路由切换。用户登录后始终在同一个框架内操作公共逻辑集中到Layout处理。路由守卫做了两级检查第一级判断是否登录未登录访问业务页面跳转登录页第二级判断角色权限因为做了动态菜单根据用户角色从后端拉取可访问菜单集合后动态注册路由。前端看不到没有权限的菜单即使手动输入路径动态路由里没有这个路径也会被拦截回首页。4.2 axios请求封装与登录态处理axios封装思路固定但有些细节容易踩坑。在src/utils/request.js里创建axios实例设置了baseURL指向后端统一接口前缀超时时间设15秒——太长用户烦躁短了导出报表接口会超时。请求拦截器从localStorage取出token放入请求头响应拦截器先判断HTTP状态码非2xx统一弹错误提示再判断业务code如果是401说明token过期或失效直接清除本地登录信息跳转登录页。文件下载的响应处理是容易漏的点。导出Excel接口返回二进制流而不是JSON不能走统一的JSON解析逻辑。我的做法是在request方法里增加responseType参数调用导出接口时指定为blob在业务代码里用URL.createObjectURL生成临时下载链接。// src/utils/request.js 核心逻辑 const service axios.create({ baseURL: /api, timeout: 15000 }); service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }); service.interceptors.response.use( response { // 文件流直接返回 if (response.config.responseType blob) return response; const res response.data; if (res.code ! 200) { ElMessage.error(res.message || 请求失败); if (res.code 401) { localStorage.removeItem(token); router.push(/login); } return Promise.reject(new Error(res.message)); } return res.data; }, error { ElMessage.error(error.message || 网络异常); return Promise.reject(error); } );4.3 待办列表与审批操作页面实现待办列表页是使用最频繁的页面。核心交互是左侧表格展示当前用户待办任务点击某条任务弹出详情抽屉展示流程基本信息、表单数据和历史审批记录抽屉底部按当前状态渲染“同意”“驳回”“撤回”按钮。表格用Element Plus的el-table加el-pagination每页10条。点击“同意”或“驳回”时前端把两个数据一起提交审批意见和待办任务ID。很多新手会把审批意见跟表单数据混在一起提交后端解析逻辑会很乱。我在项目里明确了数据划分业务表单数据走表单模型审批操作走独立对象{ taskId, comment, action }两个对象分开提交后端接口入参也设计成两个独立部分清晰不易出错。详情里展示历史审批记录用了el-timeline时间线组件按审批时间倒序展示每条记录的处理人、处理结果、意见。审批人字段为空就用“待处理”占位用户可以直观看到当前流程停在哪个角色手上。4.4 流程配置的前端方案很多项目为了“可视化拖拽流程设计器”引用了很重的第三方库光是适配和学习成本就消耗大量时间。这套系统里流程配置界面用了偏轻量的方案左侧操作栏选择当前流程右侧用表单修改流程基本信息下方节点配置区每行代表一个审批节点可以设置节点名称、审批人规则类型和参数。节点顺序靠拖拽排序组件调整序号保存时按顺序提交。这种方案虽然没有拖拽连线设计器炫酷但对90%的企业内部审批场景已经足够。配置人员十分钟内上手部署也不用额外维护一套前端设计器的构建流程。后续如果预算充足再考虑引入成熟工作流引擎配合在线流程设计器迁移时表结构里的流程编码字段也能对应上。5. 核心功能模块实战拆解5.1 流程发起动态表单与流程绑定流程发起模块解决的痛点是不同流程的表单字段完全不同。请假需要“请假类型、开始日期、结束日期、请假原因”报销需要“报销类别、发票金额、发票张数、事由说明”。如果每个流程建一张表再写一套增删改查开发量会随流程数量线性增长。我采用了表单配置加JSON存储方案。流程定义表加一个form_config字段表示该流程需要渲染哪些字段、字段类型、是否必填。前端发起页面拿到form_config动态渲染表单控件。用户填写完成后把表单值序列化成JSON对象存到表单数据表的form_data字段挂上流程实例ID。查询详情时后端解析JSON再结合form_config字段定义前端就能回显。这个方案的弱点是不能用SQL对表单里业务字段做统计查询。但针对审批系统实际使用频率绝大多数查询都是围绕流程实例和审批人的。真有必须要统计的字段比如报销总额可以由管理员在流程配置里标记为可统计列我再在MySQL里冗余一张扩展列表辅助存储。5.2 审批通过节点推进与待办生成审批通过的处理逻辑我在第三章讲过服务方法的大致逻辑这里把数据变化过程完整串一遍。假设请假流程有三个节点部门主管审批、人事复核、归档。部门主管点击同意后事务内执行该主管对应的待办任务记为已完成向审批记录表写入“同意”及意见读出流程定义里的下一个节点序号因为是人事复核按照节点配置生成新待办任务assignee是人事角色对应所有用户流程实例current_node字段更新为人事复核。这里有个“多人待办”细节如果人事角色有三个人而节点配置是多选一系统会同时生成三条待办任务但流程实例本身还是同一个。三个人里任意一个先点同意流程就继续推进另外两条待办任务在事务里更新为“已失效”不会出现重复审批。这个并发控制成本很低但效果很好。5.3 驳回与重新提交节点的回退策略驳回设计选了一条务实的路线默认驳回到第一个节点也就是打回发起人重新修改。好处是逻辑清晰发起人修改后重新提交流程从头走。有些场景要求“驳回到上一节点重复审批”需求方提出这个诉求时往往没想清楚上一节点是谁——是上一个审批人还是上一个节点。若要支持这种模式节点配置表需要加回退策略字段支持“退回发起人”“退回上一节点”“退回指定节点”三种类型系统里预留了字段目前默认第一种。驳回后重新提交系统不是新建流程实例而是复用原来实例ID状态从“已驳回”改回“审批中”旧的审批记录保留但新增一轮记录表单数据用重新编辑后的值覆盖。这样历史留痕和最新提交都能保留后续审计可以看到某条流程共被驳回几次、每次改了什么。5.4 流程监控与统计报表监控报表是给管理者看的功能。前端用ECharts做了两个实用图表部门审批量柱状图和审批时效折线图。部门审批量按用户表部门ID分组SQL里count加group by再关联部门名称。审批时效折线图计算每条流程从发起到通过的时间差按天分组聚合平均值用来观察“周五是不是审批特别慢”——这个数据在推进系统优化时很有说服力。统计接口查询条件做成参数可选支持按流程类型、部门、时间段筛选后端动态SQL拼接where前端用el-date-picker选时间范围。实现不复杂但管理价值很高。6. 常见问题与排查实录6.1 PageHelper分页插件失效与count查询错误项目里用PageHelper做分页特定场景会失效。最常见的是“分页插件必须紧跟在要分页的查询语句之前”这个规则被违反。比如查询之前先做了其他查询操作PageHelper就会把前一条查询当作分页目标导致分页不生效甚至报错。排查很久后发现问题在于startPage和Mapper查询之间不能有任何间隙中间做角色权限校验、参数初始化这些操作都会出问题。另一个容易忽略的是count语句拼接。当列表SQL带了group by时PageHelper自动生成的count不去掉order by或者不去重统计总数会和列表条数不一致。我的经验是复杂统计列表不要依赖分页插件自动count自己在XML里写精确的count查询。多写点代码但结果完全可控。6.2 跨域配置与预检请求拦截前后端分离开发时跨域是必踩的坑。一开始只配置服务端header允许跨域却发现带自定义header的请求全部失败。原因是浏览器在发起POST请求前会先发OPTIONS预检请求后端如果没有处理预检请求正式请求永远不会发出。我在拦截器里特意判断请求方法是OPTIONS就直接返回200并放行CORS配置里确认了Access-Control-Allow-Headers包含Authorization和Content-Type一个都不能漏。生产环境我一般不建议完全开放跨域而是用nginx反向代理把前后端配置在同一个域名下不同路径转发不同服务。浏览器里是同源请求连CORS配置都不需要。开发环境保留宽松配置生产环境改nginx两套方案写清楚能避免不少线上问题。6.3 审批并发提交导致状态错乱有段时间测试环境出现奇怪现象两条审批记录显示同一节点被不同的人同时同意流程实例状态也已经通过。排查下来问题出在并发控制上。假设流程实例current_node是节点2两个有权限的人同时点同意两个事务都读到current_node节点2都认为自己可以把节点推进到节点3最后都提交成功流程实例当前节点被后提交的事务覆盖。严格来说结果可能碰巧是对的但中间写了两条审批记录而且第二个人的待办任务本应失效却还是待处理状态。我在流程实例表加了version字段更新节点状态时用UPDATE ... WHERE id? AND version?乐观锁写法更新成功version加一并重新校验。影响行数为0说明并发冲突后提交的用户会收到“该任务已被其他人处理”的提示。待办任务表也加了唯一约束和状态校验从源头避免重复处理。6.4 前端表单回显与异步渲染的坑动态表单回显是前端很隐蔽的坑。表单进入编辑模式时通过接口拿到form_data JSON和form_config字段定义再渲染表单控件赋值。最开始拿到数据后立刻给表单对象赋值页面上很多控件显示为空。原因是Element Plus某些组件比如el-select、el-date-picker在初始化阶段没监听到数据变化或者组件渲染时机比数据返回早。解决方法是赋值操作放到组件渲染完成后的nextTick里或者等接口返回后再创建表单实例而不是先渲染空表单。我用了“数据就绪”布尔状态拿到全部配置和值之后才把表单渲染出来几乎避开所有回显边界问题。处理异步渲染时建议在控制台检查赋值对象的字段名是否跟form_config里的字段名对得上——排查过几次最后发现只是多拼了一个字母。结尾我写完这套系统最大的体会是工作流系统难的不是某一项技术而是状态管理和权限控制这些容易被忽视的边界细节。前端页面可以做得简单后端接口也可以做得简单但如果流程状态没有用状态机思维去约束、权限没有在接口层做校验、数据表没有为高频查询做冗余设计系统上线后会不断冒出“为什么这条单子消失了”“为什么他能审批别人的单子”这类问题。如果基于这套设计做自己的项目建议先从需求入手流程类型控制在两三种用户角色控制在五种以内先把“发起-审批-结束-驳回-撤回”这条主链路跑通再做菜单权限和统计报表。后续流程定义表加字段、审批节点加类型都可以平滑扩展但地基的表结构尽量不要推翻重来。最后分享一个小技巧流程系统上线后多做一个“流程耗时”可视化报表和业务部门沟通时会有非常有力的数据抓手。老板问为什么审批这么慢把各节点平均耗时拉出来问题出在哪个角色、哪个人手上一目了然。这种从系统数据出发的优化点比任何代码层面的炫技都更让业务方信服。