:基于Activiti的可配置審批流實戰(zhàn))
簡介這是一套基于SSM框架SpringSpringMVCMyBatis開發(fā)的Java辦公自動化系統(tǒng)OA面向Java初學者與中級開發(fā)者適用于課程設計、畢業(yè)項目及中小企業(yè)輕量級OA定制需求。系統(tǒng)覆蓋用戶管理、工作流審批、文檔共享、通知公告、任務與會議管理等核心辦公模塊具備良好的可擴展性與二次開發(fā)基礎。資源包共1160個文件含333個Java源碼、365個編譯后class文件支撐完整MVC分層結構另有179個JS腳本實現(xiàn)前端交互、50個JSP頁面構成視圖層、35個XML配置文件管理映射與參數(shù)輔以CSS、JSON、SQL等配套資源整體壓縮包僅6.95MB輕量易部署。目前已有2371人學習下載讀者可直接運行調試、理解SSM整合原理、掌握OA典型業(yè)務流程實現(xiàn)邏輯并基于現(xiàn)有結構快速拓展CRM、HRM等功能模塊。1. 這不是又一個“登錄后顯示歡迎頁”的OA Demo它跑在真實局域網(wǎng)里支撐過37人行政人事IT協(xié)同流程審批流能回退、抄送可分級、附件帶版本號——適合想拿源碼改出自己公司第一版輕量OA的Java后端開發(fā)者我去年幫某高校實驗室搭內(nèi)部事務系統(tǒng)時翻過二十多個標著“Java OA”的開源項目八成點開就是Spring Boot Thymeleaf寫個用戶管理公告列表連請假單字段都硬編碼在HTML里。這個項目不一樣它用Spring MVC分層清晰到能直接拎出approval-service模塊做微服務拆分數(shù)據(jù)庫設計里sys_workflow_node表存的是可配置的節(jié)點跳轉規(guī)則不是if-else寫死的“領導A批完→領導B批”更關鍵的是它把Office文檔在線預覽封裝成獨立doc-viewer-starter連Word里的修訂痕跡都能渲染出來。如果你正卡在“怎么讓審批人看到上一環(huán)節(jié)的批注”或者“怎么把Excel導入的員工數(shù)據(jù)自動同步到組織架構樹”這份代碼就是你該下載的那一個——它不教你怎么配Spring Boot啟動類但會告訴你WorkflowEngine.execute()里第17行那個context.put(currentApprover, resolveNextApprover())是怎么動態(tài)算出下一審批人的。2. 項目結構與技術棧從pom.xml看懂它為什么能扛住并發(fā)審批壓測2.1 目錄結構五個核心模塊撐起完整業(yè)務閉環(huán)項目采用標準Maven多模塊結構根目錄下pom.xml聲明了6個子模塊但真正承載業(yè)務的是以下5個oa-common為工具包不計入業(yè)務模塊oa-parent/ ├── oa-core/ # Spring MVC控制器層 全局異常處理 攔截器鏈 ├── oa-workflow/ # 審批引擎核心流程定義解析、節(jié)點執(zhí)行、駁回重走邏輯 ├── oa-doc/ # 文檔服務Office文件解析Apache POI、在線預覽LibreOffice headless、版本快照存儲 ├── oa-report/ # 報表中心JasperReports集成支持導出PDF/Excel的審批統(tǒng)計看板 └── 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原因很實際動態(tài)SQL能力審批流中“根據(jù)部門負責人是否在職動態(tài)跳過節(jié)點”需要運行時拼接WHERE條件MyBatis的choose標簽比JPA的Criteria API寫起來快3倍SQL審計友好oa-core模塊的SqlAuditInterceptor能直接攔截Mapper.xml里的SQL而JPA的Query注解會讓審計日志變成一堆問號參數(shù)分庫分表平滑oa-report模塊未來要接入ShardingSphereMyBatis-Plus的TableNameHandler接口比JPA的Table注解更容易注入分片邏輯。依賴清單關鍵片段pom.xml!-- 審批引擎強依賴 -- dependency groupIdorg.activiti/groupId artifactIdactiviti-spring-boot-starter-basic/artifactId version7.1.0.M6/version !-- 注意非官方最新版此版本修復了Activiti 7.1.0.M5的并發(fā)鎖bug -- /dependency !-- 文檔解析避坑選擇 -- dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version4.1.2/version !-- 低于4.1.2無法正確讀取.xlsx中的條件格式 -- /dependency dependency groupIdnet.sf.jasperreports/groupId artifactIdjasperreports/artifactId version6.17.0/version /dependency2.3 數(shù)據(jù)庫設計精髓一張sys_workflow_instance表如何承載所有流程狀態(tài)oa-workflow模塊的數(shù)據(jù)庫設計是理解整個系統(tǒng)的關鍵。以最常用的請假流程為例其狀態(tài)流轉不靠Java枚舉硬編碼而是通過三張表聯(lián)動表名核心字段作用sys_workflow_definitionid,code,name,xml_content存儲流程定義XML如leave-process-v2.xmlsys_workflow_instanceid,definition_id,status,current_node_code,variables_json實例主表status字段值為RUNNING/COMPLETED/TERMINATED不存具體審批人IDsys_workflow_taskid,instance_id,node_code,assignee_id,status,comment任務明細表每次審批生成一條記錄assignee_id指向sys_user.id關鍵邏輯當用戶提交請假申請時系統(tǒng)解析leave-process-v2.xml根據(jù)sequenceFlow節(jié)點的conditionExpression如${user.department.leaderStatus ONLINE}動態(tài)計算出第一個審批節(jié)點并向sys_workflow_task插入記錄。后續(xù)所有跳轉都基于此表的status變更觸發(fā)而非修改sys_workflow_instance.current_node_code——這保證了同一實例可被多人并行審批如會簽場景。3. 啟動與運行三步跑通繞過90%新手卡點3.1 環(huán)境準備JDK 11 MySQL 5.7 是唯一驗證組合項目明確要求JDK 11pom.xml中java.version11/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. 創(chuàng)建數(shù)據(jù)庫字符集必須為utf8mb4 mysql -u root -p -e CREATE DATABASE oa_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 2. 執(zhí)行初始化SQL路徑oa-parent/sql/oa_init.sql mysql -u root -p oa_db oa-parent/sql/oa_init.sql # 3. 修改數(shù)據(jù)庫連接配置oa-core/src/main/resources/application.yml spring: datasource: url: jdbc:mysql://localhost:3306/oa_db?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: your_password3.2 啟動命令別直接mvn spring-boot:run根目錄下執(zhí)行# 先編譯所有模塊跳過測試避免H2數(shù)據(jù)庫沖突 mvn clean compile -Dmaven.test.skiptrue # 再啟動核心模塊注意不是啟動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登錄后點擊頂部菜單【流程中心】→【新建請假】填寫表單并提交。此時檢查數(shù)據(jù)庫-- 查看流程實例是否創(chuàng)建 SELECT id, status, current_node_code FROM sys_workflow_instance WHERE business_key LIKE leave_%; -- 查看審批任務是否生成應有一條statusASSIGNED的記錄 SELECT id, assignee_id, status FROM sys_workflow_task WHERE instance_id 剛查到的id;若查詢結果符合預期實例狀態(tài)為RUNNING任務狀態(tài)為ASSIGNED說明審批引擎已激活——接下來就可以改代碼了。4. 核心模塊代碼拆解一個請假審批請求從HTTP到數(shù)據(jù)庫的完整鏈路4.1 控制器層LeaveController.submit()如何觸發(fā)流程引擎oa-core模塊的com.oa.controller.LeaveController中提交請假的入口方法如下PostMapping(/leave/submit) public Result submit(RequestBody Valid LeaveSubmitDTO dto, HttpServletRequest request) { // 1. 從Session獲取當前用戶非JWT項目用傳統(tǒng)Session User currentUser (User) request.getSession().getAttribute(currentUser); // 2. 構建流程變量關鍵variables必須是MapString, Object MapString, 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模塊提供的服務門面它內(nèi)部會① 從sys_workflow_definition查出leave-process-v2的XML定義② 解析XML獲取首節(jié)點③ 調用Activiti的runtimeService.startProcessInstanceByKey()④ 將生成的ProcessInstance信息存入sys_workflow_instance表。注意variables中的key必須與XML中userTask節(jié)點的assignee表達式匹配如XML中寫${userId}則map里必須有userId鍵。4.2 審批引擎WorkflowEngine.executeTask()里的動態(tài)路由oa-workflow模塊的WorkflowEngine類中審批動作的核心邏輯在executeTask()方法public void executeTask(String taskId, MapString, Object variables) { // 1. 先完成當前任務Activiti原生API taskService.complete(taskId, variables); // 2. 查詢流程實例當前狀態(tài) ProcessInstance pi runtimeService.createProcessInstanceQuery() .processInstanceId(getProcessInstanceIdByTaskId(taskId)) .singleResult(); // 3. 關鍵根據(jù)流程定義XML計算下一節(jié)點非硬編碼 String nextNodeCode getNextNodeCode(pi.getProcessDefinitionId(), pi.getActivityId(), variables); // 4. 如果下一節(jié)點是用戶任務則創(chuàng)建新任務 if (userTask.equals(getNodeType(nextNodeCode))) { String assigneeId resolveAssignee(nextNodeCode, variables); // 動態(tài)計算審批人 taskService.addCandidateUser(taskId, assigneeId); // 注意此處taskId是新任務ID } }參數(shù)說明resolveAssignee()方法會依次檢查① XML中userTask節(jié)點的assignee屬性如assignee${department.leaderId}② 若為空則查sys_workflow_node_config表中該節(jié)點的默認審批人③ 最終兜底到sys_department表中部門負責人的leader_id字段。這種三級 fallback 機制讓“部門負責人離職時自動轉給副職”成為可能。4.3 文檔預覽DocPreviewController.preview()如何繞過瀏覽器兼容性雷區(qū)oa-doc模塊的文檔預覽功能不依賴第三方SaaS而是本地調用LibreOffice轉換GetMapping(/doc/preview/{fileId}) public ResponseEntityResource preview(PathVariable String fileId) { // 1. 從數(shù)據(jù)庫查文件元信息含原始文件名、存儲路徑 DocFile docFile docFileMapper.selectById(fileId); // 2. 生成唯一預覽緩存路徑避免并發(fā)轉換沖突 String cacheKey DigestUtils.md5Hex(docFile.getOriginalName() docFile.getUploadTime()); String pdfPath /tmp/oa-preview/ cacheKey .pdf; // 3. 檢查PDF緩存是否存在不存在則觸發(fā)轉換 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); }血淚經(jīng)驗libreOfficeConverter.convertToPdf()底層調用soffice --headless --convert-to pdf命令必須確保服務器已安裝LibreOffice 7.06.x版本對.docx表格支持不全。若返回空白PDF先檢查/tmp/oa-preview/目錄權限是否為755再執(zhí)行soffice --version驗證安裝。5. 避坑指南那些讓我重啟三次Tomcat才定位到的致命問題5.1 現(xiàn)象提交請假后頁面卡在“加載中”控制臺無報錯數(shù)據(jù)庫sys_workflow_task無新記錄原因application.yml中spring.activiti.database-schema-update被設為false但首次啟動時Activiti所需的23張表未自動創(chuàng)建項目未提供activiti.mysql.create.sql腳本。解決手動執(zhí)行oa-parent/sql/activiti-mysql-create.sql注意此文件在oa-workflow模塊的src/main/resources目錄下非根目錄SQL文件夾。5.2 現(xiàn)象審批人收到待辦點擊“同意”后流程停滯sys_workflow_instance.status仍為RUNNING原因LeaveController.approve()方法中調用workflowService.executeTask()時傳入的taskId是前端JS拼接的字符串但數(shù)據(jù)庫中TASK_ID_字段是VARCHAR(64)而Activiti生成的ID含下劃線如123456:abc_defMySQL嚴格模式下WHERE task_id 123456:abc_def會因字符集問題匹配失敗。解決在workflowService.executeTask()開頭添加日志log.info(Executing task: {}, taskId);對比日志輸出與數(shù)據(jù)庫ACT_RU_TASK.TASK_ID_字段值若發(fā)現(xiàn)日志中taskId末尾多出空格需在前端JS中加taskId.trim()。5.3 現(xiàn)象上傳Word文檔后預覽報錯java.lang.UnsatisfiedLinkError: Cant 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 groupIdorg.jodconverter/groupId artifactIdjodconverter-local/artifactId version4.4.7/version /dependency5.4 現(xiàn)象修改leave-process-v2.xml后重啟服務新流程不生效原因Activiti默認將流程定義緩存在內(nèi)存中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 現(xiàn)象報表導出Excel時中文全部顯示為方框原因jasperreports默認字體為DejaVu Sans該字體不包含中文字符且項目未配置中文字體。解決在oa-report模塊的src/main/resources/jasperreports_extension.properties中添加net.sf.jasperreports.extension.registry.factory.fontsnet.sf.jasperreports.engine.fonts.SimpleFontExtensionsRegistryFactory net.sf.jasperreports.extension.simple.font.families.arialfonts/fonts.xml并在src/main/resources/fonts/目錄下放入fonts.xml定義SimSun字體路徑和simsun.ttc字體文件。6. 二次開發(fā)實戰(zhàn)把“部門負責人審批”改成“按職級自動匹配審批鏈”6.1 需求場景某公司規(guī)定“請假≥3天需經(jīng)部門負責人分管副總雙簽”但當前流程只能配置固定節(jié)點現(xiàn)有l(wèi)eave-process-v2.xml中審批節(jié)點寫死為userTask iddeptLeaderTask name部門負責人審批 activiti:assignee${department.leaderId} / userTask idvpTask name分管副總審批 activiti:assignee${company.vpId} /這導致所有請假都走相同路徑無法根據(jù)leaveDays動態(tài)決定是否需要副總審批。6.2 改造方案用Activiti的executionListener注入動態(tài)邏輯第一步在leave-process-v2.xml的開始事件后添加監(jiān)聽器startEvent idstart name開始 extensionElements activiti:executionListener eventstart classcom.oa.workflow.listener.LeaveStartListener / /extensionElements /startEvent第二步編寫LeaveStartListener放在oa-workflow模塊public class LeaveStartListener implements ExecutionListener { Override public void notify(DelegateExecution execution) throws Exception { // 1. 獲取流程變量中的請假天數(shù) Integer days (Integer) execution.getVariable(leaveDays); // 2. 根據(jù)天數(shù)設置不同審批路徑標志 if (days ! null days 3) { execution.setVariable(needVpApproval, true); } else { execution.setVariable(needVpApproval, false); } } }第三步修改XML中的網(wǎng)關分支exclusiveGateway idapprovalGateway name審批網(wǎng)關 / sequenceFlow idtoDeptLeader sourceRefapprovalGateway targetRefdeptLeaderTask conditionExpression${!needVpApproval}/conditionExpression /sequenceFlow sequenceFlow idtoVp sourceRefapprovalGateway targetRefvpTask conditionExpression${needVpApproval}/conditionExpression /sequenceFlow6.3 驗證方法用Postman模擬不同天數(shù)提交構造兩個請求驗證分支邏輯# 請求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 進階技巧審批人動態(tài)計算函數(shù)注冊到Activiti若resolveAssignee()邏輯復雜如“職級≥P7的員工請假需CTO終審”可將計算函數(shù)注冊為Activiti的全局函數(shù)// 在WorkflowConfig.java中 Bean public ProcessEngineConfiguration processEngineConfiguration() { StandaloneProcessEngineConfiguration config new StandaloneProcessEngineConfiguration(); // ...其他配置 config.setBeans(Collections.singletonMap(approvalResolver, new ApprovalResolver())); // 注冊為beans return config; }然后在XML中直接調用userTask idctoTask nameCTO終審 activiti:assignee${approvalResolver.resolveCtoForSenior(user)} /從那以后我每次改流程定義XML都強制走一遍mvn clean compile再啟動因為Activiti的緩存機制太玄學——哪怕只是改了個空格也可能讓新邏輯靜默失效。希望幫到你。本文還有配套的精品資源點擊獲取