ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue工作流系统设计与实现:审批流转核心解析

SpringBoot+Vue工作流系统设计与实现:审批流转核心解析 做业务系统的人迟早会遇到工作流。从请假审批到报销流转从采购申请到合同会签“工作流程管理系统”这个单词听着唬人拆开无非是把线下盖公章的流程搬到线上再把每一步的操作记录留痕。这次梳理的这套基于SpringBootVueMyBatisMySQL搭建的工作流程管理系统是我前后花了三周时间从零写出来的完整项目代码量不大但覆盖了流程定义、实例管理、待办任务、审批流转、权限控制这些核心环节整套源码直接跑起来就能用适合正在学Java全栈的开发者拆开研究也适合中小型业务系统直接拿去改造成自己的审批模块。先说说这套系统到底解决了什么问题。以前公司内部走一个报销审批要打印纸质单子、找三个领导签字、再交到财务全程没留痕单子丢了更是说不清。换成这套系统之后流程模板在后台配一次往后所有人发起报销都走同一套规则自动找到对应审批人、每一步操作都有记录、状态随时可查。就这么一个简单的诉求落到技术层面却要处理流程引擎、状态流转、任务分配、权限隔离一堆事。下面我把这套系统的设计和实现完整拆开讲从选型原因到数据库建模再到前后端代码怎么串起来最后是部署启动和我在实际开发中踩过的一堆坑。1. 项目定位与技术选型为什么这个组合在2025年依然能打1.1 工作流系统的核心痛点是什么工作流系统最核心的问题只有一个如何把一张流程图变成数据库里可以查询、可以推进的活数据。线下流程长什么样发起人填单子、第一个审批人看内容、同意了给第二个人看、第二个人不同意又退回给发起人。线上系统要把这个逻辑固化下来至少需要回答三个问题当前流程走到哪个节点了这个节点的处理人是谁处理人点同意或驳回之后流程下一步去哪如果不用现成的流程引擎比如Flowable、Activiti这都得靠自己的表结构和代码逻辑来支撑。我在设计这套系统时有过一个犹豫要不要引入Flowable直接用别人封装好的工作流引擎后来算了笔账Flowable功能确实强大但学习成本摆在那里BPMN 2.0规范、流程部署、监听器、网关有的是概念要理解。我这边的业务场景无非是“单线审批”顶多加个条件分支自己用表结构加状态机完全Hold得住还更容易按需求定制。如果不是复杂的人工流程编排自研工作流引擎是性价比最高的选择。1.2 SpringBoot MyBatis MySQL后端三件套的逻辑技术选型这块没有悬念就是Java生态最主流的一套组合。SpringBoot负责把整个后端服务搭起来。自动配置、内嵌Tomcat、开箱即用的starter依赖让我不用花时间配置Spring的XML文件集中精力写业务代码。项目里用到spring-boot-starter-web、spring-boot-starter-validation、spring-boot-starter-security来做接口认证。MyBatis负责数据访问。相比Spring Data JPAMyBatis的SQL是直接写在Mapper XML里对于工作流这种需要多表关联查询的场景特别合适。查待办列表要关联任务表、节点表、流程实例表写SQL我能在脑子里面完全掌控最终的查询语句出了性能问题也容易分析。如果让JPA帮我自动生成SQL反而可能出现莫名其妙的N1查询。MySQL做数据库理由就更直接了部署简单、运维资料多、对中小项目完全没有压力。这套系统的事务处理、行级锁都是MySQL的强项工作流业务恰恰对数据一致性要求比较高流程状态不能乱掉。1.3 Vue前端的选择与整体技术架构前端我选的是Vue 3 Vite Element Plus Pinia。Vue 3的组合式API写起来比Vue 2的选项式API清爽很多模板里少了一大堆this。Vite开发时的热更新速度确实快不用像Webpack那样等半天这个体感的提升在实际开发中非常明显。Element Plus提供了现成的表格、表单、弹窗、树形控件后管系统80%的界面都能直接组装出来。Pinia做状态管理主要用来存当前登录用户的角色权限信息。Axios负责跟后端交互统一封装了请求拦截器和响应拦截器后端返回401时自动跳到登录页。整体架构分了三层前端浏览器发起请求经过Nginx或Vite代理转到后端SpringBoot服务后端通过MyBatis访问MySQL库。前后端完全分离依赖接口文档联调这种结构在2025年依然是中小型管理系统最稳妥的实践方式。2. 数据库设计与流程引擎模型工作流系统的“骨骼”2.1 六张核心表的设计思路整个系统的数据库结构是整个项目最值得琢磨的部分。工作流的线索比较长从流程定义到流程实例再到任务和记录一层套一层。为了保证逻辑清晰、方便扩展我把表拆分成了两大部分基础数据部分用户、角色和流程核心部分流程定义、节点、实例、任务、记录。表名用途核心字段sys_user用户表id, username, password, real_name, dept_idsys_role角色表id, role_code, role_namesys_user_role用户角色关联表user_id, role_idflow_definition流程定义表id, name, node_json, statusflow_node流程节点表id, definition_id, node_name, node_type, approver_role_id, next_node_idflow_instance流程实例表id, definition_id, applicant_id, current_node_id, statusflow_task任务表id, instance_id, node_id, assignee_id, statusflow_task_record审批记录表id, task_id, operator_id, action, comment用户和角色的设计不用多说经典RBAC模型。重点说流程相关的五张表。流程定义表记录了一个流程模板的基本信息比如“请假审批”是一个流程定义“报销审批”又是一个。每个流程定义下面挂多个节点节点节点有开始、审批、条件、结束四种类型这四类节点串起来就是一张有向流程图。流程实例是“某一次实际的请假申请”包含发起人、当前处在哪个节点、整个流程的状态。任务表更细它表示“某个节点上需要谁处理的事项”一个流程实例在同一时间可能有一个或几个待办任务。审批记录表就是流水账把每一次同意或驳回的原因记录下来。2.2 流程节点怎么表达用JSON还是一个节点一张表这里我有个设计上的取舍想单独拿出来讲因为它是整个工作流系统最关键的一处。方案A节点只存JSON。流程定义表里一个node_json字段把整个流程的节点、连线、条件全部用JSON串存进去需要执行的时候用代码解析JSON。方案B节点一张表。流程定义和节点表通过definition_id关联每个节点一行通过next_node_id字段串成链表结构。我最终选了方案B原因是用JSON存节点对代码的侵入性太强。每走一步都要现解析、现判断出错了不好排查。而用表存节点每个节点就是一行数据查询节点信息只需要一次SQL开发的时候把节点抽象成Java对象逻辑非常直观。节点之间通过next_node_id单向关联形成一条审批链。当然方案B也有局限它只适合线性流程或简单条件分支如果要支持复杂的并行网关、会签、子流程表结构就吃力了。但多数中小公司的审批流程是“单线走到底”用方案B省心且可靠。下面是节点表的SQL建表语句我贴这段主要为了演示节点字段怎么设计CREATE TABLE flow_node ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 节点ID, definition_id BIGINT NOT NULL COMMENT 所属流程定义ID, node_name VARCHAR(50) NOT NULL COMMENT 节点名称, node_type TINYINT NOT NULL COMMENT 节点类型:1开始 2审批 3条件 4结束, approver_role_id BIGINT DEFAULT NULL COMMENT 审批节点对应的审批角色ID, next_node_id BIGINT DEFAULT NULL COMMENT 下一节点ID, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );审批节点的approver_role_id很关键它表示这个节点该由哪个角色来处理。实际查询时再通过角色关联用户就能定位到具体审批人。2.3 状态机与流转规则流程实例的状态我用一个TINYINT字段来维护0代表草稿、1代表审批中、2代表已通过、3代表已驳回。这个设计看着简单却是有意为之。一开始我想把“已通过”和“已驳回”再继续细分状态后来发现完全没必要审批记录表里已经有每一次操作的明细了流程实例只需要记录当前整体状态查询列表时给用户展示一个直观的标识就够。流转规则是工作流的核心逻辑我用一句话概括同意就找下一个节点驳回就回到流程起点。实际实现时还要处理几个边界条件当前节点是开始节点发起人提交后直接找它的下一个节点生成任务当前节点是最后一个审批节点审批人同意后整个流程实例状态改为“已通过”审批人驳回时流程实例状态改为“已驳回”不产生新的待办任务节点类型为“条件”时根据表单数据判断走哪个分支这个是我预留的扩展点这条规则适用于绝大多数审批场景。如果之后要支持驳回给上一节点而非发起人需要在流程实例表加一个prev_node_id记录路径历史我在代码里预留了接口后文会讲。3. 前后端核心功能实现发起、审批、权限三件事3.1 后端发起流程与任务生成的实现后端接口我按功能模块拆分统一放在com.example.workflow.controller包里。发起流程这个动作前端提交的是流程定义ID和业务表单数据后端要做三件事创建流程实例、找到开始节点的下一节点、生成第一个待办任务。PostMapping(/api/workflow/instance/start) public ResultLong startProcess(RequestBody Valid StartProcessRequest request) { // 1. 查询流程定义校验状态必须为已启用 FlowDefinition definition flowDefinitionMapper.selectById(request.getDefinitionId()); if (definition null || definition.getStatus() ! 1) { throw new WorkflowException(流程不存在或未启用); } // 2. 创建流程实例 FlowInstance instance new FlowInstance(); instance.setDefinitionId(definition.getId()); instance.setApplicantId(SecurityUtils.getCurrentUserId()); instance.setNodeId(findStartNode(definition.getId()).getNextNodeId()); instance.setStatus(1); flowInstanceMapper.insert(instance); // 3. 根据下一个节点信息生成待办任务 FlowNode firstApproveNode flowNodeMapper.selectById(instance.getNodeId()); FlowTask task new FlowTask(); task.setInstanceId(instance.getId()); task.setNodeId(firstApproveNode.getId()); // 这里根据节点配置的角色找到该角色下所有用户每个用户生成一条待办 ListLong userIds roleUserMapper.selectUserIdsByRoleId(firstApproveNode.getApproverRoleId()); for (Long userId : userIds) { task.setAssigneeId(userId); flowTaskMapper.insert(task); } return Result.success(instance.getId()); }这里有个细节我踩过坑一个审批节点绑定的是一个角色角色下可能有多个用户。我最初的设计是每个用户生成一条待办任务结果发现这样处理有个问题只要有一个用户处理了任务其他人的待办列表里这条任务应该消失否则就会出现多人同时处理同一任务的脏数据。后来我在查询待办列表时做了过滤关联查询该流程实例是否已被处理如果已被处理就不再展示。这其实是一种“抢占式”任务分配策略简单业务够用但要做真正的“会签”则需要引入任务锁本文后面会展开。3.2 审批流转的核心代码逻辑审批接口是整个系统最复杂的部分因为它要在一个事务里完成多个更新操作更新当前任务状态、写审批记录、流转到下一节点或结束流程。这段代码我单独抽了一个WorkflowEngineService避免Controller里堆逻辑。Transactional(rollbackFor Exception.class) public void approve(ApproveRequest request) { // 1. 获取任务并校验状态 FlowTask task flowTaskMapper.selectById(request.getTaskId()); if (task null || task.getStatus() ! 0) { throw new WorkflowException(任务不存在或已处理); } // 2. 校验操作人是否为任务处理人 if (!task.getAssigneeId().equals(request.getOperatorId())) { throw new WorkflowException(无权处理该任务); } // 3. 写审批记录 FlowTaskRecord record new FlowTaskRecord(); record.setTaskId(task.getId()); record.setOperatorId(request.getOperatorId()); record.setAction(request.getAction()); record.setComment(request.getComment()); flowTaskRecordMapper.insert(record); // 4. 当前任务置为已处理 task.setStatus(1); flowTaskMapper.updateById(task); // 5. 根据动作流转流程 FlowNode currentNode flowNodeMapper.selectById(task.getNodeId()); if (AGREE.equals(request.getAction())) { FlowNode nextNode flowNodeMapper.selectById(currentNode.getNextNodeId()); if (nextNode null || nextNode.getNodeType() 4) { // 到这里说明流程走到了末尾整体置为已通过 flowInstanceMapper.updateStatus(task.getInstanceId(), 2); } else { // 生成下一节点的待办任务逻辑同发起流程 createTaskForNode(task.getInstanceId(), nextNode.getId()); } } else { // 驳回流程实例状态置为已驳回 flowInstanceMapper.updateStatus(task.getInstanceId(), 3); } }这段逻辑里面最需要注意的是Transactional注解。事务的粒度是整个审批动作也就是说从“查任务”到“更新流程状态”之间任何一步抛异常前面所有数据库操作都回滚。这就保证了不会出现“审批记录写了但任务状态没更新”这种半截事务。3.3 权限控制与数据隔离系统里的角色目前有三种管理员、部门主管、普通员工。管理员能配置流程定义、查看所有流程实例部门主管能审批自己部门的流程普通员工只能发起流程和处理分派给自己的任务。权限控制我用了两层。第一层是Spring Security的登录认证和接口鉴权配置了基于URL的访问规则比如/api/workflow/definition/**只有ADMIN角色能访问第二层是业务级的数据权限查询列表时必须带上当前登录用户的ID或部门ID防止普通用户通过改参数查出别人的数据。GetMapping(/api/workflow/task/todo) public ResultListTaskVO queryTodoList() { Long currentUserId SecurityUtils.getCurrentUserId(); ListTaskVO list flowTaskMapper.selectTodoListByAssignee(currentUserId); return Result.success(list); }前端这边配合做了路由守卫用户在登录后把角色信息存到Pinia里路由跳转时判断是否有权限。虽然前端路由拦截可以被绕过但它保证了用户不会看到不属于自己的菜单入口真正的数据安全还是靠后端接口校验。3.4 前端页面路由、组件与关键交互前端我按功能拆成几个页面登录页、首页仪表盘、流程发起页、待办任务页、已办任务页、流程管理页。路由配置直接写在router/index.js里用动态路由的方式根据角色加载菜单。关键页面是“发起流程”和“我的待办”这两个我把核心逻辑说一下。发起流程页面里有一个下拉框列出当前用户所有可用的流程定义。选中流程后表单区域根据流程定义动态渲染。由于这个项目的表单比较简单我采用的方式是前端根据流程类型手动写表单组件没有做动态表单解析。如果读者以后想做成通用的动态表单需要引入表单Schema 动态渲染组件那是另一个复杂话题。我的待办页面是使用频率最高的界面我把它做成了element表格展示字段包括流程名称、发起人、发起时间、当前节点、操作按钮。点击操作按钮弹出审批对话框里面有同意、驳回两个按钮和一个备注输入框。提交后调用审批接口刷新待办列表。const handleApprove async (taskId, action) { await request.post(/api/workflow/task/approve, { taskId, action, comment: commentForm.value }) ElMessage.success(action AGREE ? 已同意 : 已驳回) loadTodoList() }为了统一处理登录态失效和接口报错我用axios封装了request工具service.interceptors.response.use( response { if (response.data.code 401) { router.push(/login) return Promise.reject(new Error(登录失效)) } return response.data }, error { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } )4. 从零到一部署启动环境准备与数据库初始化4.1 环境版本要求我在开发时使用的环境是这样的读者复现时保持大版本一致即可组件版本备注JDK17如果使用Spring Boot 2.x则JDK 8/11均可Maven3.8编译打包用MySQL8.05.7也兼容建议8.0Node.js18Vite 5要求Node18以上npm9随Node一起安装如果你本地还留着Spring Boot 2.x的习惯注意Spring Boot 3.x已经把javax.servlet改成jakarta.servlet如果项目中用到原生的Servlet API一定要改包名。我这套系统的代码基本都是Spring自己的注解所以升级到3.x没有遇到迁移问题。4.2 数据库初始化过程在MySQL里创建数据库然后把初始化脚本跑一遍。脚本要确保包含用户表、角色表、关联表和流程相关表同时内置基础数据比如管理员账号、两个审批角色、一条“请假审批”的流程定义和对应节点。我建库时用的字符集是utf8mb4这个非常关键。之前有项目用了utf8字符集结果用户备注里存入emoji表情直接报错utf8mb4才能完整支持四字节字符。排序规则用的是utf8mb4_general_ci如果你对大小写敏感比较挑剔可以用utf8mb4_bin。初始化脚本中还得给Spring Security用的用户认证表加上基础用户数据。密码我存的是BCrypt加密后的字符串明文密码是admin123这个加密动作在系统启动后通过一个CommandLineRunner自动完成读者拿到的源码不用手动处理密码加密。4.3 后端配置与启动命令后端配置文件application.yml是核心其中数据源配置和MyBatis配置常见问题最多。server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/workflow_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.workflow.entity configuration: map-underscore-to-camel-case: true这里重点说三个参数。第一个是serverTimezoneAsia/Shanghai如果不加连接MySQL 8时可能会遇到时差问题导致时间字段查出来差8个小时。第二个是map-underscore-to-camel-case设为true让数据库的create_time自动映射到Java对象的createTime属性避免每个字段都手写映射。第三个是mapper-locations指向classpath:mapper所有Mapper XML都放在resources/mapper目录下。配置改完之后在项目根目录执行mvn clean package -DskipTests java -jar target/workflow-system-1.0.0.jar启动成功后会看到Spring Boot的Logo和Tomcat started on port 8080日志。4.4 前端配置与启动命令前端代码clone下来之后先执行npm install安装依赖然后改vite.config.js里的代理配置server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }这样前端请求/api开头的接口时Vite开发服务器会自动转发到后端的8080端口绕开了跨域问题。生产环境部署时Nginx配置里同样需要做一层反向代理把/api转到SpringBoot服务。运行npm run dev之后打开浏览器访问localhost:3000用管理员账号登录进到流程管理页面确认能看到初始化时插入的请假流程数据然后新建一个普通用户、发起一条请假申请这时候切换到审批账号登录待办列表里就能看到这条任务整个闭环就走通了。5. 排雷实录我在这套系统上踩过的坑5.1 事务自调用导致Transactional失效这个坑我印象最深。当时我把审批逻辑写在了Controller里Controller直接调用自己的一个私有方法那个方法上标的Transactional。结果运行时报错了就全部写进库流程状态也变了任务也变成已处理唯独审批记录没写上。原因很典型Spring的事务代理是基于AOP的只有通过代理对象调用方法时事务注解才生效。同类内部调用走的不是代理注解直接失效。解决办法有几种最简单的是把事务方法放到单独的Service类里让Controller注入Service去调用。我现在项目中WorkflowEngineService就是一个独立Bean审批逻辑都封装在里面事务稳定生效。5.2 跨域配置踩泥坑前后端分离开发时前端在3000端口后端在8080端口直接请求必定跨域。我一开始在后端写了个CorsConfig允许所有来源、所有请求头结果发现预检请求一直报错后来才搞明白是因为没有同时放行所有HTTP方法DELETE请求被拦了。最终的配置是明确允许的方法列表GET、POST、PUT、DELETE、OPTIONS。配置这个要小心生产环境如果把allowedOrigins设为*会有安全隐患建议指定实际的前端域名。5.3 LocalDateTime格式化问题实体类里用了Java 8的LocalDateTime结果前端拿到的JSON时间变成了2025-06-18T15:30:00这种带T的格式看着很怪。解决办法是在application.yml里配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这个配置只对java.util.Date生效对LocalDateTime无效。LocalDateTime格式化要靠在前端对字段加注解或者统一在axios响应拦截器里处理我当时选择了后端返回字符串也就是在VO类的时间字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)一劳永逸。5.4 并发审批导致流程状态错乱两个不同用户在同一时刻都去审批同一个流程实例的最后两个节点数据库里就可能出现状态互相覆盖的问题。比如A和B分别处理两个不同节点两人都查询实例状态为“审批中”A处理完把实例置为“已通过”B处理完又把实例置为“已驳回”最后状态是错的。解决思路是给流程实例表加一个version字段更新时用乐观锁SQL写成UPDATE flow_instance SET status ?, version version 1 WHERE id ? AND version ?。如果更新影响的行数为0说明版本已经被别人改过这时候抛出异常提示“流程已被其他操作修改请刷新后重试”。生产环境中这个错误能拦截掉绝大多数并发冲突。5.5 MyBatis XML中特殊符号的转义在Mapper XML里写小于号、大于号的时候直接写会报错因为XML解析器把它当成标签符号了。我写“查询创建时间小于某时间”的时候第一次就直接写了个结果项目启动直接XML解析异常。正确做法是转义写成lt;写成gt;。也可以用CDATA包裹整个比较条件但可读性不如转义干净。5.6 数据库连接池参数优化默认的HikariCP连接池配置其实已经够用但并发上来之后要注意几个参数。连接池最大连接数max-pool-size默认值是10如果系统同时在线人数超过五十个待办列表频繁查询就可能出现连接等待。我把最大连接数调到30connection-timeout设置为30000毫秒避免接口偶发性的50秒超时。还有一个容易被忽略的点是数据库的wait_timeout。MySQL 8默认的wait_timeout是8小时如果项目部署后隔一晚上没人访问第二天早晨第一次请求可能报“Connection has been closed”。HikariCP有连接Keepalive机制可以自动检测并重建连接但我习惯把连接池配置改成idle-timeout: 60000让空闲超过60秒的连接直接释放彻底避开长连接失效的问题。5.7 前端打包后路由404问题开发模式一切正常打包部署到Nginx之后刷新非首页的路径比如/dept/todo直接404。原因是Vue Router使用history模式时Nginx默认找不到对应的静态文件。解决办法是给Nginx配置一个location fallbacklocation / { try_files $uri $uri/ /index.html; }这个配置的意思是如果请求的路径没有对应的真实文件就回退到index.html由前端路由接管。生产环境部署这套系统时这个配置几乎是必需的读者一定要加。6. 这套系统还能怎么扩展写到这里整套系统的核心实现其实已经闭环了。按照标题里的代码拿到手之后从建库到跑通大概只需要半小时前提是数据库脚本和配置都能正确执行。我个人在实际开发过程中的体会是工作流系统最难的不是某个技术点而是把一个业务流转过程抽象成数据模型这个想通了后面的代码都是填肉。基于这套系统后续还能做不少扩展。比如给流程节点加一个“条件分支”类型让审批流根据表单里的金额数字自动走不同审批层级再比如支持“驳回到上一节点”而不是直接驳回发起人这需要记录节点流转路径还有消息通知模块任务生成时给处理人发邮件或短信提醒这些都是当前工作流系统最常见的能力。还是那句话工作流系统的技术栈并不复杂真正的价值在模型抽象和代码组织的清晰度上。把这套思路吃透后面无论遇到多复杂的办公流程你也只是在这个骨架上不断加分支和节点而已。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进