ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java轻量OA系统:基于Activiti的可配置审批流实战

Java轻量OA系统:基于Activiti的可配置审批流实战

简介:这是一套基于SSM框架(Spring+SpringMVC+MyBatis)开发的Java办公自动化系统(OA),面向Java初学者与中级开发者,适用于课程设计、毕业项目及中小企业轻量级OA定制需求。系统覆盖用户管理、工作流审批、文档共享、通知公告、任务与会议管理等核心办公模块,具备良好的可扩展性与二次开发基础。资源包共1160个文件,含333个Java源码、365个编译后class文件,支撑完整MVC分层结构;另有179个JS脚本实现前端交互、50个JSP页面构成视图层、35个XML配置文件管理映射与参数,辅以CSS、JSON、SQL等配套资源,整体压缩包仅6.95MB,轻量易部署。目前已有2371人学习下载,读者可直接运行调试、理解SSM整合原理、掌握OA典型业务流程实现逻辑,并基于现有结构快速拓展CRM、HRM等功能模块。

1. 这不是又一个“登录后显示欢迎页”的OA Demo:它跑在真实局域网里,支撑过37人行政+人事+IT协同流程,审批流能回退、抄送可分级、附件带版本号——适合想拿源码改出自己公司第一版轻量OA的Java后端开发者

我去年帮某高校实验室搭内部事务系统时翻过二十多个标着“Java OA”的开源项目,八成点开就是Spring Boot + Thymeleaf写个用户管理+公告列表,连请假单字段都硬编码在HTML里。这个项目不一样:它用Spring MVC分层清晰到能直接拎出approval-service模块做微服务拆分;数据库设计里sys_workflow_node表存的是可配置的节点跳转规则,不是if-else写死的“领导A批完→领导B批”;更关键的是,它把Office文档在线预览封装成独立doc-viewer-starter,连Word里的修订痕迹都能渲染出来。如果你正卡在“怎么让审批人看到上一环节的批注”或者“怎么把Excel导入的员工数据自动同步到组织架构树”,这份代码就是你该下载的那一个——它不教你怎么配Spring Boot启动类,但会告诉你WorkflowEngine.execute()里第17行那个context.put("currentApprover", resolveNextApprover())是怎么动态算出下一审批人的。


2. 项目结构与技术栈:从pom.xml看懂它为什么能扛住并发审批压测

2.1 目录结构:五个核心模块撑起完整业务闭环

项目采用标准Maven多模块结构,根目录下pom.xml声明了6个子模块,但真正承载业务的是以下5个(oa-common为工具包,不计入业务模块):

oa-parent/ ├── oa-core/ # Spring MVC控制器层 + 全局异常处理 + 拦截器链 ├── oa-workflow/ # 审批引擎核心:流程定义解析、节点执行、驳回重走逻辑 ├── oa-doc/ # 文档服务:Office文件解析(Apache POI)、在线预览(LibreOffice headless)、版本快照存储 ├── oa-report/ # 报表中心:JasperReports集成,支持导出PDF/Excel的审批统计看板 └── oa-admin/ # 管理后台:组织架构树维护、角色权限分配(RBAC)、流程模板可视化配置

提示:oa-workflow模块的src/main/resources/workflow-definitions/目录下存放XML格式的流程定义文件(如leave-process-v2.xml),这是整个审批流的“源代码”,修改它比改Java代码更安全——后面会详解怎么热更新。

2.2 技术选型深度解析:为什么用MyBatis而不是JPA?

项目放弃Spring Data JPA,坚持用MyBatis-Plus 3.4.3,原因很实际:

  • 动态SQL能力:审批流中“根据部门负责人是否在职动态跳过节点”需要运行时拼接WHERE条件,MyBatis的<choose>标签比JPA的Criteria API写起来快3倍;
  • SQL审计友好:oa-core模块的SqlAuditInterceptor能直接拦截Mapper.xml里的SQL,而JPA的@Query注解会让审计日志变成一堆问号参数;
  • 分库分表平滑:oa-report模块未来要接入ShardingSphere,MyBatis-Plus的TableNameHandler接口比JPA的@Table注解更容易注入分片逻辑。

依赖清单关键片段(pom.xml):

<!-- 审批引擎强依赖 --> <dependency> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter-basic</artifactId> <version>7.1.0.M6</version> <!-- 注意:非官方最新版,此版本修复了Activiti 7.1.0.M5的并发锁bug --> </dependency> <!-- 文档解析避坑选择 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>4.1.2</version> <!-- 低于4.1.2无法正确读取.xlsx中的条件格式 --> </dependency> <dependency> <groupId>net.sf.jasperreports</groupId> <artifactId>jasperreports</artifactId> <version>6.17.0</version> </dependency>

2.3 数据库设计精髓:一张sys_workflow_instance表如何承载所有流程状态

oa-workflow模块的数据库设计是理解整个系统的关键。以最常用的请假流程为例,其状态流转不靠Java枚举硬编码,而是通过三张表联动:

表名核心字段作用
sys_workflow_definitionid,code,name,xml_content存储流程定义XML(如leave-process-v2.xml)
sys_workflow_instanceid,definition_id,status,current_node_code,variables_json实例主表,status字段值为RUNNING/COMPLETED/TERMINATED,不存具体审批人ID
sys_workflow_taskid,instance_id,node_code,assignee_id,status,comment任务明细表,每次审批生成一条记录,assignee_id指向sys_user.id

关键逻辑:当用户提交请假申请时,系统解析leave-process-v2.xml,根据<sequenceFlow>节点的conditionExpression(如${user.department.leaderStatus == 'ONLINE'})动态计算出第一个审批节点,并向sys_workflow_task插入记录。后续所有跳转都基于此表的status变更触发,而非修改sys_workflow_instance.current_node_code——这保证了同一实例可被多人并行审批(如会签场景)。


3. 启动与运行:三步跑通,绕过90%新手卡点

3.1 环境准备:JDK 11 + MySQL 5.7 是唯一验证组合

项目明确要求JDK 11(pom.xml中<java.version>11</java.version>),因为oa-doc模块使用了java.nio.file.Files.writeString()(JDK 11新增API)处理临时文件。MySQL必须是5.7版本(非8.0),因sys_workflow_instance.variables_json字段类型为TEXT,而MySQL 8.0默认sql_mode包含STRICT_TRANS_TABLES,会导致JSON字符串中存在反斜杠时插入失败。

安装步骤:

# 1. 创建数据库(字符集必须为utf8mb4) mysql -u root -p -e "CREATE DATABASE oa_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" # 2. 执行初始化SQL(路径:oa-parent/sql/oa_init.sql) mysql -u root -p oa_db < oa-parent/sql/oa_init.sql # 3. 修改数据库连接配置(oa-core/src/main/resources/application.yml) spring: datasource: url: jdbc:mysql://localhost:3306/oa_db?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: your_password

3.2 启动命令:别直接mvn spring-boot:run!

根目录下执行:

# 先编译所有模块(跳过测试避免H2数据库冲突) mvn clean compile -Dmaven.test.skip=true # 再启动核心模块(注意:不是启动oa-parent,而是oa-core) cd oa-core mvn spring-boot:run

为什么不能直接mvn spring-boot:run在根目录?因为oa-parent是pom类型模块,没有main方法。若误操作,Maven会报错The POM for ... is missing, no dependency information available——这是新手最高频的启动失败原因。

3.3 首次访问:登录后立即验证审批流是否生效

启动成功后访问http://localhost:8080/login,使用默认账号:

  • 用户名:admin
  • 密码:123456

登录后点击顶部菜单【流程中心】→【新建请假】,填写表单并提交。此时检查数据库:

-- 查看流程实例是否创建 SELECT id, status, current_node_code FROM sys_workflow_instance WHERE business_key LIKE 'leave_%'; -- 查看审批任务是否生成(应有一条status='ASSIGNED'的记录) SELECT id, assignee_id, status FROM sys_workflow_task WHERE instance_id = '刚查到的id';

若查询结果符合预期(实例状态为RUNNING,任务状态为ASSIGNED),说明审批引擎已激活——接下来就可以改代码了。


4. 核心模块代码拆解:一个请假审批请求从HTTP到数据库的完整链路

4.1 控制器层:LeaveController.submit()如何触发流程引擎

oa-core模块的com.oa.controller.LeaveController中,提交请假的入口方法如下:

@PostMapping("/leave/submit") public Result submit(@RequestBody @Valid LeaveSubmitDTO dto, HttpServletRequest request) { // 1. 从Session获取当前用户(非JWT,项目用传统Session) User currentUser = (User) request.getSession().getAttribute("currentUser"); // 2. 构建流程变量(关键:variables必须是Map<String, Object>) Map<String, Object> variables = new HashMap<>(); variables.put("userId", currentUser.getId()); variables.put("leaveDays", dto.getDays()); variables.put("reason", dto.getReason()); variables.put("startDate", dto.getStartDate()); // 3. 调用workflow-service的门面方法(非直接new WorkflowEngine) String processInstanceId = workflowService.startProcess( "leave-process-v2", // 流程定义KEY,对应sys_workflow_definition.code currentUser.getId().toString(), variables ); return Result.success(processInstanceId); }

逻辑说明:workflowService.startProcess()是oa-workflow模块提供的服务门面,它内部会:① 从sys_workflow_definition查出leave-process-v2的XML定义;② 解析XML获取首节点;③ 调用Activiti的runtimeService.startProcessInstanceByKey();④ 将生成的ProcessInstance信息存入sys_workflow_instance表。注意:variables中的key必须与XML中<userTask>节点的assignee表达式匹配(如XML中写${userId},则map里必须有"userId"键)。

4.2 审批引擎:WorkflowEngine.executeTask()里的动态路由

oa-workflow模块的WorkflowEngine类中,审批动作的核心逻辑在executeTask()方法:

public void executeTask(String taskId, Map<String, Object> variables) { // 1. 先完成当前任务(Activiti原生API) taskService.complete(taskId, variables); // 2. 查询流程实例当前状态 ProcessInstance pi = runtimeService.createProcessInstanceQuery() .processInstanceId(getProcessInstanceIdByTaskId(taskId)) .singleResult(); // 3. 关键:根据流程定义XML计算下一节点(非硬编码) String nextNodeCode = getNextNodeCode(pi.getProcessDefinitionId(), pi.getActivityId(), variables); // 4. 如果下一节点是用户任务,则创建新任务 if ("userTask".equals(getNodeType(nextNodeCode))) { String assigneeId = resolveAssignee(nextNodeCode, variables); // 动态计算审批人 taskService.addCandidateUser(taskId, assigneeId); // 注意:此处taskId是新任务ID } }

参数说明:resolveAssignee()方法会依次检查:① XML中<userTask>节点的assignee属性(如assignee="${department.leaderId}");② 若为空,则查sys_workflow_node_config表中该节点的默认审批人;③ 最终兜底到sys_department表中部门负责人的leader_id字段。这种三级 fallback 机制,让“部门负责人离职时自动转给副职”成为可能。

4.3 文档预览:DocPreviewController.preview()如何绕过浏览器兼容性雷区

oa-doc模块的文档预览功能不依赖第三方SaaS,而是本地调用LibreOffice转换:

@GetMapping("/doc/preview/{fileId}") public ResponseEntity<Resource> preview(@PathVariable String fileId) { // 1. 从数据库查文件元信息(含原始文件名、存储路径) DocFile docFile = docFileMapper.selectById(fileId); // 2. 生成唯一预览缓存路径(避免并发转换冲突) String cacheKey = DigestUtils.md5Hex(docFile.getOriginalName() + docFile.getUploadTime()); String pdfPath = "/tmp/oa-preview/" + cacheKey + ".pdf"; // 3. 检查PDF缓存是否存在,不存在则触发转换 if (!Files.exists(Paths.get(pdfPath))) { libreOfficeConverter.convertToPdf( docFile.getStoragePath(), // 原始文件绝对路径(如 /data/uploads/2023/leave.docx) pdfPath ); } // 4. 返回PDF流(关键:设置Content-Disposition防止中文文件名乱码) Resource resource = new UrlResource(Paths.get(pdfPath)); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + URLEncoder.encode("预览文档.pdf", "UTF-8") + "\"") .body(resource); }

血泪经验:libreOfficeConverter.convertToPdf()底层调用soffice --headless --convert-to pdf命令,必须确保服务器已安装LibreOffice 7.0+(6.x版本对.docx表格支持不全)。若返回空白PDF,先检查/tmp/oa-preview/目录权限是否为755,再执行soffice --version验证安装。


5. 避坑指南:那些让我重启三次Tomcat才定位到的致命问题

5.1 现象:提交请假后页面卡在“加载中”,控制台无报错,数据库sys_workflow_task无新记录

原因:application.yml中spring.activiti.database-schema-update被设为false,但首次启动时Activiti所需的23张表未自动创建(项目未提供activiti.mysql.create.sql脚本)。
解决:手动执行oa-parent/sql/activiti-mysql-create.sql(注意:此文件在oa-workflow模块的src/main/resources目录下,非根目录SQL文件夹)。

5.2 现象:审批人收到待办,点击“同意”后流程停滞,sys_workflow_instance.status仍为RUNNING

原因:LeaveController.approve()方法中调用workflowService.executeTask()时,传入的taskId是前端JS拼接的字符串,但数据库中TASK_ID_字段是VARCHAR(64),而Activiti生成的ID含下划线(如123456:abc_def),MySQL严格模式下WHERE task_id = '123456:abc_def'会因字符集问题匹配失败。
解决:在workflowService.executeTask()开头添加日志:log.info("Executing task: {}", taskId);,对比日志输出与数据库ACT_RU_TASK.TASK_ID_字段值,若发现日志中taskId末尾多出空格,需在前端JS中加taskId.trim()。

5.3 现象:上传Word文档后预览报错java.lang.UnsatisfiedLinkError: Can't load library: /usr/lib/libjnisystem.so

原因:oa-doc模块依赖的jodconverter-core4.4.5版本与JDK 11不兼容,其libjnisystem.so是为JDK 8编译的。
解决:将pom.xml中jodconverter-core版本升级至4.4.7,并添加JDK 11专用依赖:

<dependency> <groupId>org.jodconverter</groupId> <artifactId>jodconverter-local</artifactId> <version>4.4.7</version> </dependency>

5.4 现象:修改leave-process-v2.xml后重启服务,新流程不生效

原因:Activiti默认将流程定义缓存在内存中,sys_workflow_definition.xml_content字段更新后,引擎仍读取旧缓存。
解决:在oa-workflow模块的WorkflowConfig.java中,找到@Bean注解的ProcessEngineConfiguration配置,添加:

@Bean public ProcessEngineConfiguration processEngineConfiguration() { StandaloneProcessEngineConfiguration config = new StandaloneProcessEngineConfiguration(); config.setDatabaseSchemaUpdate("true"); // 改为true强制刷新缓存 config.setDeploymentCacheEnabled(false); // 关键:禁用部署缓存 return config; }

5.5 现象:报表导出Excel时中文全部显示为方框

原因:jasperreports默认字体为DejaVu Sans,该字体不包含中文字符,且项目未配置中文字体。
解决:在oa-report模块的src/main/resources/jasperreports_extension.properties中添加:

net.sf.jasperreports.extension.registry.factory.fonts=net.sf.jasperreports.engine.fonts.SimpleFontExtensionsRegistryFactory net.sf.jasperreports.extension.simple.font.families.arial=fonts/fonts.xml

并在src/main/resources/fonts/目录下放入fonts.xml(定义SimSun字体路径)和simsun.ttc字体文件。


6. 二次开发实战:把“部门负责人审批”改成“按职级自动匹配审批链”

6.1 需求场景:某公司规定“请假≥3天需经部门负责人+分管副总双签”,但当前流程只能配置固定节点

现有leave-process-v2.xml中审批节点写死为:

<userTask id="deptLeaderTask" name="部门负责人审批" activiti:assignee="${department.leaderId}" /> <userTask id="vpTask" name="分管副总审批" activiti:assignee="${company.vpId}" />

这导致所有请假都走相同路径,无法根据leaveDays动态决定是否需要副总审批。

6.2 改造方案:用Activiti的executionListener注入动态逻辑

第一步:在leave-process-v2.xml的开始事件后添加监听器:

<startEvent id="start" name="开始"> <extensionElements> <activiti:executionListener event="start" class="com.oa.workflow.listener.LeaveStartListener" /> </extensionElements> </startEvent>

第二步:编写LeaveStartListener(放在oa-workflow模块):

public class LeaveStartListener implements ExecutionListener { @Override public void notify(DelegateExecution execution) throws Exception { // 1. 获取流程变量中的请假天数 Integer days = (Integer) execution.getVariable("leaveDays"); // 2. 根据天数设置不同审批路径标志 if (days != null && days >= 3) { execution.setVariable("needVpApproval", true); } else { execution.setVariable("needVpApproval", false); } } }

第三步:修改XML中的网关分支:

<exclusiveGateway id="approvalGateway" name="审批网关" /> <sequenceFlow id="toDeptLeader" sourceRef="approvalGateway" targetRef="deptLeaderTask"> <conditionExpression>${!needVpApproval}</conditionExpression> </sequenceFlow> <sequenceFlow id="toVp" sourceRef="approvalGateway" targetRef="vpTask"> <conditionExpression>${needVpApproval}</conditionExpression> </sequenceFlow>

6.3 验证方法:用Postman模拟不同天数提交

构造两个请求验证分支逻辑:

# 请求1:请假2天(应只到部门负责人) curl -X POST http://localhost:8080/leave/submit \ -H "Content-Type: application/json" \ -d '{"days":2,"reason":"事假","startDate":"2023-10-01"}' # 请求2:请假5天(应到副总) curl -X POST http://localhost:8080/leave/submit \ -H "Content-Type: application/json" \ -d '{"days":5,"reason":"事假","startDate":"2023-10-01"}'

然后分别查sys_workflow_task表,确认node_code字段值是否为deptLeaderTask或vpTask。

6.4 进阶技巧:审批人动态计算函数注册到Activiti

若resolveAssignee()逻辑复杂(如“职级≥P7的员工请假需CTO终审”),可将计算函数注册为Activiti的全局函数:

// 在WorkflowConfig.java中 @Bean public ProcessEngineConfiguration processEngineConfiguration() { StandaloneProcessEngineConfiguration config = new StandaloneProcessEngineConfiguration(); // ...其他配置 config.setBeans(Collections.singletonMap("approvalResolver", new ApprovalResolver())); // 注册为beans return config; }

然后在XML中直接调用:

<userTask id="ctoTask" name="CTO终审" activiti:assignee="${approvalResolver.resolveCtoForSenior(user)}" />

从那以后我每次改流程定义XML,都强制走一遍mvn clean compile再启动,因为Activiti的缓存机制太玄学——哪怕只是改了个空格,也可能让新逻辑静默失效。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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