资讯动态

ASP.NET OA审批流源码解析:状态机、权限与并发避坑指南

发布时间:2026/10/26 9:19:17 来源:尧图企业网站定制
简介这是一套基于 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 FoundationWF工程。我经手过的内部 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 DbContextApplication 层编排业务事务审批流的 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 TaskIActionResult 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 ListClaim { 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 : AuthorizationHandlerPermissionRequirement { 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.AddSingletonIAuthorizationHandler, 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 Taskint StartAsync(int initiatorId, string workflowCode, string title, Dictionarystring, 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 里的用户 IDRole 要查角色下所有用户并给每个人各生成一条任务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__OADbServerprod-db;DatabaseOADB;User Idoa_app;Password...同时把 appsettings.Development.json 加入 .gitignore开发库的口令走 dotnet user-secrets 存到本机用户目录不进代码仓库。线上如果必须要用配置文件建议对连接字符串做加密或者在部署脚本里用变量替换占位符而不是直接明文写在文件里。5.2 IIS 与 Docker静态资源 404 和容器重启掉登录现象本地开发正常发布到 IIS 后 CSS、JS 加载不出来或者部署成 Docker 容器后用户隔天打开系统就掉登录要重新输入密码。原因IIS 发布目录缺 web.configASP.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 --frombuild /app/publish . ENV ASPNETCORE_ENVIRONMENTProduction ENV ASPNETCORE_URLShttp://:8080 EXPOSE 8080 ENTRYPOINT [dotnet, OA.Web.dll]容器启动后先进去探活确认服务真的起来了再排查反向代理。探活用curl http://localhost:8080/health前提是源码里有 health 接口没有就临时加一个这个接口对运维排查至关重要。反向代理转发时要保留 Host 头否则部分中间件可能报错。5.3 附件上传 413IIS、Kestrel 双重限制与临时文件清理现象上传超过 30MB 的文件报 404.13 或 413 Request Entity Too Large服务器跑了一段时间后临时上传目录磁盘满了新文件传不上去。原因IIS 的 maxAllowedContentLength 默认 30MBKestrel 的 MaxRequestBodySize 默认也是 30MB两处都要改缺一个都不生效。临时上传目录没有清理机制失败的、被放弃的文件一直堆在磁盘里。解决IIS 场景改 web.config加上 requestFiltering 节点Kestrel 场景在 appsettings.json 里加配置system.webServer security requestFiltering requestLimits maxAllowedContentLength104857600 / /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 源码我都会先看状态机的边界处理和并发控制再看界面这两点稳了系统大概率不会出大问题。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑