审批里最容易被低估的问题不是"流程怎么配",而是两个人同时点"同意"会发生什么。
如果处理得不好,结果可能是:单据被推进两级、同一个节点被完成两次、历史里出现两条互相矛盾的审批记录、或者"已通过"之后又被驳回。这篇文章讲 EasyAdminBlazor 2.3 是怎么用数据库条件更新把这些问题挡住的。
一、先把并发场景列全
审批模块要处理的竞争不止一种:
| 场景 | 期望结果 |
|---|---|
| 同一个人重复点击"同意" | 第二次必须失败,不能推进两级 |
| 或签节点 A、B 同时点"同意" | 只有一个人成功,节点只完成一次 |
| 审批人点"同意"的同时发起人点"撤回" | 只有一个成功 |
| 同一节点两个人同时"转交" | 只有一次转交生效,不能互相覆盖 |
| 最后一级并发同意 | 只产生一次"已通过" |
| 一级已通过后,再对一级"驳回" | 必须失败,不能把已通过的节点改坏 |
这六种场景在 ApprovalConcurrencyTests.cs 里都有对应用例。
二、为什么不用锁
第一反应通常是"加锁":lock、SemaphoreSlim、分布式锁。但在审批这个场景里,锁不是好答案。
进程内锁在多实例部署下直接失效。 两个请求打到不同实例,各自的锁互不可见。
分布式锁成本高、粒度难定。 按单据加锁意味着要维护锁的获取、续期、释放、超时,还得处理"持锁进程崩了"。
真正的约束是数据本身。 "这张单据现在必须是审批中、且处于第 2 级"------这个条件数据库最清楚,也最能原子地判定。
所以源码采用的方案是条件更新(乐观并发) :把"我希望的前置状态"写进 WHERE,用受影响行数判断是否抢到了这次操作。
三、核心机制一:业务状态的条件更新
1. 更新语句
csharp
// 1) 业务状态原子推进(状态 + 级次条件更新,并发审批只有一个能成功)
var affected = await SetApprovalStatus<TBill>(
tx.GetRepository<TBill>().UpdateDiy,
status,
status == ApprovalStatus.Approved ? 0 : nextLevel)
.Where(a => a.Id == billId
&& a.ApprovalStatus == ApprovalStatus.Pending
&& a.CurrentLevel == currentLevel)
.ExecuteAffrowsAsync();
if (affected <= 0) return ApprovalOperationOutcome.CreateConflict();
其中 SetApprovalStatus 只负责写两个字段:
csharp
/// <summary>
/// 条件更新单据审批状态与级次(服务端根据当前状态/级次计算,不接受客户端传入)。
/// </summary>
private static IUpdate<TBill> SetApprovalStatus<TBill>(
IUpdate<TBill> update, ApprovalStatus status, int level)
where TBill : class, IApprovalBill
=> update
.Set(x => x.ApprovalStatus, status)
.Set(x => x.CurrentLevel, level);
参数注释里那句话很关键:状态和目标级次都是服务端算出来的,不接受客户端传"我要把它改成第 3 级"。客户端只能表达"我要同意",能不能推进由服务端根据当前数据决定。
2. 对应的 SQL
上面的 LINQ 最终会生成类似:
sql
UPDATE blog_article
SET ApprovalStatus = 1, CurrentLevel = 3
WHERE Id = 1001
AND ApprovalStatus = 1
AND CurrentLevel = 2;
数据库保证这条 UPDATE 的原子性。两个请求同时执行时:
text
请求 A:UPDATE ... WHERE CurrentLevel = 2 → affected = 1(成功)
请求 B:UPDATE ... WHERE CurrentLevel = 2 → affected = 0(此时已经是 3)
affected = 0 就是"有人抢先了"的信号,直接返回冲突,不再执行后续步骤。
3. 冲突怎么反馈给用户
csharp
if (outcome.Conflict)
{
// 兼容既有提示语:单据级条件更新失败通常意味着他人已把单据推进/结束
return new ApprovalSubmitResult(false, L("该单据已被其他人处理,请刷新后重试"));
}
返回的是可读提示,不是异常堆栈。异常信息通过 GenericFailure(ex) 统一处理成通用提示(只带 TraceId,不泄露数据库细节)。
四、核心机制二:原子占用审批节点
只更新业务状态还不够。sys_approval_record 里保存着节点和待办,如果业务状态更新成功但节点记录没被正确占用,就会出现"节点还在待办里"或者"两个人各写一条审批意见"的问题。
所以紧接着是第二步------原子占用当前待办节点:
csharp
// 2) 原子占用当前待办节点:只有"当前节点 + 待处理 + 审批人是我"的记录会被更新。
// 或签场景下 A、B 同时点同意时这里只有一个请求 affected > 0,不会重复推进节点。
var claimed = await tx.GetRepository<SysApprovalRecord>().UpdateDiy
.Set(x => x.Status, ApprovalRecordStatus.Approved)
.Set(x => x.Comment, commentSnapshot)
.Set(x => x.OperateTime, DateTime.Now)
.Set(x => x.IsCurrent, false)
.Set(x => x.OperatorUserId, userId)
.Set(x => x.OperatorUserName, userName)
.Set(x => x.BeforeStatus, ApprovalStatus.Pending)
.Set(x => x.AfterStatus, status)
.Where(x => x.BillType == billType && x.BillId == billId && x.Round == round
&& x.Level == currentLevel
&& x.IsCurrent
&& x.Status == ApprovalRecordStatus.Pending
&& (x.ApproverUserId == userId || _user.IsAdministrator))
.ExecuteAffrowsAsync();
if (claimed <= 0) return ApprovalOperationOutcome.CreateConflict();
注意 WHERE 里的五个条件缺一不可:
| 条件 | 作用 |
|---|---|
Round |
只操作当前轮次,历史轮次不动 |
Level == currentLevel |
只操作当前级次 |
IsCurrent |
只操作"当前节点" |
Status == Pending |
只操作待处理,已处理的不动 |
ApproverUserId == userId(或管理员) |
只有该节点的审批人能认领 |
这就是所谓"认领 ":谁先把这条记录从 Pending 改成 Approved,谁就完成了这个节点。第二个请求的 claimed 是 0,拿不到节点,整个操作回滚。
五、或签:同节点多人时怎么收尾
"角色"来源的审批节点往往是多人(或签)。A 同意之后,同一节点的 B、C 就不应该再看到这条待办。
源码的处理是把同节点剩余待处理记录统一置为 Skipped:
csharp
// 3) 或签:同节点其他待处理记录自动跳过
await tx.GetRepository<SysApprovalRecord>().UpdateDiy
.Set(x => x.Status, ApprovalRecordStatus.Skipped)
.Set(x => x.IsCurrent, false)
.Set(x => x.OperateTime, DateTime.Now)
.Where(x => x.BillType == billType && x.BillId == billId && x.Round == round
&& x.Level == currentLevel
&& x.Status == ApprovalRecordStatus.Pending)
.ExecuteAffrowsAsync();
注意这里没有 ApproverUserId == userId 条件------目的就是把同节点其他所有人的待办一起清掉。
Skipped 是独立的记录状态,和"同意/驳回"区分开。这样时间线上能看出"A 同意了,B、C 被跳过",而不是把 B、C 显示成同意了。
六、节点推进:状态机 + 快照
下一步是决定"往哪走",由纯函数状态机负责:
csharp
var (status, nextLevel) = ApprovalStateMachine.Next(ApprovalAction.Approve, currentLevel, maxLevel);
csharp
ApprovalAction.Approve when currentLevel < maxLevel => (ApprovalStatus.Pending, currentLevel + 1),
ApprovalAction.Approve => (ApprovalStatus.Approved, 0),
这里的 maxLevel 不是直接取流程配置,而是优先取提交时落库的节点快照:
csharp
/// <summary>
/// 计算流程的有效最大级次。
/// 优先以"提交时已落库的节点"为准(流程实例快照),这样运行中的审批不会因为
/// 流程配置后来被修改而突然改变节点数量或节点内容。
/// </summary>
private int ResolveMaxLevel(IReadOnlyCollection<SysApprovalRecord> currentRecords, ApprovalFlowConfig flow)
{
...
var maxLevel = (int?)_orm.Select<SysApprovalRecord>()
.Where(x => x.BillType == billType && x.BillId == billId && x.Round == round && x.Level > 0)
.Max(x => (int?)x.Level);
snapshotMax = maxLevel ?? 0;
// 快照缺失(历史数据)时退回流程配置,但必须至少覆盖当前级次
var configuredMax = flow.Levels.Count == 0 ? 0 : flow.Levels.Max(x => x.Level);
var max = Math.Max(snapshotMax, configuredMax);
...
}
这是个很实务的考虑:管理员在流程跑到一半时改了流程配置(比如从 3 级改成 2 级),正在审批中的单据不应该突然"少一级"或"多一级"。以提交时的快照为准,配置只影响新提交的单据。
七、下一级激活失败怎么办
如果还有下一级,要把下一级节点置为 IsCurrent = true。这里有一个明确的一致性要求:
csharp
if (next.Count == 0)
{
// 下一节点缺失属于流程数据不一致,必须回滚而不是让流程悬空
throw new InvalidOperationException(
$"审批流程数据不一致:单据 {billType}#{billId} 缺少第 {nextLevel} 级待办节点");
}
foreach (var record in next) record.IsCurrent = true;
await tx.GetRepository<SysApprovalRecord>().UpdateDiy.SetSource(next).ExecuteAffrowsAsync();
抛异常 → 整个事务回滚 → 业务状态回到"第 N 级审批中",审批记录也没被改。这比"业务状态推到第 N+1 级、但没有任何人收到待办"要好得多。第 18 篇会详细讲这个事务。
八、驳回、撤回、转交用的是同一套模式
驳回
csharp
// 1) 业务状态原子更新
var affected = await SetApprovalStatus<TBill>(tx.GetRepository<TBill>().UpdateDiy, status, level)
.Where(a => a.Id == billId
&& a.ApprovalStatus == ApprovalStatus.Pending
&& a.CurrentLevel == currentLevel)
.ExecuteAffrowsAsync();
if (affected <= 0) return null;
// 2) 原子占用当前节点(改为 Rejected)
var claimed = await tx.GetRepository<SysApprovalRecord>().UpdateDiy
.Set(x => x.Status, ApprovalRecordStatus.Rejected)
...
.ExecuteAffrowsAsync();
if (claimed <= 0) return null;
// 3) 同节点其他待处理记录跳过
// 4) 后续节点全部跳过,避免待办列表出现幽灵任务
// 5) 本轮结果回写发起记录
驳回比同意多一步:后续节点要全部跳过。否则单据已经"已驳回",后面的审批人待办里还挂着任务,就出现了幽灵待办。
撤回
撤回的前置校验更严格:
csharp
var records = await GetRecordsAsync(billType, billId);
if (records.Any(x => x.Status is ApprovalRecordStatus.Approved or ApprovalRecordStatus.Rejected))
return new ApprovalSubmitResult(false, L("已有审批人处理过该单据,不能撤回"));
只要有人处理过就不能撤回。然后同样是"业务状态条件更新 + 占用当前节点 + 后续节点跳过 + 留痕":
csharp
// 1) 业务状态原子更新:仍是"审批中 + 当前级次"才允许撤回,
// 与审批人的同意/驳回形成互斥(谁先提交谁生效)
var affected = await SetApprovalStatus<TBill>(tx.GetRepository<TBill>().UpdateDiy, status, level)
.Where(a => a.Id == billId
&& a.ApprovalStatus == ApprovalStatus.Pending
&& a.CurrentLevel == currentLevel)
.ExecuteAffrowsAsync();
if (affected <= 0) return false;
那句注释是重点:同意和撤回竞争的是同一个条件更新,谁先提交谁生效,不需要额外的互斥锁。
转交
转交不改业务状态,只改"这个节点由谁处理":
csharp
// 原子转交:只有"当前节点 + 待处理 + 审批人是我"的记录会被改写。
// 两个并发转交只有一个 affected > 0,不会互相覆盖。
var affected = await tx.GetRepository<SysApprovalRecord>().UpdateDiy
.Set(x => x.ApproverUserId, toUserIdSnapshot)
.Set(x => x.ApproverUserName, targetNameSnapshot)
.Set(x => x.OperatorUserId, userId)
.Set(x => x.OperatorUserName, userName)
.Where(x => x.BillType == billType && x.BillId == billId && x.Round == round
&& x.Level == currentLevel
&& x.IsCurrent
&& x.Status == ApprovalRecordStatus.Pending
&& (x.ApproverUserId == userId || _user.IsAdministrator))
.ExecuteAffrowsAsync();
if (affected <= 0) return null;
转交还有几条业务校验,防止把流程搞乱:
- 转交对象必须存在且未停用;
- 不能转交给单据发起人(避免变相自审);
- 不能转交给当前节点已有的审批人(避免重复任务)。
九、审计留痕:历史只允许新增
处理并发时还有一个隐含要求:不能为了"修复状态"去改历史记录。
源码里的做法是:当前节点记录被条件更新(认领),但结果留痕、转交留痕、撤回留痕都是 INSERT 新记录:
csharp
// 转交留痕:历史只新增,记录"原审批人 → 目标用户 + 原因",
// 因此仍能追溯到"原来是谁、什么时候转给谁、为什么转交"。
var trail = NewRecord(billType, billId, round, currentLevel, levelName,
ApprovalRecordStatus.Transferred, userId, userName, billTitle,
bill.CreatedUserId ?? userId, submitterNameSnapshot, current: false,
instanceKey: instanceKey,
beforeStatus: fromStatus, afterStatus: fromStatus,
operatorUserId: userId, operatorUserName: userName);
trail.ToUserId = toUserIdSnapshot;
trail.ToUserName = targetNameSnapshot;
trail.Comment = commentSnapshot;
await tx.GetRepository<SysApprovalRecord>().InsertAsync(trail);
每条记录还带 BeforeStatus / AfterStatus / OperatorUserId / InstanceKey,审计时能看到"变更前后状态 + 实际操作人 + 属于哪一轮"。
OperatorUserId 和 ApproverUserId 分开存也有讲究:
text
ApproverUserId:该节点应由谁处理
OperatorUserId:实际执行本次动作的人(转交时 = 原审批人,代审时 = 被代审人)
十、测试怎么锁定这些行为
ApprovalConcurrencyTests.cs 不是"跑一遍没报错",而是逐条锁定并发语义:
| 测试 | 验证的问题 |
|---|---|
ConcurrentApprove_OnlyOneSucceeds |
并发同意只有一个成功 |
ConcurrentApprove_RepeatedRequest_DoesNotDuplicateHistory |
重复请求不产生重复历史、不再推进节点 |
ApproveAndReject_AreMutuallyExclusive |
一级已通过后再驳回必须失败 |
ApproveAndRevokeConcurrently_OnlyOneSucceeds |
同意与撤回并发只有一个成功 |
ConcurrentTransfer_OnlyOneSucceeds |
并发转交不互相覆盖 |
LastLevelConcurrentApprove_ProducesSingleCompletion |
末级并发同意只产生一次完成 |
OrSign_ConcurrentApproveByDifferentApprovers_CompletesNodeOnce |
或签并发同意,节点只完成一次、不重复激活下一级 |
MissingNextLevel_RollsBackWholeTransaction |
下一节点缺失时整体回滚 |
BusinessStatusConflict_DoesNotTouchApprovalRecords |
业务状态冲突时审批记录不被改动 |
Revoke_AfterApproved_IsRejected |
已审批过的单据不能撤回 |
Revoke_Twice_DoesNotDuplicateHistory |
第二次撤回不产生新历史 |
Notification_IsNotSentWhenTransactionFails |
事务失败时绝不发"审批成功"通知 |
其中 BusinessStatusConflict_DoesNotTouchApprovalRecords 最能说明设计意图:
text
他人已把业务状态推进到二级,但一级待办记录仍是当前节点
→ 条件更新 affected = 0
→ 审批记录必须保持原样,不能出现"记录已同意、业务还在审批中"
十一、这套方案不覆盖什么
条件更新解决的是"同一行数据的竞争",它不能替代所有并发控制:
- 跨单据的业务规则(例如"这个客户所有合同的审批总额不能超过 100 万")不在这里处理,需要在业务层加锁或做汇总校验。
- 多实例下的初始化类操作(例如动态创建租户)仍然需要分布式锁------条件更新保护的是已存在的数据行。
- 真正的会签/并行分支超出模块范围,需要工作流引擎。
- 条件更新依赖数据库隔离级别 。主流数据库的默认隔离级别下,
UPDATE ... WHERE的原子性是有保证的;如果你的环境做了特殊设置(比如某些只读副本上写),要单独验证。
十二、小结
EasyAdminBlazor 审批并发控制的核心可以概括成两句话:
- 业务状态用"状态 + 级次"条件更新 ,
affected <= 0就代表有人抢先,直接返回冲突; - 审批节点用"当前节点 + 待处理 + 审批人是我"条件更新来认领 ,或签时其余待处理记录置
Skipped,历史只新增不改写。
这两个条件更新都在同一个事务里,所以不会出现"状态改了但节点没认领"的半成功状态------那是下一篇文章的主题。
如果你的后台需要审批,又担心"两个人同时点会怎样",可以让团队直接读 EasyAdminBlazor 的审批实现:并发语义写在了条件更新的 WHERE 里,也写在了测试用例里。