ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot审批流OA系统实战:从状态机设计到部署全流程解析

Spring Boot审批流OA系统实战:从状态机设计到部署全流程解析 做OA系统的同学应该都有一个感受表面上看起来是“增删改查”但真正把审批流这一块理顺了项目的复杂度立刻上了一个台阶。最近我把一个基于Springboot的审批流OA管理系统完完整整地梳理了一遍从数据库设计、核心接口到部署调试整条链路走下来踩了不少坑也沉淀了不少可以直接复用的方案。这篇文章就围绕这个项目我这边习惯叫它t4q46工程来写既是给自己做个复盘也是给正在做OA、审批流、Springboot相关毕设或实训项目的朋友一份接地气的参考。先说这个项目是什么它是一套典型的轻量级OA办公系统底层用Springboot搭建核心业务是审批流包含用户管理、部门管理、角色权限、流程申请、待办审批、已办查询、审批记录、流程状态追踪这些模块。它解决的痛点很实际——公司里请假、报销、用章申请、采购审批这类“找领导签字”的场景线下跑腿效率低流程不可见卡在谁那儿也不知道。套上这套系统之后所有申请线上提交审批人收到待办点一下通过或驳回全流程有记录、可追溯。这篇文章适合谁看一类是正在做计算机毕业设计、需要快速落地一个可演示项目的人另一类是刚接触审批流开发、想搞懂“状态机到底怎么设计”的后端开发。内容不会只贴代码更多会讲设计思路、表结构为什么这样建、审批动作之间怎么防呆还有部署调试时那些文档里不会写的细节。1. 这类OA项目的整体设计与思路拆解1.1 为什么选Springboot做底座很多人问我OA系统又不复杂为什么用Springboot而不是SSH或者更老的框架我的回答是正因为OA的难点不在框架本身而在业务流的组织方式所以选一个能让你把精力花在业务上的框架更重要。Springboot的优势大家都清楚内嵌Tomcat不用单独部署容器starter机制把常用的依赖都封装好了引入一个spring-boot-starter-web就解决web层配置application.yml集中管理数据源、端口、日志等配置比传统XML堆配置的方式清爽太多。对于OA这种需要快速迭代、频繁调整流程规则的项目这套特性非常受用。在实际项目中我用的版本组合是Springboot 2.7.x MyBatis MySQL 8.0JDK用1.8。这个组合不算新但胜在稳网上资料也多遇到问题一搜就能找到答案。很多OA项目还会配上Redis做缓存和Session共享但轻量级场景下有没有Redis其实不影响核心流程后面我会单独说什么时候该上Redis。1.2 审批流在整个OA系统里的位置OA系统表面上是一个多模块应用但拆开看几乎所有业务模块最终都要挂到审批流上。比如请假模块逻辑是“填单—提交—主管审批—人事归档”报销模块是“填单—部门审批—财务审批—打款”用章申请可能是“填单—部门审批—行政审批—总经理审批”。这些业务的表单内容不一样但底层的流程驱动逻辑是共通的一个流程实例、一串待办任务、一组审批记录。所以我在设计时没有为每个业务单独写一套状态机而是抽了一个通用的审批流核心层。业务模块只负责自己的表单字段和业务规则审批的推进、驳回、撤回、转办这些动作全部交给统一的服务来处理。这样做的好处非常明显新增一个审批业务时不需要动流程引擎代码只要注册业务类型、绑定表单模板就能跑起来。项目的整体结构大致分三层顶层是业务模块请假、报销、采购等中间是审批流核心层流程实例、任务节点、审批动作底层是基础平台用户、角色、部门、菜单权限。这种分层思路建议你在自己设计时也优先考虑。1.3 技术选型和模块清单列一份这个项目的实际技术栈方便对照参考层级技术选型说明后端框架Springboot 2.7.x核心容器与Web服务ORMMyBatis 3.5.xSQL灵活可控适合复杂审批查询数据库MySQL 8.0存储业务与流程数据连接池Druid 1.2.x自带监控方便排查慢SQL权限认证Spring Security JWT接口鉴权无状态前端Vue2 Element UI前后端分离方便部署展示构建工具Maven 3.6依赖管理和打包模块清单方面至少包含系统管理用户、角色、菜单、部门、流程管理流程定义、流程实例、待办任务、已办任务、业务申请请假、报销等示例业务、通知中心站内信或待办提醒、数据统计按部门、按流程类型的审批耗时分析。前端技术选型这里多说一句如果项目本身以展示为主可以用Thymeleaf做服务端渲染部署更简单。如果后续想扩展成完整的前后端分离架构就用Vue。我这次用的是前后端分离因为接口清晰的分离式结构在答辩或演示时更好讲。2. 审批流的核心机制状态机与流程节点设计2.1 先搭一个“请假申请”把流程走通审批流最容易被初学者忽略的是“状态机”概念。很多人一上来就写if else判断如果status等于0就改成1等于1就改成2。这样写短期内能跑但流程一复杂状态跳转没有约束容易出现“从已通过变成审批中”这种逻辑上不可能出现的状态。我的做法是先用一个枚举把流程状态固定下来所有状态流转都通过方法统一处理而不是在业务代码里到处改status。以请假流程为例我定义了这几个状态public enum ProcessStatus { DRAFT(0, 草稿), PENDING(1, 审批中), APPROVED(2, 已通过), REJECTED(3, 已驳回), WITHDRAWN(4, 已撤回), CANCELED(5, 已作废); private final int code; private final String desc; ProcessStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }状态机的核心价值在于它把“流程允许到达的状态”显式定义出来后续加逻辑时只要围绕状态枚举做判断不会漏掉边界情况。比如提交申请只允许从DRAFT到PENDING驳回时只能从PENDING到REJECTED重新提交时从REJECTED回到PENDING。一条完整请假流程的实际状态变化通常是这样的员工新建请假单DRAFT→ 提交PENDING→ 部门主管审批通过PENDING继续走到下一节点→ 人事确认APPROVED→ 流程结束。如果中间主管驳回则变成REJECTED员工修改后重新提交再次进入PENDING。2.2 多级审批与会签、或签的实现思路只做一个“提交—通过”的两级流程根本谈不上审批流。实际项目里更常见的是多级审批。比如请假天数小于等于3天的部门主管审批就行超过3天的还要总监审批超过7天的总经理也要批。这种规则怎么优雅地实现我采用的方式是“节点配置 条件判断”。流程定义表里存节点列表每个节点有节点类型、审批人角色、以及一个条件表达式。提交请假单时流程引擎根据请假天数动态计算出需要走哪些节点生成对应的任务链表。比如请假2天生成的链就是“部门主管”请假5天生成的链就是“部门主管 → 总监”请假10天就是“部门主管 → 总监 → 总经理”。这里的关键点是流程实例一旦创建节点链就固定下来不要每次查询时再动态算一遍否则中途调整规则会导致历史流程错乱。会签和或签是另一个高频需求。会签的含义是多个审批人必须全部通过流程才能进入下一节点。典型的场景是合同评审法务、财务、业务负责人各有一票任何一票驳回整个流程就终止。实现时我在任务节点上做了一个“子任务”表每个子任务对应一个审批人主任务记录总人数和已通过人数所有子任务通过后主任务才算完成。或签则简单一些多个审批人只要有一人通过节点即通过。比如紧急事项的快速审批。实际开发中我在子任务表里加了一个approve_model字段0表示或签1表示会签2表示依次审批。查询待办任务时按审批人匹配对应的子任务处理完子任务后再汇总判断主任务状态。这个方案实测下来比较稳不管是请假、报销还是合同审批场景都能覆盖。2.3 驳回、撤回、转办这些边界情况怎么处理正常的“提交—审批—通过”路径写起来不难难的是各种边界动作。我的经验是这些动作必须在流程引擎层统一做幂等处理不能只靠前端按钮控制。驳回有两种常见策略一种驳回到发起人发起人修改后重新提交另一种驳回到上一节点让上一节点审批人重新审批。具体用哪种看业务需要。请假这种场景一般用“驳回到发起人”因为可能是天数填错了合同评审这种多人会签场景驳回到上一节点更合理因为可能是合同条款有问题得让上一个评审角色重新看。撤回我限定了条件只有流程处于PENDING状态、且当前待办节点还没有被任何人处理时发起人才能撤回。一旦有人审批过撤回按钮就必须置灰前端做提示后端接口也要校验。转办属于比较高级的功能审批人收到待办后发现自己不方便审批可以把任务转给同角色的其他人。实现上我不修改流程实例本身只修改子任务的审批人字段。但要注意保留转办记录防止后续扯皮。我在审批记录表里专门用一个action_type字段标识这是转办操作记录原审批人和新审批人。下面这张表总结了各动作的前置条件和状态变化动作前置条件状态变化说明提交状态为DRAFT或REJECTED→ PENDING生成首个待办任务审批通过状态为PENDING节点1或→ APPROVED根据节点链判断审批驳回状态为PENDING→ REJECTED可配置驳回到发起人或上一节点撤回状态为PENDING且无人审批→ WITHDRAWN仅发起人可操作转办状态为PENDING子任务审批人变更记录原审批人与新审批人作废状态为DRAFT→ CANCELED发起人主动取消3. 数据库设计与核心表结构解析3.1 用户、角色、部门这些基础表怎么建基础数据表不用搞得太花哨但一定要符合RBAC模型不然权限这块后面会很难控制。我推荐的表结构是sys_user用户表、sys_role角色表、sys_dept部门表、sys_user_role用户角色关联表、sys_role_menu角色菜单关联表、sys_menu菜单权限表。用户表的核心字段我列一下user_id主键、username登录名、passwordBCrypt加密后的密码、real_name姓名、dept_id所属部门、phone、email、status1启用0禁用、create_time。部门表的设计注意一点很多系统上线一段时间后要支持多级部门所以dept_id、parent_id、ancestors这三个字段最好一开始就加上。ancestors存的是从根到当前节点的完整路径比如“1,2,5”这样查询某部门及其所有子部门时用like %5%就能查出来不用递归。菜单权限表存储的是页面路由和按钮标识比如新增按钮的权限标识是“leave:add”审批按钮是“leave:approve”。前端在渲染按钮时根据用户是否拥有对应标识来决定显隐后端接口再用Spring Security的PreAuthorize注解做二次校验。3.2 审批流相关的核心表流程实例、任务节点、审批记录这是整套系统的灵魂我拆成三张表来设计。第一张是流程实例表我命名为oa_process_instance。核心字段包括instance_id、business_type业务类型比如leave、expense、business_id业务单号关联请假单或报销单的主键、process_name、current_node当前所在节点编码、status1待提交2审批中3已通过4已驳回5已撤回、submitter_id发起人、submit_time、end_time。第二张是任务节点表命名为oa_process_task。核心字段包括task_id、instance_id、node_code节点编码、node_name节点名称、task_status0待处理1已通过2已驳回3已转办、approve_model0或签1会签2依次审批、expected_user期望审批人角色、actual_user实际审批人、create_time、finish_time。第三张是审批记录表命名为oa_process_record。核心字段包括record_id、instance_id、task_id、operator_id操作人、action_type1提交2通过3驳回4撤回5转办、comment审批意见、record_time。三张表的关联关系非常清晰流程实例表是总纲任务节点表对应一条流程实例下要经过的所有审批节点审批记录表则记录了每一次操作的动作细节。查询某个申请的历史轨迹时只要按instance_id查审批记录表按时间排序即可。3.3 几个关键的设计细节建表时有一些细节容易被忽略但恰恰是这些细节决定了系统上线后好不好用。第一业务数据表和流程表要解耦。请假单有leave_id、leave_days、leave_type、start_date、end_date等字段审批流只通过instance_id和业务类型关联不要在业务表里塞一堆流程字段。这样设计的好处是流程引擎可以复用到所有业务模块新增的采购申请、用章申请不需要动流程表结构。第二并发控制。用户连续点击“提交”按钮如果没有防重机制可能创建出两条流程实例。我的处理方式是在oa_process_instance表上加一个联合唯一索引business_type, business_id数据库层面直接挡住重复提交。第三xxx_time字段要区分。提交时间、审批时间、完成时间这些字段看起来都是时间类型但业务含义不同不要混用。特别是做审批耗时统计时比如计算平均审批时长必须用submit_time到end_time的时间差这里如果字段语义不清统计结果就是错的。第四审批意见字段用text类型不要用varchar(255)。真实场景中审批人可能会粘贴大段意见varchar(255)很容易截断。4. 关键代码实现从Mapper到Service再到Controller4.1 实体类和Mapper层设计以流程实例为例实体类定义如下字段和表结构一一对应public class ProcessInstance { private Long instanceId; private String businessType; private Long businessId; private String processName; private String currentNode; private Integer status; private Long submitterId; private Date submitTime; private Date endTime; }Mapper层我用的是MyBatis XML方式原因很简单审批流模块的SQL涉及多表关联和动态条件XML写起来比注解更直观、更方便调试。以“查询当前用户的待办任务”为例这条SQL是待办列表的核心select idselectTodoList resultTypecom.oa.process.vo.TodoVO SELECT t.task_id, t.node_name, i.process_name, i.business_type, i.business_id, i.submitter_id, u.real_name AS submitter_name, i.submit_time FROM oa_process_task t INNER JOIN oa_process_instance i ON t.instance_id i.instance_id INNER JOIN sys_user u ON i.submitter_id u.user_id WHERE t.task_status 0 AND t.actual_user #{userId} AND i.status 2 ORDER BY i.submit_time DESC /select注意这里用了inner join去关联流程实例表和用户表目的是在待办列表里直接展示“谁提交的、提交时间、流程名称”这些关键信息避免前端拿到task_id后再去查一次。4.2 Service层里审批流转的核心逻辑Service层是整个审批流的心脏所有状态流转都在这层完成。我把提交、审批通过、驳回、撤回、转办这几个核心动作都封装成独立方法并且加上事务注解。举个例子提交审批的核心代码如下Transactional(rollbackFor Exception.class) public Long submit(SubmitRequest request) { // 1. 创建流程实例 ProcessInstance instance new ProcessInstance(); instance.setBusinessType(request.getBusinessType()); instance.setBusinessId(request.getBusinessId()); instance.setProcessName(request.getProcessName()); instance.setCurrentNode(request.getFirstNodeCode()); instance.setStatus(ProcessStatus.PENDING.getCode()); instance.setSubmitterId(request.getSubmitterId()); instance.setSubmitTime(new Date()); processInstanceMapper.insert(instance); // 2. 生成第一个节点任务 ProcessTask task new ProcessTask(); task.setInstanceId(instance.getInstanceId()); task.setNodeCode(request.getFirstNodeCode()); task.setNodeName(request.getFirstNodeName()); task.setTaskStatus(0); task.setApproveModel(request.getApproveModel()); task.setExpectedUser(request.getFirstNodeRoleId()); task.setActualUser(request.getFirstNodeAssigneeId()); processTaskMapper.insert(task); // 3. 写入提交记录 processRecordMapper.insert(buildRecord(instance, task, 1, 提交申请)); return instance.getInstanceId(); }审批通过的方法要复杂一些因为要判断当前节点是否还有下一节点。我常用的写法是更新当前节点任务状态为已通过然后查询流程定义中的下一节点如果存在则创建新任务同时把流程实例的current_node更新为新节点如果不存在则把流程实例状态改为已通过并写入end_time。这里有一个非常容易踩的坑事务方法内部调用本类的另一个事务方法Transactional会失效。比如我在approve()里调用了this.createNextTask()后者虽然加了事务注解但因为走的是内部调用路径不经过代理对象事务根本不会生效。解决方法是把创建下一节点任务的方法拆到另一个Service类里或者通过AopContext.currentProxy()获取代理对象。ServiceImpl里千万不要把状态更新逻辑散落到各个业务方法里所有状态变更都集中在审批流核心方法中处理这样才能保证“哪一步该改什么状态”全局只有一份逻辑。4.3 Controller层和外部的接口约定Controller层我采用了标准的RESTful风格所有接口统一返回Result对象PostMapping(/api/process/submit) public ResultLong submit(RequestBody Valid SubmitRequest request) { return Result.success(processService.submit(request)); } PostMapping(/api/process/approve) public ResultVoid approve(RequestBody Valid ApproveRequest request) { processService.approve(request); return Result.success(); } PostMapping(/api/process/reject) public ResultVoid reject(RequestBody Valid RejectRequest request) { processService.reject(request); return Result.success(); } GetMapping(/api/process/todo) public ResultPageResultTodoVO todoList(RequestParam Long userId, RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize) { return Result.success(processService.todoList(userId, pageNum, pageSize)); }这里有一个细节分页参数我用pageNum和pageSize而不是page和limit。前者更贴近MyBatis分页插件的默认参数语义前端做表格组件时也更容易封装。接口参数校验方面用javax.validation的NotBlank、NotNull注解配合全局异常处理器统一返回参数错误信息。审批接口的ApproveRequest里必须校验taskId和instanceId防止有人绕开待办列表直接调接口改状态。权限鉴权我用了Spring Security JWT。登录成功后返回token前端每次请求在header里带上Authorization后端通过拦截器解析token拿到当前用户ID。审批接口上再加一个自定义权限校验确保当前用户确实有这个任务的处理权否则直接抛异常。5. 实操部署从零把一个SpringbootOA项目跑起来5.1 开发环境与工具准备拿到的项目如果是一套完整交付包通常包含源码、数据库脚本和部署文档。我建议先在本地把环境对齐到项目原本的版本组合否则很容易出现莫名其妙的问题。我这次使用的环境是JDK 1.8企业项目到现在还有大量用1.8的遇到高版本JDK反而不太推荐直接切容易踩到一些老依赖的兼容坑、Maven 3.6.3、MySQL 8.0.33、IDEA 2023.2。如果你本机装的是MySQL 5.7问题也不大只要字符集设置成utf8mb4大部分功能都能正常跑。初始化数据这部分我建议按这个顺序来启动MySQL服务用root账号登录。执行CREATE DATABASE oa_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;创建数据库。用source命令导入项目自带的oa_system.sql脚本。检查核心表是否创建成功比如sys_user、oa_process_instance并确认admin账号已插入。5.2 配置文件和数据库初始化项目的application.yml是启动的关键我贴一份整合了数据源、MyBatis、日志配置的示例server: port: 8080 spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/oa_system?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 druid: initial-size: 5 min-idle: 5 max-active: 20 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.oa.system.entity configuration: map-underscore-to-camel-case: true logging: level: com.oa.system.mapper: debugurl里的serverTimezoneAsia/Shanghai一定要加不然MySQL 8.0的驱动会报时区错误。password按你本机实际密码修改不建议直接在测试环境用加密配置容易绕晕。map-underscore-to-camel-case这个配置很多人会忽略。数据库字段是business_type实体属性是businessType如果不开启驼峰映射查询结果就会一直是null。这个我踩过不止一次写在这里提醒大家。5.3 启动、打包和常见问题本地启动步骤很简单在IDEA中打开项目等待Maven把依赖下载完找到启动类OaApplication右键Run即可。如果启动过程中报错八成是下面几个原因报错现象可能原因解决办法Failed to configure a DataSourceapplication.yml的url、用户名或密码错误逐项核对数据库连接配置Access denied for user rootlocalhost账号权限或密码错误用命令行验证能否正常登录MySQLPort 8080 was already in use端口被其他进程占用修改server.port为8081或杀掉占用进程java.sql.SQLSyntaxErrorExceptionSQL脚本和数据库版本不兼容确认MySQL版本重新导入SQL脚本Table xx doesnt existSQL脚本未导入完整重新执行source命令检查报错信息生产环境部署时我一般用Maven打包成可执行jar包命令是mvn clean package -DskipTests然后在服务器上执行java -jar oa-system.jar启动。注意服务器上同样需要提前装好JDK和MySQL并且防火墙要放行相应的端口。打包过程中有一个常见问题如果项目里引入了本地jar包mvn package默认不会把它打进去需要在pom.xml里把本地依赖也一起打包。对于这种交付型项目我建议直接用maven-assembly-plugin或者maven-shade-plugin把依赖统一打进一个fat jar里避免部署时缺包。6. 常见问题与排查技巧实录6.1 问题速查表实际跑项目过程中肯定会遇到各种问题。我整理了一份速查表覆盖这个项目最常见的几个坑点常见现象可能原因解决办法前端登录成功但接口返回401JWT过期或token未传到后端检查axios拦截器是否带上Authorization请求头待办列表一直为空任务表里actual_user和当前用户ID不匹配用SQL查询task表确认当前节点审批人是否正确审批通过后流程状态没变事务没生效状态更新被回滚检查approve方法是否在Service内部调用导致事务失效中文乱码数据库连接未指定utf8或数据库字符集不对url加characterEncodingutf8建库时指定utf8mb4重新提交后历史记录丢失新建了流程实例但审批记录没关联上重新提交时应复用原instanceId更新状态而不是新建定时任务没有触发未开启EnableScheduling在启动类确认是否添加了该注解6.2 我在实际调试中踩过的几个坑第一个坑是审批状态机设计得太散。一开始我把状态判断逻辑写在各个Service方法里结果迭代到第三个业务模块时发现同一个状态在请假模块和报销模块里含义居然不一样。后来统一收拢到审批流核心服务所有状态变更都走同一套方法这个问题才彻底解决。第二个坑是并发重复提交。测试时用Jmeter模拟20个并发请求同时提交同一张请假单发现创建了多条流程实例。加了联合唯一索引之后重复提交直接抛DuplicateKeyException再配合全局异常处理器转成友好的提示信息这个坑才算填平。第三个坑是驳回后重新提交导致审批链断裂。初次设计的驳回逻辑只是简单把状态改成REJECTED重新提交时又新建了一个完整节点链导致老节点的审批记录全部丢失。后来改成重新提交时复用原流程实例先清理掉剩余的任务节点再根据当前业务数据重新生成节点链这样审批记录完整保留演示效果也更好。第四个坑是审批流的节点顺序问题。最初我用的是一个int类型的step字段表示当前节点序号多级审批时通过step加减来控制。后来发现如果某个节点被跳过了step顺序就乱了。最后改成用node_code字符串编码节点的先后顺序由流程定义表里的sort字段决定这样不管怎么跳转都能准确找到下一节点。6.3 针对这个项目的一些优化建议如果这个项目后续还要继续扩展我建议关注三个方向。第一把审批流从“伪流程”升级成“动态流程”。目前这个项目的审批链虽然是动态计算出来的但流程模板还需要在代码里写死。可以引入activiti或flowable这类流程引擎虽然重一些但流程定义、流程图展示、会签并行网关这些能力是现成的。如果不想引入重量级引擎也可以自己用JSON定义节点链后端解析JSON生成任务核心原则是一样的。第二增加消息通知能力。审批待办如果只靠系统内的待办列表用户很可能不及时处理。可以接邮件通知或者对接企业微信、钉钉这类办公软件的消息推送审批人收到待办后直接点消息跳转到审批页面体验会提升非常大。第三补一个审批耗时的统计报表。很多管理层关心的其实是“审批效率”哪个部门审批最慢、平均一个流程要几天。基于现有的submit_time、end_time和审批记录表做这个统计其实成本很低但对系统的完整度提升很明显。最后说一个我个人的体会审批流这套东西第一次做的时候觉得复杂感觉状态、节点、记录之间总有绕不清楚的关系。但一旦把“流程实例、任务节点、审批记录”这三个模型想清楚后面加多少业务模块都是顺手的事。花时间把底层设计打牢远比上来就堆接口更有价值。
返回列表