EasyAdminBlazor 审批事务:为什么审批失败必须全部回滚?

前一篇文章讲并发:两个人同时审批只有一个能成功。

这一篇讲更隐蔽的问题:一次审批要写好几处数据,如果写到一半失败会怎样?


一、一次审批到底要写多少地方

以"同意"为例,一次操作涉及:

text 复制代码
1. 更新业务表:ApprovalStatus + CurrentLevel
2. 更新审批记录:当前节点标记为已同意(含意见、处理人、处理时间、审计字段)
3. 跳过同节点其他待处理记录(或签)
4. 激活下一节点(或把本轮结果回写发起记录)
5. 提交之后:发送站内信

如果不放在同一个事务里,最典型的故障是"半成功":

text 复制代码
业务表:已通过 ✅
审批记录:还停在"待处理" ❌
→ 单据显示已通过,审批人待办里还挂着这个任务

或者反过来:

text 复制代码
审批记录:A 已同意 ✅
业务表:还在第 1 级审批中 ❌
→ 时间线显示有人同意了,单据却像没人处理

更糟的是第 4 步失败:业务状态已经推进到第 2 级,但第 2 级的待办节点没有激活------流程悬空,谁都处理不了。


二、事务封装:ExecuteInTransactionAsync

csharp 复制代码
/// <summary>
/// 在数据库事务中执行审批写操作。
/// 任一步抛异常则整体回滚,绝不允许"业务状态改了、审批记录没写"的半成功状态。
/// <para>
/// 关键点:事务体必须通过 UnitOfWork 上的仓储(ApprovalTransaction)访问数据库。
/// 直接用 _orm 的操作会游离在事务之外(自动提交),
/// 那样"下一节点缺失就回滚"会失效。
/// </para>
/// </summary>
private async Task<T> ExecuteInTransactionAsync<T>(Func<ApprovalTransaction, Task<T>> action, string operation)
{
    // 事务外先确保审批记录表结构存在(避免 DDL 落在事务里)
    EnsureApprovalStructure();

    // UnitOfWorkManager 负责开启事务;仓储必须通过 manager.Binding 加入该工作单元,
    // 否则仓储会各自新开连接(SQLite 下直接表现为 database is locked,事务也失去意义)。
    var uow = _unitOfWorkManager.Begin();
    var transaction = new ApprovalTransaction(_unitOfWorkManager, uow);
    try
    {
        var result = await action(transaction);
        uow.Commit();
        return result;
    }
    catch (Exception ex)
    {
        try
        {
            uow.Rollback();
        }
        catch (Exception rollbackEx)
        {
            _logger.LogError(rollbackEx, "审批事务回滚失败,操作:{Operation}", operation);
        }

        _logger.LogError(ex, "审批事务执行失败并已回滚,操作:{Operation}", operation);
        throw;
    }
    finally
    {
        transaction.Dispose();
    }
}

这段代码包含四个必须理解的点。

1. DDL 必须在事务外

csharp 复制代码
// 事务外先确保审批记录表结构存在(避免 DDL 落在事务里)
EnsureApprovalStructure();
csharp 复制代码
/// <summary>
/// 确保审批记录表结构存在。
///
/// 关键:FreeSql 的仓储在第一次访问实体会触发 CodeFirst.SyncStructure(DDL)。
/// 若这一步发生在事务内部,DDL 会与事务写锁冲突(SQLite 直接报 database is locked),
/// 也会让"审批事务"里混进建表语句。因此先在事务之外同步一次。
/// SyncStructure 本身幂等,仅在事务开始前多一次轻量的表结构探测。
/// </summary>
private void EnsureApprovalStructure()
{
    try
    {
        _orm.CodeFirst.SyncStructure<SysApprovalRecord>();
    }
    catch (Exception ex)
    {
        // 同步失败不影响后续流程(表通常已存在),只记录告警
        _logger.LogWarning(ex, "同步审批记录表结构失败,将按现有结构继续");
    }
}

这段注释值回票价。很多人第一次写"审批事务"时会遇到 database is locked,原因就是 ORM 在事务里第一次访问实体触发了建表语句。解法不是加超时,而是把结构同步提到事务之外。

2. 仓储必须绑定到同一个工作单元

csharp 复制代码
/// <summary>
/// 事务内的数据访问入口:所有仓储都绑定到同一个 IUnitOfWork,
/// 从而保证业务状态、审批记录、审批历史的写入在同一个事务里提交或回滚。
/// </summary>
private sealed class ApprovalTransaction : IDisposable
{
    private readonly UnitOfWorkManager _manager;
    private readonly IUnitOfWork _uow;
    private readonly List<IDisposable> _repositories = [];

    public ApprovalTransaction(UnitOfWorkManager manager, IUnitOfWork uow)
    {
        _manager = manager;
        _uow = uow;
    }

    /// <summary>
    /// 取指定实体的仓储(与事务绑定)。
    /// 仓储建立在当前租户 ORM 上,并通过 UnitOfWorkManager.Binding 纳入事务,
    /// 因此事务内的读写共用同一条连接。
    /// </summary>
    public IBaseRepository<TEntity> GetRepository<TEntity>() where TEntity : class
    {
        var repository = _manager.Orm.GetRepository<TEntity>();
        _manager.Binding(repository);
        _repositories.Add(repository);
        return repository;
    }

    public void Dispose()
    {
        if (Interlocked.Exchange(ref _disposed, 1) == 1) return;

        foreach (var repository in _repositories)
        {
            try { repository.Dispose(); } catch { /* 释放失败不影响事务结果 */ }
        }

        _repositories.Clear();
        _uow.Dispose();
    }
}

这里的关键是 _manager.Binding(repository)。没有绑定,仓储会各自开连接,事务就名存实亡:

text 复制代码
绑定正确:
  UnitOfWork(事务连接)
     ├── 业务表仓储
     ├── 审批记录仓储
     └── 同一个连接 → 一起提交 / 一起回滚

没有绑定:
  业务表仓储 → 连接 A → 自动提交
  审批记录仓储 → 连接 B → 自动提交
  → 回滚只影响其中一个

测试 UnitOfWork_RepositoryIsBoundToCurrentTransaction 就是专门锁定这一点的。

3. 提交和回滚都要显式调用

csharp 复制代码
var result = await action(transaction);
uow.Commit();
return result;
csharp 复制代码
catch (Exception ex)
{
    try { uow.Rollback(); }
    catch (Exception rollbackEx) { _logger.LogError(...); }
    throw;
}

回滚本身如果失败也会被记录,但原始异常继续向上抛------因为调用方需要知道"这次审批没成功"。

4. 资源释放放在 finally

csharp 复制代码
finally
{
    transaction.Dispose();
}

ApprovalTransaction.Dispose() 用 Interlocked.Exchange 保证只执行一次,逐个释放仓储,最后释放 IUnitOfWork。


三、写序列:以"同意"为例

把并发那篇的代码按事务边界重新看一遍,结构非常清晰:

csharp 复制代码
outcome = await ExecuteInTransactionAsync(async tx =>
{
    // 1) 业务状态原子推进
    var affected = await SetApprovalStatus<TBill>(tx.GetRepository<TBill>().UpdateDiy, status, ...)
        .Where(a => a.Id == billId && a.ApprovalStatus == ApprovalStatus.Pending && a.CurrentLevel == currentLevel)
        .ExecuteAffrowsAsync();
    if (affected <= 0) return ApprovalOperationOutcome.CreateConflict();

    // 2) 原子占用当前待办节点
    var claimed = await tx.GetRepository<SysApprovalRecord>().UpdateDiy...
        .ExecuteAffrowsAsync();
    if (claimed <= 0) return ApprovalOperationOutcome.CreateConflict();

    // 3) 或签:同节点其他待处理记录自动跳过
    ...

    // 4) 还有下一级 → 激活下一节点;否则本轮结束
    if (status == ApprovalStatus.Pending)
    {
        var next = await ...;
        if (next.Count == 0)
        {
            // 下一节点缺失属于流程数据不一致,必须回滚而不是让流程悬空
            throw new InvalidOperationException(
                $"审批流程数据不一致:单据 {billType}#{billId} 缺少第 {nextLevel} 级待办节点");
        }
        foreach (var record in next) record.IsCurrent = true;
        await ...ExecuteAffrowsAsync();
        return ApprovalOperationOutcome.Next(pending: next);
    }

    // 5) 本轮结束:回写发起记录
    ...
    return ApprovalOperationOutcome.Finished(status, resultRecord);
}, $"Approve:{billType}#{billId}");

四个写操作、一个"下一节点缺失"的异常点,全部在同一个 action 里。任何一处抛异常或提前 return 冲突,事务都不会提交。

注意 CreateConflict() 的处理方式:它不是抛异常,而是返回一个"冲突"结果,事务正常提交(因为没有任何已执行的写操作生效------affected = 0 时前面的 UPDATE 根本没改数据)。这是有意的:冲突是正常的业务结果,不是系统故障,不应该记成错误日志。


四、通知必须在事务提交之后

最容易搞错的一步是通知。看提交路径的写法:

csharp 复制代码
// 通知在事务提交之后发送
List<SysApprovalRecord> pendingToNotify;
try
{
    pendingToNotify = await ExecuteInTransactionAsync(async tx =>
    {
        ...
        return records.Where(x => x.IsCurrent).ToList();
    }, $"Submit:{billType}#{bill.Id}");
}
catch (Exception ex)
{
    return new ApprovalSubmitResult(false, GenericFailure(ex));
}

if (pendingToNotify is null)
{
    return new ApprovalSubmitResult(false, L("单据状态已发生变化,请刷新后重试"));
}

bill.ApprovalStatus = status;
bill.CurrentLevel = currentLevel;

// 事务已提交:此时才发送通知。通知失败不回滚审批。
if (_options.EnableNotification && pendingToNotify.Count > 0)
{
    await NotifyPendingSafeAsync(pendingToNotify);
}

三个设计决定:

(1)事务内只返回"待通知的数据",不发通知。

通知涉及站内信表写入和 SignalR 推送,把它放进事务会拉长事务时间,还可能因为推送失败把审批回滚掉。

(2)通知失败不回滚审批。

NotifyPendingSafeAsync / NotifyResultSafeAsync 内部做了异常隔离。审批已经成功落库,不能因为"消息没发出去"就撤销一次合法的审批。

(3)事务失败时绝不发通知。

因为通知代码在 catch 分支之外、事务提交之后,所以不可能出现"审批失败但通知说已通过"。测试直接锁定了这个行为:

text 复制代码
Notification_IsNotSentWhenTransactionFails
→ 制造脏数据让事务失败
→ 断言没有发出任何"审批成功"的通知

顺带一提,内存里的单据对象也是在事务成功之后才更新的:

csharp 复制代码
bill.ApprovalStatus = status;
bill.CurrentLevel = currentLevel;

如果放在事务里更新,事务回滚后内存对象就成了"和数据库不一致"的幽灵状态。


五、失败时返回什么

csharp 复制代码
private string GenericFailure(Exception ex)
{
    var traceId = Guid.NewGuid().ToString("N")[..16];
    _logger.LogError(ex, "审批处理失败,TraceId:{TraceId}", traceId);
    return LF("审批处理失败,请稍后重试。TraceId:{0}", traceId);
}

用户看到的是"审批处理失败,请稍后重试。TraceId:xxxx",运维能拿 TraceId 去日志里定位。数据库异常、表名、SQL 片段都不会出现在界面上。

测试 FailedApprove_DoesNotLeakExceptionDetail 锁定了这一点。


六、测试怎么保证"真的回滚了"

回滚这种能力,光看代码不够,必须有能制造失败的测试。ApprovalTransactionIntegrityTests.cs 的做法是:在事务中途人为制造失败,然后断言数据库里什么都没变。

测试 制造的失败 断言
Approve_WhenNextNodeActivationFails_RollsBackBillRecordAndHistory 下一节点激活失败 业务状态、审批记录、历史全部回滚
Reject_WhenFinalUpdateFails_RollsBackBillAndRecords 最后一处更新失败 单据与记录都回到操作前
Revoke_WhenFinalUpdateFails_RollsBackEverything 撤回收尾失败 撤回相关写入全部回滚
Approve_WhenNextNodeMissing_RollsBackBillAndRecord 删除二级待办节点(脏数据) 一级同意不生效,流程不悬空
Submit_WhenRecordInsertFails_RollsBackBillStatus 插入审批流水失败 业务状态不进入"审批中"
UnitOfWork_RepositoryIsBoundToCurrentTransaction ------ 仓储确实绑在事务连接上
ApprovalWrites_AlwaysUseCurrentTenantOrm ------ 审批读写只走当前租户库
Notification_IsNotSentWhenTransactionFails 事务失败 不发送成功通知
RepeatedOperations_ProduceNoAdditionalSideEffects 重复操作 不产生额外副作用

其中 ApproveAndRevokeConcurrently_ResultMatchesWhicheverSucceeded 这类测试更进一步:并发操作之后,数据库的状态必须与"最终成功的那一个操作"完全一致,不存在混合态。


七、边界与注意事项

  1. 事务只覆盖单个数据库。 多租户模式下审批服务用的是当前租户库的 ORM,用户、角色、组织、单据、审批记录全在同一个库里,所以事务是有效的。不要把审批和主库的写入放进同一个"本地事务"------跨库事务需要分布式事务方案。
  2. 注入的 UnitOfWorkManager 要与 ORM 匹配。 构造函数里留了可注入参数:
csharp 复制代码
public ApprovalService(
    IFreeSql orm,
    ...
    UnitOfWorkManager? unitOfWorkManager = null)
{
    _orm = orm;
    ...
    _unitOfWorkManager = unitOfWorkManager ?? new UnitOfWorkManager(orm);
}

如果容器里注册了更合适的 UnitOfWorkManager,注入进来即可;不注入时用当前 ORM 新建一个。

  1. 事务内不要发起无关查询。 源码在提交前把审批人姓名一次性查好,注释写得很直白:
csharp 复制代码
// 审批人姓名一次性查好:事务内不再访问数据库(单连接的事务里再发起查询会触发 SQLite 锁等待)

单连接事务里做额外查询会放大锁竞争,尤其是 SQLite。

  1. DDL 不要进事务。 这条对任何用 CodeFirst 自动建表的场景都适用,不只是审批。
  2. 不要用 _orm 直连绕过事务。 一旦绕过,回滚就不再完整,而且问题只会在故障时暴露。

八、小结

审批事务的设计可以用一张表说清楚:

关注点 做法
事务边界 UnitOfWorkManager.Begin() → Commit() / Rollback()
数据访问 仓储必须 Binding 到同一个 UnitOfWork
DDL 事务开始前 SyncStructure,不混进事务
冲突 条件更新 affected = 0 返回冲突,不抛异常
数据不一致 抛异常 → 整体回滚(例如下一节点缺失)
通知 事务提交之后发送;失败不回滚审批
内存对象 事务成功后才同步状态
错误暴露 通用提示 + TraceId,不泄露数据库细节

一句话总结:审批要么全部成功,要么什么都没发生。 这句话在源码里就是"所有写操作都在同一个 action 里、都通过绑定了 UnitOfWork 的仓储执行、任何异常都让事务回滚"。


如果你正在给 .NET 10 + Blazor 后台加审批,事务与并发这两块最容易被低估。EasyAdminBlazor 的审批实现把这两件事写进了源码注释和测试用例,可以直接对照改自己的业务代码。

相关推荐
计算机魔术师1 小时前
我让AI教学生写前端,三天后课堂变了——Web教育者的集体反思
后端
Wx-bishekaifayuan2 小时前
springboot房屋租赁系统11574-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·sql·spring·课程设计
青山木2 小时前
秒杀系统设计(二):数据正确性——防超卖、分布式锁与一人一单
分布式·后端·mysql·中间件·架构
KING-WU5123 小时前
Linux 工具之 yum、vim、gcc
linux·运维·服务器·后端
蜗牛互联网3 小时前
GPT-6.1 Sol迁移指南:从token单价转向每任务成本门禁
java·人工智能·后端·gpt
IT_陈寒3 小时前
Redis键过期失效?这个坑我踩得明明白白
前端·人工智能·后端
蜗牛互联网3 小时前
HSTU在Dynamo-Triton中的AOTI与KV缓存验收方法
java·人工智能·后端·缓存
青山木3 小时前
秒杀系统设计(一):需求拆解与流量治理
java·数据库·redis·后端·架构
沫璃染墨3 小时前
《从零入门Linux系统篇(五十三):线程篇·六——线程互斥详解:从并发问题到mutex互斥锁》
linux·运维·服务器·开发语言·后端·架构·系统架构