简介:这是一套基于 ASP.NET 与 SQL Server 的 OA 自动办公及审批流源码,面向需要快速搭建企业内部协同系统的开发人员。系统采用三层架构,界面参照猫扑的标签式操作,覆盖个人事务、公司通知、内部邮件、系统设置以及审批流转全流程,支持公文模板、审批类别/节点/流程/归档设置。包内共 2069 个文件,资源包大小 8.86MB,以 js、gif、cs、aspx、css、html、png 等为主,其中 js 承担大量前端交互,cs/aspx 体现后端业务逻辑,png/gif 用于界面资源,并随包提供 mdf/ldf 数据库文件与 Office 在线编辑相关组件。目前资源已有 1171 人学习下载。通过源码可掌握完整的三层架构项目组织、审批流状态机设计、DsoFramer 在线编辑留痕、大文件上传进度条、CKEditor 集成及 VML 画图等典型 ASP.NET 功能实现,也便于二次开发时按模块复用代码,适合有 VS2008/SQL2005 基础的中级开发者。
1. ASP.NET 自动办公系统(OA+审批流)源码:能跑通登录页只是开始
收到一套 ASP.NET 自动办公系统(OA+审批流)源码时,多数人的第一反应是配好数据库、按 F5 跑通登录页,看到菜单出来就松了一口气。真正接手过 OA 系统的人都知道,用户、角色、菜单这些 CRUD 只是热身,最考验功底的是审批流:一张申请单从发起、会签、逐级审批到驳回、撤回、重新提交,状态机稍微没设计好,线上就会出“同一张单子被审批两次”这类事故。
这套源码的核心价值在于同时覆盖了两件事:一是 OA 基础的组织、用户、权限与待办中心,二是审批流从流程定义、实例化到任务驱动、状态回滚的完整链路。适合正在做内部办公系统的开发,也适合准备把老 .NET Framework 项目往 ASP.NET Core 迁移的团队参考。把这两条线理清楚,这套源码才算真正能改、能跑、能上生产。
2. 选型先于动手:OA+审批流在 ASP.NET 下的工程结构与数据模型
2.1 自研状态机、开源工作流引擎还是继续用老 WF?
拿到源码第一步不是打开 Visual Studio 看代码,而是先弄清楚审批流这块用的什么模型。市面上的 OA 源码实现方式大致分三类:第一类自研状态机,流程定义、实例、任务三张表自己管,流转逻辑写在服务层;第二类集成开源工作流引擎,比如 Elsa Workflow、Workflow Core;第三类是 .NET Framework 时代留下的 Windows Workflow Foundation(WF)工程。
我经手过的内部 OA 源码,最终留在生产环境的绝大部分是自研状态机。原因很现实:OA 的审批流看起来复杂,拆解下来就是顺序审批、会签、条件分支三种模式的排列组合,状态也就 Running、Approved、Rejected、Withdrawn、Terminated 这几个。固定场景用通用工作流引擎反而要付出额外学习成本,引擎的持久化机制、节点扩展点、表单变量绑定都要重新适配,出了问题还不好查——引擎在你面前就是个黑匣子。
Elsa Workflow 在 ASP.NET Core 下确实能做可视化流程设计器,适合流程经常变、业务人员要自己拖节点的场景。但把它嵌进 OA 源码里,审批人动态计算、表单字段和流程变量的绑定都要二次开发,落地周期不比自研短。如果只是公司内部行政、人事、财务三类审批,我一般建议保留源码里的自研状态机,把节点表设计成可配置的,就够用了。下面这张表是选型时的对比:
| 维度 | 自研简单状态机 | 开源工作流引擎 | WF 遗留工程 |
|---|---|---|---|
| 流程变化频率 | 低到中 | 高 | 几乎不变 |
| 开发成本 | 低 | 中高 | 高(且 Core 不支持) |
| 并发控制手段 | 自己写条件更新 | 引擎内建 | 老式锁方案 |
| 出问题排查难度 | 低,逻辑在自己代码里 | 中,要翻引擎源码 | 高 |
| OA 源码场景推荐度 | 高 | 中 | 不推荐 |
选型时还要看团队构成。如果团队里都是熟悉业务但不太啃得动引擎源码的开发,自研状态机最稳;如果公司未来要做 BPM 平台、流程种类会膨胀到几十种,那在前期就引入开源引擎,否则后面自研的节点表会被各种特殊规则撑爆。
2.2 把工程拆成五个层:Web 不碰 DbContext,状态机只活在 Domain
拿到源码先别着急加功能,把项目结构理顺。一套能长期维护的 ASP.NET Core OA 源码,建议至少拆成五个工程。我在整理旧项目时经常看到所有代码堆在一个 Web 项目里,Controller 里直接 new DbContext、写 SQL,这种结构改到第三个月就没人敢动了。合理的分层是这样的:
OA.sln ├─ src/ │ ├─ OA.Web/ // 控制器、视图、静态资源、JWT 配置 │ ├─ OA.Application/ // 审批服务、待办服务、DTO、事务编排 │ ├─ OA.Domain/ // 实体、枚举、状态机常量、领域接口 │ ├─ OA.Infrastructure/ // EF Core DbContext、仓储实现、文件存储 │ └─ OA.Shared/ // 通用返回结果、工具类、日志封装 └─ tests/ └─ OA.Application.Tests/ // 审批流的单元测试与状态流转测试这套分层的核心约束有三条:Web 只做参数绑定和结果返回,不能直接 new DbContext;Application 层编排业务事务,审批流的 Approve、Reject 这些入口都在这层;Domain 层只放实体和纯逻辑,不引用 EF Core 的包。实际改源码时最容易翻车的,是把状态枚举散落在各个 Controller 里,今天这个地方写 "1" 表示通过,明天那个地方写 "Approved",状态一多就崩。
我一般会在 Domain 里放一个 WorkflowStatus 静态类,把所有状态和允许的事件集中管理,再加上一个记录状态流转的方法。这样不管是 Controller 还是定时任务触发审批操作,都走同一套校验,不会出现“这里改了状态、那里不知道”的尴尬。
2.3 四张核心表加一张扩展表:流程定义、节点、实例、任务、表单值
数据库设计是 OA 源码里最值得先看的部分。流程定义和流程实例是两层:定义是模板,实例是模板跑起来之后的一条具体申请。如果源码里只有一张“审批表”,那基本就是写死的流程,改流程就要改代码,这种源码的扩展性很差。一张能支撑起 OA+审批流的表结构,至少要包含下面这五张表:
CREATE TABLE WorkflowDefinition ( Id INT IDENTITY PRIMARY KEY, Code NVARCHAR(50) NOT NULL, -- 流程编码,如 Leave_Request Name NVARCHAR(100) NOT NULL, -- 流程名称,如请假审批 IsActive BIT DEFAULT 1, CreateTime DATETIME DEFAULT GETDATE() ); CREATE TABLE WorkflowNode ( Id INT IDENTITY PRIMARY KEY, DefinitionId INT NOT NULL REFERENCES WorkflowDefinition(Id), NodeCode NVARCHAR(50) NOT NULL, -- Start / Approval / End NodeType NVARCHAR(20) NOT NULL, -- Start / Approval / CounterSign / End ApproverType NVARCHAR(20) NOT NULL, -- User / Role / DeptLeader ApproverValue NVARCHAR(200), -- 具体用户 ID 或角色编码 RejectTo NVARCHAR(20) DEFAULT 'Previous', -- Previous / Initiator ConditionExpr NVARCHAR(500), -- 条件分支表达式 SortOrder INT NOT NULL ); CREATE TABLE WorkflowInstance ( Id INT IDENTITY PRIMARY KEY, DefinitionId INT NOT NULL REFERENCES WorkflowDefinition(Id), Title NVARCHAR(200) NOT NULL, InitiatorId INT NOT NULL, Status NVARCHAR(20) NOT NULL, -- Running / Approved / Rejected CurrentNodeId INT NULL, CreateTime DATETIME DEFAULT GETDATE(), FinishTime DATETIME NULL ); CREATE TABLE WorkflowTask ( Id INT IDENTITY PRIMARY KEY, InstanceId INT NOT NULL REFERENCES WorkflowInstance(Id), NodeId INT NOT NULL REFERENCES WorkflowNode(Id), AssigneeId INT NOT NULL, -- 当前审批人 Status NVARCHAR(20) DEFAULT 'Pending', -- Pending / Approved / Rejected Comment NVARCHAR(500), HandleTime DATETIME NULL ); CREATE TABLE FormValue ( Id INT IDENTITY PRIMARY KEY, InstanceId INT NOT NULL REFERENCES WorkflowInstance(Id), DataJson NVARCHAR(MAX) NOT NULL, -- 表单数据统一存 JSON CreateTime DATETIME DEFAULT GETDATE() );四张核心表的关系很直接:WorkflowDefinition 和 WorkflowNode 管模板,决定一张申请单走几个节点、每步谁来审;WorkflowInstance 是一条具体的申请记录;WorkflowTask 是实例上的待办任务,待办中心查的就是这张表。审批历史不用单独建表,已处理的 WorkflowTask 直接当历史记录用,省一次关联查询。
表单值单独存 JSON 是 OA 源码里很常见的取舍。请假单、报销单、用章申请字段完全不同,为每类申请建一张表的话,流程引擎的维护成本会爆炸。代价是按字段统计(比如查所有金额超过一万的报销单)不能走普通 SQL,要么接受全量解析,要么额外建一张字段索引表。这点在接手源码时就要和业务方对齐,别等上线后才改存储方案。
3. 从用户登录到待办中心:OA 基础模块的 ASP.NET Core 落地
3.1 基于 Identity + JWT 的登录接口:令牌怎么发、过期怎么续
老 OA 源码里最常见的认证方案是 Cookie,.NET Framework 时代尤其多。但 ASP.NET Core 项目如果前端要同时服务 PC 和手机端,我一般换成 JWT——优点是后端不用管 Session 存储,手机端拿到令牌放 Header 里就能用。下面是一个最小可用的登录接口:
[HttpPost("api/auth/login")] public async Task<IActionResult> Login(LoginDto dto) { var user = await _userManager.FindByNameAsync(dto.UserName); if (user == null || !await _userManager.CheckPasswordAsync(user, dto.Password)) return Unauthorized(new { message = "用户名或密码错误" }); var claims = new List<Claim> { new Claim(ClaimTypes.Name, user.UserName), new Claim("UserId", user.Id.ToString()), new Claim("DeptId", user.DeptId.ToString()) // 部门维度后面审批要用 }; foreach (var role in await _userManager.GetRolesAsync(user)) claims.Add(new Claim(ClaimTypes.Role, role)); var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!)); var token = new JwtSecurityToken( issuer: _config["Jwt:Issuer"], audience: _config["Jwt:Audience"], claims: claims, expires: DateTime.Now.AddHours(2), signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)); return Ok(new { token = new JwtSecurityTokenHandler().WriteToken(token), userId = user.Id, displayName = user.DisplayName }); }这段逻辑分三步:先验用户名密码,再把用户身份、部门、角色塞进 Claim,最后用 HmacSha256 签名生成令牌。注意 Jwt:Key 在配置里至少要 32 个字符,否则运行时直接抛异常;expires 设 2 小时是内部 OA 的折中值,太短让员工频繁重登,太长又有令牌泄露风险。移动端接入时还要补一个 refresh token 接口,用长期有效的刷新令牌换新 JWT,避免过期后强制重输密码。老源码如果没做刷新令牌,二次开发时建议优先补上,这是手机端 OA 体验的分水岭。
3.2 RBAC 权限:用 Policy 替代 Controller 里写死的角色判断
很多 OA 源码里权限判断长这样:if (User.IsInRole("Admin"))。角色一多,代码里全是 if 分支,而且“部门经理”这个角色一旦要细分出“人事部经理”“财务部经理”,改起来就是全局搜索替换。换成基于 Permission 的 Policy 后,控制器只声明需要什么权限,判断逻辑集中在一个 Handler 里,角色和权限的映射关系放到数据库维护。
public class PermissionRequirement : IAuthorizationRequirement { public string PermissionName { get; } public PermissionRequirement(string permissionName) => PermissionName = permissionName; } public class PermissionHandler : AuthorizationHandler<PermissionRequirement> { private readonly IPermissionService _permissionService; public PermissionHandler(IPermissionService permissionService) => _permissionService = permissionService; protected override async Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionRequirement requirement) { var userId = context.User.FindFirst("UserId")?.Value; if (userId == null) return; if (await _permissionService.HasPermissionAsync(int.Parse(userId), requirement.PermissionName)) context.Succeed(requirement); } }使用方式是在 Action 上写[Authorize(Policy = "Leave.Approve")],这样审批权限和具体角色解耦。将来把请假审批权从部门经理挪到人事专员,只需要改数据库里的角色-权限映射表,不用动代码。注册 Policy 时把 PermissionHandler 注入 DI,并在 StartUp 里配置services.AddSingleton<IAuthorizationHandler, PermissionHandler>()。
这里有个性能坑:Handler 里每次授权都查一次数据库。OA 系统里菜单权限、按钮权限的校验频率很高,建议给权限集合加内存缓存,角色权限变更时手动清缓存。这个点不处理,权限多的系统上线一个月后接口延迟会明显上升,到时候排查起来还以为是数据库慢。
3.3 待办中心:我的申请、待我审批、我已审批三条 SQL
待办中心是 OA 的主页面,一般三个 Tab:我发起的、待我审批的、我已审批的。核心是 WorkflowTask 表的查询,别在 ORM 里把全表拉出来再内存过滤,数据量过万就卡。下面是三个视图的查询写法,直接可以在源码里替换对应接口:
-- 我的申请:查流程实例,关联流程定义取流程名 SELECT wi.Id AS InstanceId, wi.Title, wd.Name AS WorkflowName, wi.CreateTime, wi.Status FROM WorkflowInstance wi JOIN WorkflowDefinition wd ON wd.Id = wi.DefinitionId WHERE wi.InitiatorId = @userId ORDER BY wi.CreateTime DESC; -- 待我审批:当前节点指派给我的 Pending 任务 SELECT wt.Id AS TaskId, wi.Title, wd.Name AS WorkflowName, wt.CreateTime, wt.NodeId FROM WorkflowTask wt JOIN WorkflowInstance wi ON wi.Id = wt.InstanceId JOIN WorkflowDefinition wd ON wd.Id = wi.DefinitionId WHERE wt.AssigneeId = @userId AND wt.Status = 'Pending' ORDER BY wt.CreateTime DESC; -- 我已审批:取 Status 非 Pending 的历史任务 SELECT wt.Id AS TaskId, wi.Title, wd.Name AS WorkflowName, wt.Comment, wt.HandleTime, wt.Status FROM WorkflowTask wt JOIN WorkflowInstance wi ON wi.Id = wt.InstanceId JOIN WorkflowDefinition wd ON wd.Id = wi.DefinitionId WHERE wt.AssigneeId = @userId AND wt.Status <> 'Pending' ORDER BY wt.HandleTime DESC;三条 SQL 的共同点是以当前用户 ID 为过滤起点,join 到流程定义拿名称。已审批的 Tab 里要特别注意 Status 的取值:Withdrawn(撤回)和 Terminated(终止)也属于非 Pending,产品上要决定撤回的单据是否显示在“已审批”列表里。我见过有源码直接写死Status = 'Approved'或'Rejected',撤回的单据在三个 Tab 里都看不到,用户以为单子丢了,这种问题排查起来最隐蔽。
4. 审批流核心链路:从发起申请到审批完成的代码实现
4.1 发起申请:流程实例化与第一张审批任务的生成
发起申请是审批流的入口。用户填完表单点提交,后端要做两件事:把表单数据存起来,按流程定义生成一个 Running 状态的实例和第一张待办任务。这段逻辑必须放在同一个事务里,否则实例建好了、待办没生成,申请单就永远无人处理。
public async Task<int> StartAsync(int initiatorId, string workflowCode, string title, Dictionary<string, object> formData) { var definition = await _db.WorkflowDefinitions .AsNoTracking() .Include(d => d.Nodes) .FirstAsync(d => d.Code == workflowCode && d.IsActive); var instance = new WorkflowInstance { DefinitionId = definition.Id, Title = title, InitiatorId = initiatorId, Status = WorkflowStatus.Running, CreateTime = DateTime.Now }; _db.WorkflowInstances.Add(instance); await _db.SaveChangesAsync(); _db.FormValues.Add(new FormValue { InstanceId = instance.Id, DataJson = JsonSerializer.Serialize(formData) }); var firstNode = definition.Nodes .Where(n => n.NodeType == NodeType.Approval) .OrderBy(n => n.SortOrder) .First(); await CreateTaskAsync(instance.Id, firstNode, initiatorId); await _db.SaveChangesAsync(); return instance.Id; }流程实例化和任务生成放一个事务,避免“实例建好但第一张待办丢失”。表单值用 JSON 存储,在前面表结构设计里已经说了取舍。调用这个方法时 workflowCode 要传定义表里的 Code,比如Leave_Request,不要传 Id——这样将来换环境、流程定义重新导入时,业务代码不需要跟着改。
CreateTaskAsync 内部要做两件事:生成一条 Pending 状态的 WorkflowTask,同时更新 WorkflowInstance.CurrentNodeId。审批人的计算逻辑放在 ApproverType 上:User 直接取 ApproverValue 里的用户 ID;Role 要查角色下所有用户并给每个人各生成一条任务;DeptLeader 要拿到发起人的部门,再找部门的负责人。这部分在源码里往往是重灾区,尤其是多部门会签时,发起人所属部门搞错,所有审批人都会跟着错。
4.2 会签与逐级审批:并行任务和串行任务的驱动逻辑
审批节点有两种驱动方式:串行节点只有一个审批人,处理完跳到下一节点;会签节点有多个人,全部通过才进入下一节点。实现上,串行靠“完成当前任务后查下一节点”,并行靠“同一 Node 生成多条 Task,全部完成才流转”。这是审批流里的关键路径,代码顺序错了就翻车。
public async Task ApproveAsync(int taskId, int userId, string comment) { var task = await _db.WorkflowTasks.FindAsync(taskId); if (task == null || task.AssigneeId != userId || task.Status != "Pending") throw new BusinessException("任务不存在或已被处理"); task.Status = "Approved"; task.Comment = comment; task.HandleTime = DateTime.Now; var node = await _db.WorkflowNodes.FindAsync(task.NodeId); var instance = await _db.WorkflowInstances.FindAsync(task.InstanceId); if (node.NodeType == NodeType.CounterSign) { // 会签节点:本节点还有其他 Pending 任务,就不往后流转 var pendingCount = await _db.WorkflowTasks .CountAsync(t => t.InstanceId == instance.Id && t.NodeId == node.Id && t.Status == "Pending"); if (pendingCount > 0) { await _db.SaveChangesAsync(); return; } } // 当前节点全部完成,找下一审批节点 var nextNode = await _db.WorkflowNodes .Where(n => n.DefinitionId == instance.DefinitionId && n.SortOrder > node.SortOrder && n.NodeType == NodeType.Approval) .OrderBy(n => n.SortOrder) .FirstOrDefaultAsync(); if (nextNode == null) { instance.Status = WorkflowStatus.Approved; instance.FinishTime = DateTime.Now; } else { await CreateTaskAsync(instance.Id, nextNode, userId); } await _db.SaveChangesAsync(); }这段代码的顺序很重要:先把当前任务置为 Approved 并落库,再判断本节点是否还有 Pending 任务。如果是会签且还有未处理的,直接 return 不往下走;等最后一个人通过时,pendingCount 为 0,才进入下一节点。SortOrder 的定义要统一:同一层级只有一条链,条件分支用 ConditionExpr 控制,不依赖 SortOrder 做复杂排序。
这里有个容易忽略的细节:会签节点的 ApproveAsync 方法,每个人进来都走同一个逻辑。如果某人的处理结果是 Reject,整个实例应该直接变为 Rejected 并通知其他会签人“流程已终止”,他们的待办变成无效。这个场景在 4.3 里会说。
4.3 驳回、撤回与终止:状态机事件驱动的三个边界
OA 里最容易出 bug 的不是通过,而是驳回和撤回。驳回业务上有两种:驳回到上一节点(退回重审)和驳回到发起人(直接打回)。撤回则要求只能由发起人在第一个审批人尚未处理时发起。这三个边界写不清楚,线上必出事故。
public static class WorkflowStatus { public const string Running = "Running"; public const string Approved = "Approved"; public const string Rejected = "Rejected"; public const string Withdrawn = "Withdrawn"; public const string Terminated = "Terminated"; } public async Task RejectAsync(int taskId, int userId, string comment) { var task = await _db.WorkflowTasks.FindAsync(taskId); var node = await _db.WorkflowNodes.FindAsync(task.NodeId); task.Status = "Rejected"; task.Comment = comment; task.HandleTime = DateTime.Now; // 驳回到上一节点:重新生成上一节点的审批任务 if (node.RejectTo == RejectTarget.PreviousNode) { var prevNode = await _db.WorkflowNodes .Where(n => n.DefinitionId == node.DefinitionId && n.SortOrder < node.SortOrder && n.NodeType == NodeType.Approval) .OrderByDescending(n => n.SortOrder) .FirstAsync(); await CreateTaskAsync(task.InstanceId, prevNode, userId); } else // 驳回到发起人:实例直接置为 Rejected { var instance = await _db.WorkflowInstances.FindAsync(task.InstanceId); instance.Status = WorkflowStatus.Rejected; instance.FinishTime = DateTime.Now; } await _db.SaveChangesAsync(); }Reject 和 Approve 的对称性很重要:Approved 之后找下一节点,Rejected 之后找上一节点,两段代码放一起看才不容易漏。驳回到上一节点时,上一节点的审批人重新进入待办列表,他之前填的审批意见应该保留,做到“历史记录可追溯”,这需要 Task 表保留旧记录,只新增一条新任务。
撤回的边界条件必须写死:只有 WorkflowInstance.Status == Running 且当前所有 Task 状态都为 Pending 且发起人是当前登录用户,三个条件同时满足才允许撤回。缺一个都可能造成“审批人刚点完通过,发起人却把单子撤了”的并发事故。终止操作给管理员用,调用后实例状态置为 Terminated,所有 Pending 任务全部置为 Terminated,前端待办中心要能过滤掉这种单据。
5. 部署与排查:OA 源码上生产环境的常见问题与避坑记录
5.1 连接字符串明文泄露:环境变量覆盖比文件加密更省心
现象:把 appsettings.json 连同源码一起提交到内部代码仓库,数据库口令直接暴露;或者部署目录被扫描,任何人都能读到配置里的明文密码。
原因:开发和生产环境共用一个配置文件,源码里只写了开发库的地址,生产部署时没人改,就带着开发库口令上线了。
解决:生产环境不要依赖 appsettings.json 里的 ConnectionStrings,改用环境变量覆盖。ASP.NET Core 的配置系统会把环境变量自动映射到同名配置项:
export ConnectionStrings__OADb="Server=prod-db;Database=OADB;User Id=oa_app;Password=..."同时把 appsettings.Development.json 加入 .gitignore,开发库的口令走 dotnet user-secrets 存到本机用户目录,不进代码仓库。线上如果必须要用配置文件,建议对连接字符串做加密,或者在部署脚本里用变量替换占位符,而不是直接明文写在文件里。
5.2 IIS 与 Docker:静态资源 404 和容器重启掉登录
现象:本地开发正常,发布到 IIS 后 CSS、JS 加载不出来;或者部署成 Docker 容器后,用户隔天打开系统就掉登录,要重新输入密码。
原因:IIS 发布目录缺 web.config,ASP.NET Core 模块没接管请求,静态资源全被 IIS 默认处理挡掉。Docker 场景下,JWT 签名密钥如果在代码里写死或每次启动随机生成,容器重启后所有旧令牌签名验证失败,用户自然全部掉线。
解决:IIS 发布用dotnet publish生成,发布目录必须带 web.config,不要手动复制文件。Docker 里 JWT 的 SigningKey 从环境变量注入,并设置固定的 Issuer 和 Audience。下面是多阶段构建的 Dockerfile 关键片段:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY . . RUN dotnet publish OA.Web/OA.Web.csproj -c Release -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime WORKDIR /app COPY --from=build /app/publish . ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:8080 EXPOSE 8080 ENTRYPOINT ["dotnet", "OA.Web.dll"]容器启动后先进去探活,确认服务真的起来了再排查反向代理。探活用curl http://localhost:8080/health,前提是源码里有 health 接口;没有就临时加一个,这个接口对运维排查至关重要。反向代理转发时要保留 Host 头,否则部分中间件可能报错。
5.3 附件上传 413:IIS、Kestrel 双重限制与临时文件清理
现象:上传超过 30MB 的文件报 404.13 或 413 Request Entity Too Large;服务器跑了一段时间后,临时上传目录磁盘满了,新文件传不上去。
原因:IIS 的 maxAllowedContentLength 默认 30MB,Kestrel 的 MaxRequestBodySize 默认也是 30MB,两处都要改,缺一个都不生效。临时上传目录没有清理机制,失败的、被放弃的文件一直堆在磁盘里。
解决:IIS 场景改 web.config,加上 requestFiltering 节点;Kestrel 场景在 appsettings.json 里加配置:
<system.webServer> <security> <requestFiltering> <requestLimits maxAllowedContentLength="104857600" /> </requestFiltering> </security> </system.webServer>{ "Kestrel": { "Limits": { "MaxRequestBodySize": 104857600 } } }附件存储建议按日期分目录,写到wwwroot/uploads/2025/06/这种结构,再写一个定时任务清理超过 30 天且未被表单引用的文件。这个清理逻辑在源码里经常被忽略,等磁盘满了才暴露,是运维层面的定时炸弹。
5.4 审批并发重复处理:一条带条件的 UPDATE 解决
现象:两个审批人同时打开同一张单子,都点了通过,结果产生两条审批记录,流程状态错乱,业务那边为了对账吵翻天。
原因:代码是“先查后改”——两个请求都读到 Status = 'Pending',然后各自执行 Update,后一个把前一个覆盖了。
解决:用一个带条件的 UPDATE 代替“先查后改”,让数据库来判断任务是否已被处理:
UPDATE WorkflowTask SET Status = 'Approved', HandleTime = GETDATE() WHERE Id = @taskId AND AssigneeId = @userId AND Status = 'Pending';影响行数为 0 说明任务已被处理,直接返回提示“该审批任务已处理,请刷新页面”。这个方案比加事务锁简单,也扛得住审批场景的并发。如果源码已经用了 EF Core,就在 SaveChanges 前执行 ExecuteSqlRaw,把更新结果作为是否继续流转的依据。
更进一步可以给 WorkflowTask 表加 Version 字段做乐观锁,前端能拿到更明确的冲突提示,比如“该审批已被其他人处理,当前页面已重置”。我经历过一次线上事故后,把所有的 Approve/Reject 都换成了条件更新,再没出过重复审批的问题。
6. 让这套源码真正投入使用:二次开发与验收的几个实用技巧
OA 系统上线后,产品提得最多的需求往往不是流程引擎,而是“根据下拉框的值隐藏表单字段”。比如报销单选了差旅费,就显示出发地、目的地;选了办公用品,就隐藏差旅相关字段。我在源码里实现时没有在前端写死,而是在表单定义表里加一个 ShowWhen 字段,存 JSON 表达式:
{ "field": "expenseType", "operator": "eq", "value": "travel" }后端返回表单时把这个条件拼进字段元数据,前端渲染时遇到满足条件的字段就显示,否则隐藏。这样一个字段可以挂多条显示条件,以后新增字段不用改后端代码,实施人员自己就能配置出来。这个方案比在视图里写一堆 if 判断干净得多,也是这套源码二次开发时性价比最高的一个改动。
验收阶段要重点盯三个边界:并发、驳回、撤回。并发测试用两个账号同时打开同一待办,一个先通过,另一个再点必须提示已处理;撤回测试发起人撤回后,已生成的任务必须全部回到可重新处理的状态;驳回测试要验证“驳回到上一节点”后,上一节点审批人能正常进入待办列表。这三个场景跑通,审批流基本就稳了。
这套 OA 审批流的源码我前后做过三轮,最早一版没做并发控制,上线第二周就出了同一张单子被两个经理审批的事故——一个批同意,一个批不同意,最后流程状态直接乱掉。后来加了条件 UPDATE,又把审批操作日志补全,才算消停。现在每接到一套 OA 源码,我都会先看状态机的边界处理和并发控制,再看界面;这两点稳了,系统大概率不会出大问题。希望帮到你。
本文还有配套的精品资源,点击获取