EasyAdminBlazor 审批并发控制:两个人同时审批为什么只能成功一个?

Original 2026-10-01 889 views

审批里最容易被低估的问题不是"流程怎么配",而是两个人同时点"同意"会发生什么。

如果处理得不好,结果可能是:单据被推进两级、同一个节点被完成两次、历史里出现两条互相矛盾的审批记录、或者"已通过"之后又被驳回。这篇文章讲 EasyAdminBlazor 2.3 是怎么用数据库条件更新把这些问题挡住的。


一、先把并发场景列全

审批模块要处理的竞争不止一种:

场景 期望结果
同一个人重复点击"同意" 第二次必须失败,不能推进两级
或签节点 A、B 同时点"同意" 只有一个人成功,节点只完成一次
审批人点"同意"的同时发起人点"撤回" 只有一个成功
同一节点两个人同时"转交" 只有一次转交生效,不能互相覆盖
最后一级并发同意 只产生一次"已通过"
一级已通过后,再对一级"驳回" 必须失败,不能把已通过的节点改坏

这六种场景在 ApprovalConcurrencyTests.cs 里都有对应用例。


二、为什么不用锁

第一反应通常是"加锁":lock、SemaphoreSlim、分布式锁。但在审批这个场景里,锁不是好答案。

进程内锁在多实例部署下直接失效。 两个请求打到不同实例,各自的锁互不可见。

分布式锁成本高、粒度难定。 按单据加锁意味着要维护锁的获取、续期、释放、超时,还得处理"持锁进程崩了"。

真正的约束是数据本身。 "这张单据现在必须是审批中、且处于第 2 级"——这个条件数据库最清楚,也最能原子地判定。

所以源码采用的方案是条件更新(乐观并发):把"我希望的前置状态"写进 WHERE,用受影响行数判断是否抢到了这次操作。


三、核心机制一:业务状态的条件更新

1. 更新语句

// 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 只负责写两个字段:

/// <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 最终会生成类似:

UPDATE blog_article
SET ApprovalStatus = 1, CurrentLevel = 3
WHERE Id = 1001
  AND ApprovalStatus = 1
  AND CurrentLevel = 2;

数据库保证这条 UPDATE 的原子性。两个请求同时执行时:

请求 A:UPDATE ... WHERE CurrentLevel = 2  → affected = 1(成功)
请求 B:UPDATE ... WHERE CurrentLevel = 2  → affected = 0(此时已经是 3)

affected = 0 就是"有人抢先了"的信号,直接返回冲突,不再执行后续步骤。

3. 冲突怎么反馈给用户

if (outcome.Conflict)
{
    // 兼容既有提示语:单据级条件更新失败通常意味着他人已把单据推进/结束
    return new ApprovalSubmitResult(false, L("该单据已被其他人处理,请刷新后重试"));
}

返回的是可读提示,不是异常堆栈。异常信息通过 GenericFailure(ex) 统一处理成通用提示(只带 TraceId,不泄露数据库细节)。


四、核心机制二:原子占用审批节点

只更新业务状态还不够。sys_approval_record 里保存着节点和待办,如果业务状态更新成功但节点记录没被正确占用,就会出现"节点还在待办里"或者"两个人各写一条审批意见"的问题。

所以紧接着是第二步——原子占用当前待办节点:

// 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:

// 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 显示成同意了。


六、节点推进:状态机 + 快照

下一步是决定"往哪走",由纯函数状态机负责:

var (status, nextLevel) = ApprovalStateMachine.Next(ApprovalAction.Approve, currentLevel, maxLevel);
ApprovalAction.Approve when currentLevel < maxLevel => (ApprovalStatus.Pending, currentLevel + 1),
ApprovalAction.Approve => (ApprovalStatus.Approved, 0),

这里的 maxLevel 不是直接取流程配置,而是优先取提交时落库的节点快照:

/// <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。这里有一个明确的一致性要求:

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 篇会详细讲这个事务。


八、驳回、撤回、转交用的是同一套模式

驳回

// 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) 本轮结果回写发起记录

驳回比同意多一步:后续节点要全部跳过。否则单据已经"已驳回",后面的审批人待办里还挂着任务,就出现了幽灵待办。

撤回

撤回的前置校验更严格:

var records = await GetRecordsAsync(billType, billId);
if (records.Any(x => x.Status is ApprovalRecordStatus.Approved or ApprovalRecordStatus.Rejected))
    return new ApprovalSubmitResult(false, L("已有审批人处理过该单据,不能撤回"));

只要有人处理过就不能撤回。然后同样是"业务状态条件更新 + 占用当前节点 + 后续节点跳过 + 留痕":

// 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;

那句注释是重点:同意和撤回竞争的是同一个条件更新,谁先提交谁生效,不需要额外的互斥锁。

转交

转交不改业务状态,只改"这个节点由谁处理":

// 原子转交:只有"当前节点 + 待处理 + 审批人是我"的记录会被改写。
// 两个并发转交只有一个 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 新记录:

// 转交留痕:历史只新增,记录"原审批人 → 目标用户 + 原因",
// 因此仍能追溯到"原来是谁、什么时候转给谁、为什么转交"。
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 分开存也有讲究:

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 最能说明设计意图:

他人已把业务状态推进到二级,但一级待办记录仍是当前节点
→ 条件更新 affected = 0
→ 审批记录必须保持原样,不能出现"记录已同意、业务还在审批中"

十一、这套方案不覆盖什么

条件更新解决的是"同一行数据的竞争",它不能替代所有并发控制:

  1. 跨单据的业务规则(例如"这个客户所有合同的审批总额不能超过 100 万")不在这里处理,需要在业务层加锁或做汇总校验。
  2. 多实例下的初始化类操作(例如动态创建租户)仍然需要分布式锁——条件更新保护的是已存在的数据行。
  3. 真正的会签/并行分支超出模块范围,需要工作流引擎。
  4. 条件更新依赖数据库隔离级别。主流数据库的默认隔离级别下,UPDATE ... WHERE 的原子性是有保证的;如果你的环境做了特殊设置(比如某些只读副本上写),要单独验证。

十二、小结

EasyAdminBlazor 审批并发控制的核心可以概括成两句话:

  1. 业务状态用"状态 + 级次"条件更新,affected <= 0 就代表有人抢先,直接返回冲突;
  2. 审批节点用"当前节点 + 待处理 + 审批人是我"条件更新来认领,或签时其余待处理记录置 Skipped,历史只新增不改写。

这两个条件更新都在同一个事务里,所以不会出现"状态改了但节点没认领"的半成功状态——那是下一篇文章的主题。


如果你的后台需要审批,又担心"两个人同时点会怎样",可以让团队直接读 EasyAdminBlazor 的审批实现:并发语义写在了条件更新的 WHERE 里,也写在了测试用例里。