ASP.NET MVC工作流源码实战:表单设计器与流程引擎避坑

发布时间:2026/10/24 2:42:33
ASP.NET MVC工作流源码实战:表单设计器与流程引擎避坑

简介:这是一套基于 ASP.NET MVC5 开发的工作流管理系统源码,面向从事 OA 办公、CRM 客户关系及 HR 人事管理等系统开发的开发者与学习者,也可作为熟悉 MVC 架构、流程引擎与表单驱动开发的实践参考。压缩包共约 2000 个文件、60.93MB,包含 cshtml 视图、C# 源码、JavaScript 脚本、CSS 样式、DLL 程序集、aspx 页面、config 配置及 SQL 数据库文件等,覆盖可视化流程设计器、表单设计器、后台逻辑与前端资源等主要模块。已有 1099 人学习,说明其作为完整案例具备一定参考热度。读者可从中了解工作流引擎的流程建模、表单配置、页面交互与数据持久化实现,并对照 MVC5 项目结构、权限与业务分层思路进行学习,适合需要积累 OA、CRM、HR 类系统源码经验的中级开发者参考。

1. 拿到一套 ASP.NET MVC 工作流源码,先判断它值不值得跑

OA 项目里最容易被低估的就是工作流。需求方张口一句「加个审批」,真做起来是表单设计、节点流转、条件分支、字段权限四件事缠在一起。手上这套 ASP.NET MVC 工作流管理系统源码,把自定义表单设计器、流程引擎、组织架构和 Bootstrap 前端打在一起,是典型的 OA Workflow 落地骨架:ASP.NET MVC 分层、C# 后端、SQL Server 存流程定义与实例,前端用 Bootstrap 加 JS 做拖拽建模。

它适合两类人:一类是接中小型 OA、报销、请假、合同审批的开发者,需要一个能改能扩的底座,而不是从零画状态机;另一类是公司内部要一套自己能维护的审批系统,不想被外部服务锁死字段和流程。不适合想开箱即用、连数据库都不愿意碰的人——这套东西跑起来容易,改对不容易。

2. 把源码跑起来:环境对齐、数据库还原与首次登录

很多人卡在这一步不是技术难,而是顺序错。先编译再建库,或者先建库再改连接字符串,都会在登录页给你一个 500 白页。我一般按「环境 → 库 → 站点 → 初始化数据」四步走,每步都能独立验证。

2.1 依赖清单与目录结构对照

运行环境按 .NET Framework 4.6 以上准备,IIS 用集成模式应用池,数据库 SQL Server 2012 以上。Visual Studio 打开解决方案先做一次「重新生成」,如果编译就报缺程序集,先别急着往下走——大概率是 NuGet 包没还原,或者引用了本地路径的第三方 DLL。

常见的分层目录大致是这样,先认路再改代码:

目录职责改动频率
App_Start路由、过滤器、依赖注入注册低
ControllersMVC 控制器,含表单与流程接口中
Models实体、视图模型、EF 映射中
ViewsRazor 视图,Bootstrap 布局页高
Content / ScriptsBootstrap、jQuery、表单设计器 JS高
流程引擎目录节点推进、条件解析、任务分配低但关键

流程引擎那一层平时不用动,但只要动,就必须跑回归——后面第 6 章会说怎么跑。先把「能登录」这件事做完,再谈改流程。

2.2 还原数据库与写连接字符串

拿到 .bak 先看逻辑文件名,别直接 RESTORE,否则 MOVE 对不上会报错。下面这段是我习惯的两步走:

-- 第一步:只读备份头,确认逻辑文件名和恢复链 RESTORE FILELISTONLY FROM DISK = 'D:\bak\oa_wf.bak'; GO -- 第二步:按上一步查到的逻辑名做 MOVE,避免还原到默认实例目录 RESTORE DATABASE OaWorkflow FROM DISK = 'D:\bak\oa_wf.bak' WITH MOVE 'oa_wf' TO 'D:\data\OaWorkflow.mdf', MOVE 'oa_wf_log' TO 'D:\data\OaWorkflow_log.ldf', REPLACE, RECOVERY; GO

还原完之后改web.config,这一步有个血泪经验:尽量用 SQL 账号,不要用Integrated Security=True。本地调试时你的 Windows 账号是库的 sysadmin,发布到 IIS 之后应用池标识换成了IIS APPPOOL\xxx,十有八九没有库权限,表现就是「本地好好的,线上一登录就 500」。

<connectionStrings> <!-- 用 SQL 账号,避免应用池标识与库权限不一致;密码别写死在仓库里 --> <add name="DefaultConnection" connectionString="Data Source=127.0.0.1;Initial Catalog=OaWorkflow;User ID=oa_app;Password=******;MultipleActiveResultSets=True;App=OaWeb" providerName="System.Data.SqlClient" /> </connectionStrings>

MultipleActiveResultSets=True在 EF 里做嵌套查询时会用到,缺了它偶尔报「已有打开的与此命令相关联的 DataReader」。App=OaWeb是我自己的习惯,出问题时在 SQL 的会话列表里一眼能认出是哪套站点的连接。

2.3 应用池、静态资源与上传目录

站点建好之后,应用池三件事要确认:.NET CLR 版本和项目目标框架一致、托管管道模式用「集成」、闲置超时设 0。最后这条很多人忽略——默认 20 分钟回收,第二天早上第一个访问的人要等十几秒编译,看起来像卡死。

上传目录给写权限,用命令行比较稳:

# 上传目录必须给应用池标识写权限,否则附件保存报 500 或落 0 字节文件 $dir = 'D:\sites\oa\UploadFiles' icacls $dir /grant "IIS_IUSRS:(OI)(CI)M" /T

另外把customErrors设成RemoteOnly并打开日志落盘。发布初期最怕的就是线上只给一个「/ 应用程序中的服务器错误」,连异常栈都看不到,排查全靠猜。

2.4 首次登录后必须做的三件事

第一,用默认管理员账号进系统,先把组织架构和岗位补上。工作流的审批人规则大量依赖部门、岗位、上下级关系,架构是空的,流程发起时会直接报「找不到审批人」。

第二,检查菜单和按钮权限表有没有初始化数据。这类系统通常在登录时按角色加载菜单,权限表空着就会出现「能登录但左边一片空白」,新手很容易误判成前端样式问题。

第三,跑一条最短的流程验证闭环:发起一张请假单 → 生成待办 → 审批通过 → 实例结束。这条链路能跑通,说明表单渲染、任务生成、状态推进三段都正常,后面改代码才有基线。

提示:这一步建议截个图存档。后面每一次改表单或改节点,都拿这条基线流程做对比,比看日志快得多。

3. 自定义表单设计器:控件怎么变成 JSON,再变成页面

这套源码里最有价值的其实是 Form design 那部分。工作流的流程可以慢点改,但表单字段几乎每周都在加。设计器的思路是「拖拽生成结构 → 存 JSON → 运行态按 JSON 渲染 → 数据按 JSON 回写」,理解了这条链,改字段就是改数据,不用动数据库。

3.1 表单为什么存 JSON 而不建物理表

建物理表的做法是「一个表单一张表」,字段改了就要 ALTER TABLE,历史数据还要迁移。审批单这种业务恰好是字段变更最频繁的场景,物理表方案在第三个需求变更时就会开始痛苦。

JSON 方案的好处是字段增删只改定义,不动结构;代价是查询统计变难——想按「请假天数」做汇总,不能直接写WHERE。所以常见做法是折中:定义和数据存 JSON,同时把少量需要检索、排序的字段抽到索引列,写入时同步一份。

方案字段变更成本统计查询适合场景
一表单一张物理表高,需 DDL 与迁移直接写 SQL表单形态极稳定的系统
JSON 存定义与数据低,改 JSON 即可需抽索引列或解析字段频繁调整的 OA 审批
混合:JSON + 索引列中常用字段可查大多数工作流系统

这套源码走的是混合路线,所以看代码时会发现同一份数据存了两处,别以为是冗余 bug。

3.2 一个字段要存哪些元信息

设计器拖出来的每个控件,落到 JSON 里至少要有五个信息:唯一标识、数据键、显示标签、控件类型、布局列宽。缺一个后面就难受。

{ "formKey": "leave_apply", "version": 3, "layout": { "labelWidth": 110, "columns": 12 }, "fields": [ { "id": "f_leave_days", "name": "leaveDays", "label": "请假天数", "type": "number", "required": true, "col": 6, "props": { "min": 0.5, "step": 0.5, "unit": "天" }, "rules": [{ "type": "max", "value": 30, "msg": "超过 30 天需走特殊审批" }] }, { "id": "f_reason", "name": "reason", "label": "请假事由", "type": "textarea", "required": true, "col": 12, "props": { "rows": 4, "maxlength": 500 }, "rules": [] } ] }

name是数据键,一旦有历史数据就绝不能改,这是后面第 5 章第一个坑的根源;id只用于前端定位 DOM;type决定用哪个渲染器;rules要前后端共用,别只写在前端做样子。

3.3 运行态渲染:一份 JSON 走同一套渲染函数

渲染层用 JS 拼 DOM,最容易翻车的是把用户填的内容直接塞进 HTML。字段值里只要有一个引号,页面结构就断了;更糟的是能注入脚本。

// 控件类型走白名单,未知类型降级为只读文本,避免脏 JSON 把整个页面搞崩 const RENDERERS = { text: (f, v) => `<input class="form-control" name="${f.name}" value="${esc(v)}" maxlength="${f.props.maxlength || 200}">`, number: (f, v) => `<input type="number" class="form-control" name="${f.name}" value="${esc(v)}" step="${f.props.step || 1}">`, textarea: (f, v) => `<textarea class="form-control" name="${f.name}" rows="${f.props.rows || 3}">${esc(v)}</textarea>`, date: (f, v) => `<input type="date" class="form-control" name="${f.name}" value="${esc(v)}">` }; // 所有值出口统一转义,这是最低成本的后悔药 function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&amp;').replace(/</g, '&lt;') .replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } function renderForm(schema, values) { return schema.fields.map(f => { const render = RENDERERS[f.type] || ((field, v) => `<span class="form-control-static">${esc(v)}</span>`); const req = f.required ? '<span class="text-danger">*</span>' : ''; return `<div class="col-md-${f.col || 12} form-group"> <label class="control-label">${esc(f.label)}${req}</label> ${render(f, values[f.name])} </div>`; }).join(''); }

逻辑上就三步:按type找渲染器、按col分配 Bootstrap 栅格、按required补星号。前端渲染只解决「看得见」,真正的校验必须在服务端重跑一遍,否则改个 DOM 就能绕过必填。

// schema 按实例锁定的 FormVersionId 取,保证校验规则和渲染时是同一版 public IEnumerable<string> Validate(FormSchema schema, IDictionary<string, string> values) { foreach (var f in schema.Fields) { values.TryGetValue(f.Name, out var raw); if (f.Required && string.IsNullOrWhiteSpace(raw)) yield return $"{f.Label}不能为空"; if (f.Type == "number" && !string.IsNullOrWhiteSpace(raw)) { if (!decimal.TryParse(raw, out var n)) { yield return $"{f.Label}必须是数字"; continue; } var max = f.Rules.FirstOrDefault(r => r.Type == "max"); if (max != null && n > Convert.ToDecimal(max.Value)) yield return max.Msg; } } }

注意这里用的是实例上锁定的表单版本,而不是「当前最新版」。这个差别在演示环境里看不出来,一上线就会变成事故。

3.4 字段级权限:同一个表单在不同节点长得不一样

真实的审批场景里,申请人填的金额到了财务节点可能只读,到了总监节点又要可编辑。做法是在节点和字段之间建一张权限映射,值只有三种:隐藏、只读、可编辑。渲染时先按节点过滤字段列表,提交时再按白名单过滤一遍数据。

// mode: 0=隐藏 1=只读 2=可编辑;提交时只收可编辑字段,防止前端被改包 public IDictionary<string, string> ApplyFieldAuth(int nodeId, IDictionary<string, string> post) { var auth = _fieldAuthRepo.GetByNode(nodeId); // 走缓存,别每次提交都查库 var result = new Dictionary<string, string>(); foreach (var kv in post) { if (!auth.TryGetValue(kv.Key, out var mode)) continue; // 未配置默认拒绝 if (mode == 2) result[kv.Key] = kv.Value; } return result; }

「未配置默认拒绝」这条是我踩过坑之后改的。早期写成默认允许,结果新增字段忘了配权限,所有人都能改,审计上完全说不清。

4. 流程引擎:节点定义、条件分支与会签的推进逻辑

表单解决「填什么」,引擎解决「谁来审、什么时候往下走」。这套源码的引擎本质是一个带条件的状态机,核心就三张表加一个推进方法。看懂它之后,加节点类型比加表单字段难不了多少。

4.1 实例、任务、节点三张表的职责

表关键字段说明
WF_FlowDefFlowId, FlowKey, Version, Status同 key 多版本并存,新单走最新版,老单锁旧版
WF_NodeDefNodeId, FlowId, NodeType, ApproverRule, PassRate节点类型、审批人规则、会签通过率
WF_InstanceInstanceId, FlowId, FormVersionId, CurrentNodeId, Status运行中的单据,FormVersionId 是防坑关键
WF_TaskTaskId, InstanceId, NodeId, HandlerId, Status, RowVersion待办任务,RowVersion 用于并发控制
WF_LogLogId, InstanceId, NodeId, Action, Remark, CreateTime流转日志,排查全靠它

建任务表时把并发标记和待办索引一起加上,后面省很多事:

CREATE TABLE WF_Task ( TaskId BIGINT IDENTITY(1,1) PRIMARY KEY, InstanceId BIGINT NOT NULL, NodeId INT NOT NULL, HandlerId INT NOT NULL, Status TINYINT NOT NULL DEFAULT 0, -- 0待办 1同意 2驳回 3已转交 4作废 RowVersion ROWVERSION NOT NULL, -- 乐观并发标记 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), FinishTime DATETIME NULL ); -- 待办列表按“处理人+状态”查,做成覆盖索引避免回表 CREATE INDEX IX_Task_Handler ON WF_Task(HandlerId, Status) INCLUDE (InstanceId, NodeId);

引擎推进的入口通常只有一个方法:MoveToNext(instanceId, nodeId)。它做四件事——找当前节点的出边、按条件挑一条、生成下一节点的任务、写日志。所有节点类型最终都收口到这里,改起来才有下限。

4.2 条件表达式:别用动态求值,也别让业务写代码

分支条件一般存成字符串,比如leaveDays > 3 && dept == "研发部"。最省事的做法是扔给动态执行引擎求值,但那等于把服务器交给表单设计者,任何一次误配都可能变成执行任意代码。

我一般自己写一个只认比较运算的小解析器,够覆盖绝大多数审批分支:

// 只支持 "字段 运算符 值" 用 && / || 拼接;变量只从表单值里取,运算符白名单固定 public bool Evaluate(string expr, IDictionary<string, object> ctx) { foreach (var orPart in expr.Split(new[] { "||" }, StringSplitOptions.None)) { var allMatch = true; foreach (var andPart in orPart.Split(new[] { "&&" }, StringSplitOptions.None)) { var m = Regex.Match(andPart.Trim(), @"^(\w+)\s*(>=|<=|==|!=|>|<)\s*'?([^']*)'?$"); if (!m.Success) { allMatch = false; break; } var key = m.Groups[1].Value; if (!ctx.ContainsKey(key)) { allMatch = false; break; } // 未知字段判假,不抛异常 var left = Convert.ToString(ctx[key]) ?? ""; var op = m.Groups[2].Value; var right = m.Groups[3].Value; bool hit; if (decimal.TryParse(left, out var ld) && decimal.TryParse(right, out var rd)) { hit = op switch { ">" => ld > rd, ">=" => ld >= rd, "<" => ld < rd, "<=" => ld <= rd, "==" => ld == rd, _ => ld != rd }; } else { hit = op == "==" ? left == right : op == "!=" && left != right; } if (!hit) { allMatch = false; break; } } if (allMatch) return true; } return false; }

两个细节值得说:数字和字符串分别比较,避免"10" < "9"这种字符串比较的玄学结果;未知字段直接判假而不是抛异常,因为条件里引用了已被删除的字段很常见,抛异常会让整张单据卡死,判假至少还能走默认分支。老项目如果是 C# 7 以下,把switch表达式改回switch-case就行。

4.3 会签、或签与退回:状态机怎么写才不出错

会签是同一个节点生成多条任务,全部处理完(或达到通过率)才往下走。或签是多人候选、谁先处理算谁的。两者最大的区别在于并发写法:会签要计数,或签要抢占。

或签用一条带条件的 UPDATE 就能解决,别先查再改:

-- 只有把状态从 0 改成 1 的那次更新才算“我处理了” UPDATE WF_Task SET Status = 1, HandlerId = @uid, FinishTime = GETDATE() WHERE TaskId = @taskId AND Status = 0; -- 检查受影响行数:0 表示别人抢先处理了,直接提示“该任务已被处理”返回即可

会签则是计数判断,同时要处理「通过率不足」的情况:

// 会签节点:达到通过率才推进,否则按驳回结束 public void CompleteCounterSign(long instanceId, int nodeId) { var tasks = _taskRepo.GetByNode(instanceId, nodeId); var total = tasks.Count; var done = tasks.Count(t => t.Status == 1); var passRate = _nodeRepo.Get(nodeId).PassRate; // 0.5 表示过半通过 if (done < total && (decimal)done / total < passRate) return; // 还没到阈值,继续等 if ((decimal)done / total < passRate) _instanceRepo.Close(instanceId, InstanceStatus.Rejected); else _engine.MoveToNext(instanceId, nodeId); }

退回比审批更容易出问题。退回不是「把当前任务状态改成 2」就完事,它要做三件事:作废当前节点剩余任务、清掉下游已生成但未处理的任务、把实例的CurrentNodeId指回目标节点。少做一件,待办列表就会出现重复任务,用户点进去还会发现单据状态对不上。

5. 避坑与排查:五个真实翻车现场

下面这五条基本覆盖了这套源码上手后 80% 的「怎么突然不行了」。每条我都按现象、原因、解决写,照着对能省不少时间。

5.1 表单加了个字段,在途的单子全打不开

现象:设计器里新增一个必填字段,保存后新单正常,之前发起还没审完的单子一打开就报错或白屏。

原因:运行态渲染用的是「当前最新版表单定义」,而实例里存的数据还是旧结构。新字段在旧数据里不存在,取值拿到 null,必填校验直接抛,或者前端渲染器读props时空引用。

解决:实例创建时把FormVersionId写死,渲染和校验都按这个版本取定义;设计器每次保存生成新版本而不是覆盖旧版本。存量数据要么按版本做兼容取值,要么写一次数据迁移脚本把新字段补默认值。

5.2 两个人同时点通过,流程被推进了两步

现象:会签节点三个人审,日志里出现了两次「流转到下一节点」,下一节点的待办重复生成两份。

原因:典型的读改写竞态。两个请求几乎同时读到「已完成 2/3」,各自加一后都判断达到通过率,于是都调了一次推进方法。

解决:推进方法必须在一个事务里做,并且对实例行加锁(SELECT ... WITH (UPDLOCK, ROWLOCK))或者用RowVersion做乐观并发,更新失败就重试一次读最新状态。会签完成的判定最好也落成一条带条件的 UPDATE,用受影响行数决定谁有权推进。

5.3 退回一次之后,待办列表出现两条一模一样的任务

现象:审批人点了退回,发起人改完重新提交,结果审批人待办里出现两条相同单据的任务,点其中一条处理完,另一条还在。

原因:退回时只改了当前任务状态,没有作废该节点剩余任务和下游任务;重新提交时又按节点重新生成了一遍。

解决:退回做成一个原子操作——作废当前节点所有未处理任务、作废下游节点任务、写退回日志、重置CurrentNodeId,四步同事务。另外给退回次数加个上限字段,超过阈值强制走上级审批,避免无限来回。

5.4 部署到服务器后,设计器拖不动、页面样式全乱

现象:本地一切正常,发布到 IIS 之后 Bootstrap 样式丢失,表单设计器拖拽没反应,控制台一堆 404。

原因:九成是静态资源路径问题。视图里写了硬编码的/Content/...,站点部署在虚拟目录下就全断;或者服务器没装对应 MIME 类型,字体文件被拒;再或者是应用池用了经典模式,路由接管不到。

解决:视图里统一用@Url.Content("~/Content/...")生成路径;检查web.config的system.webServer/modules里runAllManagedModulesForAllRequests;应用池切集成模式。顺手把附件上传大小也调了,maxRequestLength和maxAllowedContentLength要一起改,只改一个照样报错。

5.5 新加的人登录进来,一个菜单都看不到

现象:后台配好了角色和菜单权限,新用户登录后左侧菜单空白,重启站点又好了。

原因:权限数据被缓存在进程内,缓存键只按角色 ID 拼,角色权限变更后没有失效;或者登录时把权限写进了 Session,改了角色但会话没刷新。

解决:缓存键带上「角色 ID + 权限版本号」,角色或菜单一改就把版本号加一;提供手动清缓存的入口。排查时先看日志里加载菜单的 SQL 有没有执行,如果压根没查库,那一定是缓存命中,不用怀疑数据。

6. 跑通之后:用状态快照做回归,别靠人肉点流程

流程引擎的改动有个特点——改一行,影响的是所有在跑的流程。靠手工点单验证,覆盖面太小,而且人总会漏。我习惯在改引擎前后各跑一遍状态快照用例:构造实例、按顺序推进节点、断言每一步的CurrentNodeId和任务数量。

// 最简回归:不依赖浏览器,直接驱动引擎,断言流转结果 [TestMethod] public void 请假三天以内_应直接到主管节点() { var ctx = new Dictionary<string, object> { ["leaveDays"] = 2, ["dept"] = "研发部" }; var instanceId = _engine.Start("leave_apply", ctx, starterId: 1001); // 断言 1:条件分支应命中主管审批,而不是总监审批 var inst = _instanceRepo.Get(instanceId); Assert.AreEqual("node_manager", _nodeRepo.Get(inst.CurrentNodeId).NodeKey); // 断言 2:任务只生成一条,且处理人是发起人的直属上级 var tasks = _taskRepo.GetByInstance(instanceId); Assert.AreEqual(1, tasks.Count); Assert.AreEqual(_orgRepo.GetLeader(1001), tasks[0].HandlerId); // 断言 3:走完审批后实例状态闭合,不留待办 _engine.Approve(tasks[0].TaskId, 2001, "同意"); Assert.AreEqual(InstanceStatus.Finished, _instanceRepo.Get(instanceId).Status); Assert.AreEqual(0, _taskRepo.GetPending(instanceId).Count); }

这套用例的价值不在于断言写得多漂亮,而在于它把「流程应该怎么走」变成了可重复执行的东西。我一般会准备一张覆盖表,按节点类型和边界值各来一条:

用例覆盖点关键断言
天数 2 天条件分支默认路径落到主管节点
天数 31 天阈值边界 + 特殊审批落到总监节点并生成抄送
三人会签 2 通过通过率判定达到 0.5 才推进
退回后重新提交任务清理待办数量不重复
同一任务重复审批并发幂等第二次返回失败而非报错

改引擎之前先让这五条全绿,改完再跑一遍。哪条红了,基本能立刻定位到是分支判定、会签计数还是任务清理出了问题,比在页面上点半小时快得多。另外提一句,用例里的流程 key 和节点 key 最好用常量而不是魔法字符串,流程定义一多,拼错一个字母能查一下午。

从那以后我每次动流程引擎或者改表单结构,都会先把这组快照用例跑一遍再发版,哪怕是「只改了一个字段的必填」。流程这东西出错不会当场报错,它会在三天后某个领导的审批页上炸给你看。希望帮到你。

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