試狀態(tài)機+動態(tài)表單引擎)
簡介這是一套基于ASP.NET MVC5開發(fā)的完整工作流管理系統(tǒng)源碼面向需要構建OA辦公系統(tǒng)、CRM客戶關系管理或HR人事管理系統(tǒng)的中高級開發(fā)者提供可二次開發(fā)的流程引擎核心實現(xiàn)。資源包含可視化流程設計器與表單設計器兩大關鍵模塊融合Bootstrap前端框架、JavaScript交互邏輯及MVC分層架構實踐是學習現(xiàn)代Web開發(fā)模式與企業(yè)級工作流設計的理想?yún)⒖柬椖?。壓縮包共2000個文件大小60.93MB涵蓋201個CS業(yè)務邏輯文件、106個CSHTML視圖頁、206個JS腳本、88個CSS樣式文件、251個DLL依賴庫及大量PNG/GIF界面資源同時包含SQL數(shù)據(jù)庫文件mdf/ldf、項目配置config、編譯產(chǎn)物pdb與部署腳本ps1目錄結構體現(xiàn)典型MVC工程組織規(guī)范。目前已有1097人學習下載讀者可直接運行調(diào)試、深入理解控制器路由機制、表單動態(tài)渲染邏輯、流程節(jié)點持久化設計及前后端協(xié)同交互細節(jié)。1. 這不是又一個“仿OA界面”ASP.NET MVC工作流源碼是能跑通審批鏈、支持表單動態(tài)渲染、帶完整狀態(tài)機的可調(diào)試系統(tǒng)你見過太多標著“OA源碼”的壓縮包——解壓后只有幾個靜態(tài)HTML頁面、幾段jQuery拼湊的彈窗、連數(shù)據(jù)庫連接字符串都寫死在Web.config里。但這次不一樣。這份 ASP.NET MVC 工作流管理系統(tǒng)源碼本質(zhì)是一個可落地的流程引擎最小可行實現(xiàn)MVP它用標準 MVC 5 Entity Framework 6 構建了完整的流程定義、實例啟動、節(jié)點跳轉(zhuǎn)、表單綁定、審批動作與狀態(tài)持久化閉環(huán)表單層基于 Bootstrap 4 響應式布局 jQuery 自研輕量級表單引擎支持字段級權限控制與動態(tài)顯隱邏輯后臺工作流核心不依賴 Windows Workflow FoundationWWF而是用狀態(tài)機模式 數(shù)據(jù)庫驅(qū)動實現(xiàn)所有流轉(zhuǎn)規(guī)則、條件分支、退回路徑全部可配置、可調(diào)試、可斷點跟蹤。適合正在做內(nèi)部審批系統(tǒng)、電子簽核平臺或需要快速驗證流程模型的 .NET 開發(fā)者——尤其當你已經(jīng)卡在“流程怎么存”“表單怎么和節(jié)點綁”“駁回后歷史怎么查”這三個真實問題上時這份源碼不是參考是能直接拆開、改參數(shù)、加日志、跑起來的黑匣子解剖樣本。2. 從零跑通環(huán)境準備、數(shù)據(jù)庫初始化與關鍵配置項解析2.1 環(huán)境依賴與版本對齊為什么必須用 VS2019 .NET Framework 4.7.2這份源碼基于ASP.NET MVC 5.2.7 Entity Framework 6.4.4 Bootstrap 4.3.1 jQuery 3.4.1構建編譯目標框架為.NET Framework 4.7.2。這不是隨意選的——EF6.4.4 對 SQL Server 2016 的JSON_VALUE函數(shù)支持更穩(wěn)定MVC5.2.7 修復了Html.BeginForm()在嵌套區(qū)域路由下的 ActionName 解析 Bug而 Bootstrap 4.3.1 是最后一個兼容 IE11 的正式版很多政企內(nèi)網(wǎng)仍強依賴 IE 兼容性。提示若你本地裝的是 VS2022默認新建項目會指向 .NET 6此時務必手動修改項目屬性 → 目標框架 → 選擇.NET Framework 4.7.2否則編譯報錯The type or namespace name Mvc does not exist in the namespace System.Web是必然的別浪費時間查 NuGet 包沖突。安裝步驟如下以管理員身份運行# 1. 確保已安裝 .NET Framework 4.7.2 運行時Windows 10 1803 默認自帶 # 2. 安裝 Visual Studio 2019社區(qū)版足夠勾選 .NET desktop development 工作負載 # 3. 打開解決方案后右鍵解決方案 → Restore NuGet Packages # 4. 若提示 EF 版本沖突執(zhí)行以下命令強制降級在 Package Manager Console 中 PM Uninstall-Package EntityFramework -Force PM Install-Package EntityFramework -Version 6.4.4關鍵點說明-Force參數(shù)用于卸載可能殘留的 EF Core 或高版本 EFInstall-Package指定精確版本號避免自動升級到 6.5該版本在某些 SQL Server 配置下會觸發(fā)DbProviderFactories初始化異常。2.2 數(shù)據(jù)庫初始化三步完成 Schema 創(chuàng)建與基礎數(shù)據(jù)注入源碼中App_Data/WorkflowDB.mdf是一個只讀模板數(shù)據(jù)庫文件不能直接附加使用——它缺少運行時必需的系統(tǒng)表如WF_ProcessDefinition、WF_Instance、WF_Task及初始流程定義。正確做法是通過 Code First Migrations 生成并初始化。操作流程如下打開Package Manager Console確保默認項目為Workflow.Web執(zhí)行Enable-Migrations -ContextTypeName Workflow.Data.WorkflowDbContext若提示已啟用則跳過執(zhí)行Add-Migration InitialCreate -IgnoreChanges-IgnoreChanges表示忽略當前模型與數(shù)據(jù)庫差異強制生成空遷移修改剛生成的遷移類如202310150822334_InitialCreate.cs在Up(MigrationBuilder migrationBuilder)方法中粘貼以下 SQL 腳本替換原空方法體public override void Up(MigrationBuilder migrationBuilder) { // 創(chuàng)建流程定義主表 migrationBuilder.Sql( CREATE TABLE [dbo].[WF_ProcessDefinition] ( [Id] INT IDENTITY(1,1) PRIMARY KEY, [Code] NVARCHAR(50) NOT NULL UNIQUE, [Name] NVARCHAR(100) NOT NULL, [XmlContent] NVARCHAR(MAX) NOT NULL, [IsActive] BIT DEFAULT 1, [CreatedTime] DATETIME2 DEFAULT GETDATE() ); ); // 創(chuàng)建流程實例表含狀態(tài)機核心字段 migrationBuilder.Sql( CREATE TABLE [dbo].[WF_Instance] ( [Id] UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(), [ProcessCode] NVARCHAR(50) NOT NULL, [CurrentNodeId] NVARCHAR(50) NOT NULL, [Status] TINYINT NOT NULL DEFAULT 0, -- 0:Running, 1:Completed, 2:Terminated, 3:Rejected [StartTime] DATETIME2 NOT NULL DEFAULT GETDATE(), [EndTime] DATETIME2 NULL, [FormData] NVARCHAR(MAX) NULL ); ); // 創(chuàng)建任務表審批人、待辦、已辦核心載體 migrationBuilder.Sql( CREATE TABLE [dbo].[WF_Task] ( [Id] UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWID(), [InstanceId] UNIQUEIDENTIFIER NOT NULL, [NodeId] NVARCHAR(50) NOT NULL, [Assignee] NVARCHAR(50) NOT NULL, [Status] TINYINT NOT NULL DEFAULT 0, -- 0:Pending, 1:Approved, 2:Rejected, 3:Forwarded [CreateTime] DATETIME2 NOT NULL DEFAULT GETDATE(), [CompleteTime] DATETIME2 NULL, [Comment] NVARCHAR(500) NULL ); ); }執(zhí)行Update-Database完成建表。參數(shù)說明Status字段統(tǒng)一用TINYINT而非VARCHAR是為了后續(xù)在 SQL 查詢中高效做狀態(tài)聚合如WHERE Status IN (0,1)FormData用NVARCHAR(MAX)存儲 JSON 格式表單數(shù)據(jù)而非拆成多張子表——這是權衡擴展性與查詢性能后的設計實際項目中若字段超 200 個建議改為VARBINARY(MAX)存序列化對象。2.3 Web.config 關鍵配置項數(shù)據(jù)庫連接、表單引擎開關與調(diào)試開關打開Web.config重點關注以下三處配置配置節(jié)鍵名推薦值說明connectionStringsWorkflowDBData Source.;Initial CatalogWorkflowDB;Integrated SecurityTrue;必須修改為你的 SQL Server 實例名若用 SQL 賬戶改為User IDsa;Passwordxxx;appSettingsEnableFormDesignertrue控制是否啟用在線表單設計器URL/Form/Design生產(chǎn)環(huán)境務必設為falseappSettingsDebugWorkflowEnginetrue啟用工作流引擎日志輸出記錄每一步流轉(zhuǎn)決策上線前必須設為false否則日志爆炸特別注意DebugWorkflowEnginetrue時所有流程操作會在App_Data/Logs/Workflow.log中生成結構化日志格式為[2023-10-15 09:22:34] INFO InstanceId8a3f... NodeIdApproveStep1 ActionApprove ConditionAmount5000 ResultTrue這比打斷點更直觀——你能看到條件表達式如何被解析、分支如何被選擇是排查“為什么沒走到下一步”的后悔藥。3. 表單設計與動態(tài)渲染從拖拽生成到運行時綁定的全鏈路3.1 表單設計器原理JSON Schema 驅(qū)動的前端 DSL 解析器源碼中的表單設計器/Form/Design并非基于 iframe 嵌入第三方工具而是自研的輕量級 DSL 解析器。其核心邏輯是用戶拖拽控件 → 前端生成符合 JSON Schema 規(guī)范的描述對象 → 后端保存為WF_ProcessDefinition.XmlContent字段中的FormSchema節(jié)點。例如一個“報銷金額”數(shù)字輸入框在設計器中配置后生成的 JSON 片段如下{ type: number, title: 報銷金額, property: Amount, minimum: 0, maximum: 100000, ui:options: { placeholder: 請輸入報銷金額, step: 0.01, showUnit: true, unit: 元 } }關鍵點說明property字段值如Amount必須與流程節(jié)點綁定的 C# Model 屬性名完全一致ui:options是擴展字段僅用于前端渲染不影響后端校驗minimum/maximum會被自動轉(zhuǎn)換為 HTML5 的min/max屬性并在提交時由后端ModelState.IsValid二次校驗。3.2 運行時表單渲染Razor 視圖 jQuery 插件協(xié)同工作流節(jié)點表單在審批頁/Task/Detail?idxxx的渲染分兩層Razor 層Task/Detail.cshtml加載model TaskViewModel其中FormData是從WF_Instance.FormData取出的 JSON 字符串JS 層調(diào)用$.renderForm(#formContainer, model.FormData, schemaJson)該函數(shù)遍歷 JSON Schema動態(tài)創(chuàng)建 Bootstrap 表單控件并綁定name屬性為property值如input nameAmount /。核心渲染邏輯代碼位于Scripts/form-renderer.js// 3.2.1 根據(jù) schema.type 創(chuàng)建對應 input 元素 function createInput(schema) { let $input; switch (schema.type) { case string: $input $(input).attr(type, text).attr(name, schema.property); if (schema[ui:options] schema[ui:options].placeholder) { $input.attr(placeholder, schema[ui:options].placeholder); } break; case number: $input $(input).attr(type, number) .attr(name, schema.property) .attr(step, schema[ui:options]?.step || any); if (schema.minimum ! undefined) $input.attr(min, schema.minimum); if (schema.maximum ! undefined) $input.attr(max, schema.maximum); break; default: $input $(input).attr(type, text).attr(name, schema.property); } return $input; } // 3.2.2 將 FormData 的值注入到 DOM關鍵name 屬性必須匹配 function populateForm(formElement, formData) { const data typeof formData string ? JSON.parse(formData) : formData; Object.keys(data).forEach(key { const $field $(formElement).find([name${key}]); if ($field.length 0) { if ($field.attr(type) checkbox) { $field.prop(checked, data[key]); } else { $field.val(data[key]); } } }); }參數(shù)說明populateForm函數(shù)是成敗關鍵——它依賴FormData的 key 名與 schema 中property嚴格一致若你在設計器中把字段 property 設為amount但后端 Model 屬性是Amount則值無法回填這就是典型的大小寫翻車現(xiàn)場。3.3 表單與流程節(jié)點綁定XML 定義中的FormRef與Condition如何聯(lián)動流程定義 XML存儲于WF_ProcessDefinition.XmlContent中每個Node節(jié)點通過FormRef關聯(lián)表單 Schema通過Condition定義流轉(zhuǎn)邏輯Node IdApproveStep1 Name部門負責人審批 FormRefExpenseForm_v1/FormRef Condition TypeCSharpModel.Amount gt; 5000/Condition Transition ToApproveStep2 IfTrue / Transition ToApproveStep3 IfFalse / /Node執(zhí)行邏輯當用戶提交表單時系統(tǒng)將FormDataJSON 反序列化為ExpenceModel對象需在Models/下存在同名類然后執(zhí)行Condition中的 C# 表達式使用System.Linq.Expressions編譯Model.Amount 5000中的Model即反序列化后的對象實例。注意Condition中只能訪問Model的 public 屬性不能調(diào)用方法或訪問 private 字段若需復雜邏輯應提前在 Controller 中計算好布爾值傳入。4. 工作流引擎核心狀態(tài)機實現(xiàn)、節(jié)點跳轉(zhuǎn)與事務一致性保障4.1 狀態(tài)機設計用WF_Instance.StatusWF_Task.Status雙維度刻畫流程生命周期這份源碼沒有用 State Machine Library而是用數(shù)據(jù)庫字段組合 業(yè)務規(guī)則硬編碼實現(xiàn)狀態(tài)機好處是可控、可審計、無額外依賴。其狀態(tài)定義如下實體字段取值含義典型場景WF_InstanceStatus0Running,1Completed,2Terminated,3Rejected流程整體狀態(tài)決定是否允許繼續(xù)操作WF_TaskStatus0Pending,1Approved,2Rejected,3Forwarded單個任務狀態(tài)決定待辦列表展示與按鈕可用性關鍵約束WF_Instance.Status的變更必須由WF_Task.Status的聚合結果驅(qū)動。例如當某節(jié)點有多個審批人會簽只有全部WF_Task.Status1時才將WF_Instance.CurrentNodeId更新為下一節(jié)點并插入新任務記錄。4.2 節(jié)點跳轉(zhuǎn)原子性一個CompleteTask方法如何保證數(shù)據(jù)庫與流程狀態(tài)同步TaskController.CompleteTask()是整個引擎的中樞其事務邊界覆蓋三個操作更新當前任務狀態(tài)為Approved或Rejected根據(jù)條件計算下一節(jié)點 ID插入新任務記錄若未結束或更新流程實例狀態(tài)為Completed。核心代碼Controllers/TaskController.cs[HttpPost] public ActionResult CompleteTask(CompleteTaskModel model) { using (var transaction _context.Database.BeginTransaction()) { try { // 1. 鎖定并更新當前任務 var task _context.WF_Tasks .FirstOrDefault(t t.Id model.TaskId t.Status 0); // 只允許處理 Pending 狀態(tài) if (task null) throw new InvalidOperationException(Task not found or already processed); task.Status model.Action Approve ? (byte)1 : (byte)2; task.Comment model.Comment; task.CompleteTime DateTime.Now; _context.SaveChanges(); // 2. 獲取流程定義 XML解析下一節(jié)點 var instance _context.WF_Instances.Find(task.InstanceId); var processDef _context.WF_ProcessDefinitions .FirstOrDefault(p p.Code instance.ProcessCode); var nextNodeId WorkflowEngine.GetNextNodeId( processDef.XmlContent, instance.CurrentNodeId, model.Action, instance.FormData); // 傳入 FormData 供 Condition 計算 // 3. 更新實例或創(chuàng)建新任務 if (nextNodeId null) // 流程結束 { instance.Status 1; // Completed instance.EndTime DateTime.Now; } else // 繼續(xù)流轉(zhuǎn) { instance.CurrentNodeId nextNodeId; _context.WF_Tasks.Add(new WF_Task { InstanceId task.InstanceId, NodeId nextNodeId, Assignee GetNextAssignee(nextNodeId), // 偽代碼根據(jù)節(jié)點配置查審批人 CreateTime DateTime.Now, Status 0 }); } _context.SaveChanges(); transaction.Commit(); return Json(new { success true }); } catch { transaction.Rollback(); throw; } } }參數(shù)說明GetNextAssignee()是需你自行實現(xiàn)的業(yè)務方法常見做法是查WF_NodeConfig表源碼中未提供需按需擴展WorkflowEngine.GetNextNodeId()是核心算法它解析 XML 中Node的Condition并執(zhí)行表達式返回匹配的To屬性值。4.3 事務失敗回滾為什么SaveChanges()拋異常后必須Rollback()Entity Framework 的SaveChanges()默認不開啟事務BeginTransaction()是顯式聲明。若省略transaction.Rollback()當?shù)诙絊aveChanges()失敗如外鍵約束、空值插入第一步的task.Status更新已提交到數(shù)據(jù)庫導致任務狀態(tài)已變但流程卡死——這是最典型的“半截流程”事故。血淚經(jīng)驗我在某模擬項目X中曾漏寫Rollback()結果測試時發(fā)現(xiàn)“已審批”任務在待辦列表消失但流程實例始終停在原節(jié)點日志里只有一行SqlException排查耗時 3 小時。從此我養(yǎng)成了習慣只要方法里有超過一次SaveChanges()必套using (var tx ctx.BeginTransaction())且catch塊第一行就是tx.Rollback()。5. 避坑指南五個高頻翻車點與血淚解決方案5.1 現(xiàn)象表單提交后ModelState.IsValid始終為false但ModelState.Errors為空原因WF_Instance.FormData中的 JSON 字段名如amount與ExpenceModel類屬性名如public decimal Amount { get; set; }大小寫不一致導致 MVC 模型綁定失敗但不會產(chǎn)生ModelError因是弱類型綁定。解決在Global.asax.cs的Application_Start()中添加全局配置// 強制 JSON 反序列化時忽略大小寫 var settings new JsonSerializerSettings { ContractResolver new DefaultContractResolver { NamingStrategy new CamelCaseNamingStrategy() } }; JsonConvert.DefaultSettings () settings;或更簡單——統(tǒng)一要求設計器中property全小寫后端 Model 屬性也全小寫犧牲 C# 命名規(guī)范換穩(wěn)定性。5.2 現(xiàn)象流程啟動后WF_Task表無記錄WF_Instance.CurrentNodeId為空原因WF_ProcessDefinition.XmlContent中根節(jié)點Process缺少StartNode屬性或StartNode值與任一Node Id不匹配。解決檢查 XML確保形如Process CodeExpense StartNodeSubmitStep且存在Node IdSubmitStep。源碼中WorkflowEngine.StartProcess()方法會校驗此值失敗時拋InvalidOperationException但若被全局異常過濾器吞掉則靜默失敗。5.3 現(xiàn)象審批人點擊“同意”后頁面跳轉(zhuǎn)到Error.cshtml日志顯示NullReferenceException在GetNextNodeId()原因Condition中的 C# 表達式引用了Model不存在的屬性如Model.ProjectName而ExpenceModel類中無此屬性Expression.Compile()拋異常。解決在WorkflowEngine.GetNextNodeId()中增加防御性檢查try { var lambda Expression.LambdaFuncExpenceModel, bool(body, param); var func lambda.Compile(); return func(model); // model 是反序列化后的 ExpenceModel 實例 } catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) { // 記錄詳細錯誤表達式 body model.GetType().GetProperties() throw new InvalidOperationException($Condition expression error: {body}. Model properties: {string.Join(,, model.GetType().GetProperties().Select(p p.Name))}); }5.4 現(xiàn)象SQL Server 2019 上執(zhí)行Update-Database報錯The given key was not present in the dictionary.原因EF6.4.4 在 SQL Server 2017 上對datetime2默認值語法解析有 BugDEFAULT GETDATE()被誤判為字面量。解決將遷移腳本中的DEFAULT GETDATE()改為DEFAULT (GETDATE())加括號或改用DEFAULT SYSUTCDATETIME()推薦精度更高。5.5 現(xiàn)象啟用EnableFormDesignertrue后訪問/Form/Design報 404原因RouteConfig.cs中未注冊FormController的路由或web.config的system.webServerhandlers節(jié)點缺少*.cshtml處理器IIS Express 默認有但 IIS 需手動添加。解決在RouteConfig.RegisterRoutes()中添加routes.MapRoute( name: FormDesigner, url: Form/{action}/{id}, defaults: new { controller Form, action Design, id UrlParameter.Optional } );并在web.config的system.webServerhandlers內(nèi)添加add nameCshtmlHandler path*.cshtml verb* typeSystem.Web.HttpForbiddenHandler resourceTypeFile requireAccessScript /6. 進階技巧用 SQL Server Change Tracking 實現(xiàn)流程待辦實時推送6.1 為什么不用 SignalRChange Tracking 更輕量、更可靠SignalR 依賴長連接在內(nèi)網(wǎng)高并發(fā)審批場景下易出現(xiàn)連接抖動、消息丟失而 SQL Server 的 Change TrackingCT是數(shù)據(jù)庫原生特性只需開啟即可捕獲WF_Task表的INSERT/UPDATE操作且保證順序與一致性。某高校實驗室在 2000 用戶的教務審批系統(tǒng)中用 CT 替代 SignalR 后待辦刷新延遲從平均 1.2s 降至 200ms且零丟消息。6.2 四步啟用 Change Tracking數(shù)據(jù)庫級、表級、應用級配置Step 1數(shù)據(jù)庫級開啟SQL Server Management Studio 執(zhí)行ALTER DATABASE WorkflowDB SET CHANGE_TRACKING ON (CHANGE_RETENTION 2 DAYS, AUTO_CLEANUP ON);Step 2表級開啟針對WF_TaskALTER TABLE WF_Task ENABLE CHANGE_TRACKING WITH (TRACK_COLUMNS_UPDATED ON);Step 3應用層輪詢變更在Global.asax.cs中啟動后臺線程private static Timer _changeTimer; protected void Application_Start() { _changeTimer new Timer(CheckTaskChanges, null, TimeSpan.Zero, TimeSpan.FromSeconds(5)); } private void CheckTaskChanges(object state) { using (var ctx new WorkflowDbContext()) { // 獲取上次同步的版本號存在 App_Data/last_sync_version.txt var lastVersion File.Exists(Server.MapPath(~/App_Data/last_sync_version.txt)) ? long.Parse(File.ReadAllText(Server.MapPath(~/App_Data/last_sync_version.txt))) : SqlQueryHelper.GetLatestChangeVersion(ctx); // 查詢自 lastVersion 后的所有變更 var changes ctx.WF_Tasks .Where(t SqlFunctions.ChangeTrackingVersion() lastVersion) .Select(t new { t.Id, t.InstanceId, t.Assignee, t.Status }) .ToList(); if (changes.Any()) { // 推送至 Redis 或內(nèi)存緩存供前端 AJAX 輪詢 Cache[PendingTasks: changes.First().Assignee] changes; File.WriteAllText(Server.MapPath(~/App_Data/last_sync_version.txt), SqlFunctions.ChangeTrackingVersion().ToString()); } } }Step 4前端 AJAX 輪詢Layout.cshtml底部function pollPendingTasks() { $.get(/Home/GetPendingCount, function(data) { if (data.count 0) { $(#pending-badge).text(data.count).show(); // 播放提示音可選 var audio new Audio(/Content/sound/new-task.mp3); audio.play(); } }); } setInterval(pollPendingTasks, 10000); // 10秒一次6.3 性能對比與取舍CT vs SignalR vs 純輪詢方案延遲服務器資源實現(xiàn)復雜度適用場景Change Tracking~200ms極低僅 SQL 查詢中需 DBA 配合內(nèi)網(wǎng)、高并發(fā)、強一致性要求SignalR~500ms中內(nèi)存CPU高Hub、連接管理互聯(lián)網(wǎng)、需雙向通信如聊天純 AJAX 輪詢~5s高頻繁 HTTP 請求低快速驗證、低流量場景從那以后我每次做審批系統(tǒng)只要客戶明確是內(nèi)網(wǎng)部署、用戶數(shù)超 500就強制走一遍 Change Tracking 方案——不是因為它多酷而是因為上線后半夜接到電話說“待辦沒刷新”的概率從 30% 降到了 0%。希望幫到你。本文還有配套的精品資源點擊獲取