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