如何将EF Core的SaveChangesInterceptor、CommandInterceptor与审计落地实现一招多用的拦截器实战?

摘要:审计不是“给表补几个 CreatedBy 字段”,也不是“在业务方法里顺手记日志”。它本质上是系统级可追溯能力,设计目标是让系统在任何写路径下都能稳定回答四个问题:谁发起、改了什么、何时发生、通过哪条链路触发。 真正的难点不在 API 用法
审计不是“给表补几个 CreatedBy 字段”,也不是“在业务方法里顺手记日志”。它本质上是系统级可追溯能力,设计目标是让系统在任何写路径下都能稳定回答四个问题:谁发起、改了什么、何时发生、通过哪条链路触发。 真正的难点不在 API 用法,而在系统设计阶段是否把审计定义成基础设施能力。这里聚焦两层落地:SaveChangesInterceptor 负责实体变更审计,CommandInterceptor 负责 SQL 执行审计,两者一起组成可观测、可追溯、可审计的最小闭环。 1. 问题背景:为什么审计必须在系统设计期落地 如果你刚开始做系统设计,通常会先把功能跑通,再逐步补监控、日志和审计。这条路径很正常,但系统从单一写入口演进到多入口后,审计会出现一些典型断层: HTTP 请求有 TraceId,但数据库变更记录无法关联到具体调用链路。 Web 接口有审计字段,批处理、后台任务、集成事件消费链路没有统一审计。 SQL 慢查询能看到语句本身,但看不到对应业务场景和调用来源。 合规追查时能找到结果,找不到完整过程和责任主体。 这些现象不是某个人“写错了”,而是系统设计阶段还没有建立统一审计模型: 还没先定义统一的审计契约(身份、时间、来源、关联链路)。 写入路径没有统一审计入口,不同调用通道口径自然会分化。 变更审计和 SQL 观测没有打通,排障时很难快速复原完整链路。 只有开发约定,没有系统级自动机制,随着迭代推进就容易出现漏记。 这篇文章要解决的核心问题不是“把审计代码写到哪一层”,而是“如何在系统层提供默认生效、可验证、可扩展的审计能力”,并且让这套能力能跟着系统规模一起演进。 2. 原理解析:先拆职责,再落地拦截器 如果你是第一次从系统设计角度做审计,最稳妥的方法是先做职责拆分,再做实现落地。这里按“三步法”来理解:先统一实体变更审计,再统一 SQL 观测入口,最后明确拦截器边界。 2.1 第一步:用 SaveChangesInterceptor 统一实体变更审计 SaveChangesInterceptor 适合处理实体状态相关规则,例如: Added 时补 CreatedAt/CreatedBy Modified 时补 UpdatedAt/UpdatedBy 软删除时把 Deleted 改写成 Modified + IsDeleted 这类逻辑和实体状态强相关,放在拦截器里能覆盖所有调用路径,避免每个 Service 重复写一遍。 2.2 第二步:用 CommandInterceptor 统一 SQL 观测入口 DbCommandInterceptor 适合做 SQL 级别的统一观测: 慢 SQL 记录 SQL 失败统一日志 参数快照和调用耗时 它不适合做业务决策,但非常适合做“排障入口统一化”。 2.3 第三步:守住边界,避免拦截器承载业务规则 拦截器是横切能力,不是业务规则容器。像“订单状态机是否允许跳转”这类业务校验,仍然应该放在领域或应用服务层。把业务规则塞进拦截器,后期只会让行为变得不可预测。 3. 示例代码:从分散审计到拦截器统一落地 3.1 问题写法:按入口各自补审计,系统层没有统一策略 public sealed class OrderWriteService { private readonly AppDbContext _db; private readonly ICurrentUser _currentUser; public OrderWriteService(AppDbContext db, ICurrentUser currentUser) { _db = db; _currentUser = currentUser; } // HTTP 入口:有用户上下文,补了部分审计字段 public async Task UpdateFromHttpAsync(long id, decimal amount, CancellationToken ct) { var order = await _db.Orders.FirstAsync(x => x.Id == id, ct); order.Amount = amount; order.UpdatedAt = DateTimeOffset.UtcNow; order.UpdatedBy = _currentUser.UserId ?? "system"; await _db.SaveChangesAsync(ct); } // 后台任务入口:没有用户上下文,常见做法是直接跳过审计字段 public async Task UpdateFromSchedulerAsync(long id, OrderStatus status, CancellationToken ct) { var order = await _db.Orders.FirstAsync(x => x.Id == id, ct); order.Status = status; await _db.SaveChangesAsync(ct); } // 消费者入口:补了 UpdatedBy,但漏了 UpdatedAt public async Task UpdateFromEventConsumerAsync(long id, string externalStatus, CancellationToken ct) { var order = await _db.Orders.FirstAsync(x => x.Id == id, ct); order.ExternalStatus = externalStatus; order.UpdatedBy = "order-sync-consumer"; await _db.SaveChangesAsync(ct); } } 这套写法的核心问题不是某个开发者“忘了写”,而是审计规则绑定在调用入口。入口一多,规则必然分裂:字段含义不一致、更新时间口径不一致、排障链路也无法统一。 3.2 优化写法一:SaveChangesInterceptor 统一补齐审计字段 先约定实体接口: public interface IAuditableEntity { DateTimeOffset CreatedAt { get; set; } string CreatedBy { get; set; } DateTimeOffset? UpdatedAt { get; set; } string? UpdatedBy { get; set; } } 再实现审计拦截器: using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; public sealed class AuditSaveChangesInterceptor : SaveChangesInterceptor { private readonly ICurrentUser _currentUser; private readonly TimeProvider _timeProvider; public AuditSaveChangesInterceptor(ICurrentUser currentUser, TimeProvider timeProvider) { _currentUser = currentUser; _timeProvider = timeProvider; } public override InterceptionResult<int> SavingChanges( DbContextEventData eventData, InterceptionResult<int> result) { ApplyAudit(eventData.Context); return base.SavingChanges(eventData, result); } public override ValueTask<InterceptionResult<int>> SavingChangesAsync( DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default) { ApplyAudit(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); } private void ApplyAudit(DbContext? dbContext) { if (dbContext is null) { return; } var now = _timeProvider.GetUtcNow(); var userId = _currentUser.UserId ?? "system"; foreach (var entry in dbContext.ChangeTracker.Entries<IAuditableEntity>()) { if (entry.State == EntityState.Added) { entry.Entity.CreatedAt = now; entry.Entity.CreatedBy = userId; entry.Entity.UpdatedAt = now; entry.Entity.UpdatedBy = userId; } else if (entry.State == EntityState.Modified) { entry.Entity.UpdatedAt = now; entry.Entity.UpdatedBy = userId; } } } } 3.3 优化写法二:CommandInterceptor 统一记录慢 SQL 和失败 SQL using System.Data.Common; using Microsoft.EntityFrameworkCore.Diagnostics; public sealed class AuditCommandInterceptor : DbCommandInterceptor { private readonly ILogger<AuditCommandInterceptor> _logger; private static readonly TimeSpan SlowThreshold = TimeSpan.FromMilliseconds(300); public AuditCommandInterceptor(ILogger<AuditCommandInterceptor> logger) { _logger = logger; } public override DbDataReader ReaderExecuted( DbCommand command, CommandExecutedEventData eventData, DbDataReader result) { LogIfSlow(command, eventData); return base.ReaderExecuted(command, eventData, result); } public override int NonQueryExecuted( DbCommand command, CommandExecutedEventData eventData, int result) { LogIfSlow(command, eventData); return base.NonQueryExecuted(command, eventData, result); } public override object? ScalarExecuted( DbCommand command, CommandExecutedEventData eventData, object? result) { LogIfSlow(command, eventData); return base.ScalarExecuted(command, eventData, result); } public override void CommandFailed(DbCommand command, CommandErrorEventData eventData) { _logger.LogError( eventData.Exception, "EF SQL failed. CommandText={CommandText}", command.CommandText); base.CommandFailed(command, eventData); } private void LogIfSlow(DbCommand command, CommandExecutedEventData eventData) { if (eventData.Duration < SlowThreshold) { return; } _logger.LogWarning( "EF slow SQL. DurationMs={DurationMs}, CommandText={CommandText}", eventData.Duration.TotalMilliseconds, command.CommandText); } } 3.4 注册方式:把拦截器挂到 DbContext 统一生效 builder.Services.AddScoped<AuditSaveChangesInterceptor>(); builder.Services.AddScoped<AuditCommandInterceptor>(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddDbContext<AppDbContext>((sp, options) => { options.UseSqlServer(builder.Configuration.GetConnectionString("Default")); options.AddInterceptors( sp.GetRequiredService<AuditSaveChangesInterceptor>(), sp.GetRequiredService<AuditCommandInterceptor>()); }); 如果你希望慢 SQL 日志能快速关联到业务场景,查询时建议配合 TagWith: var order = await _db.Orders .TagWith("OrderQuery:GetByOrderNo") .FirstOrDefaultAsync(x => x.OrderNo == orderNo, ct); 3.5 多 DbContext / 多租户场景:统一注册与上下文透传 单体示例里只注册一个 AppDbContext,但真实项目常见多个上下文(交易库、账务库、报表库)。如果每个上下文都手写一份注册代码,后续很容易出现“有的上下文挂了审计拦截器,有的没挂”。 更稳妥的做法是把拦截器挂载动作抽成统一扩展方法: public static class DbContextOptionsBuilderExtensions { public static DbContextOptionsBuilder AddAuditInterceptors( this DbContextOptionsBuilder options, IServiceProvider serviceProvider) { options.AddInterceptors( serviceProvider.GetRequiredService<AuditSaveChangesInterceptor>(), serviceProvider.GetRequiredService<AuditCommandInterceptor>()); return options; } } 多个上下文复用同一套挂载: builder.Services.AddDbContext<AppDbContext>((sp, options) => { options.UseSqlServer(builder.Configuration.GetConnectionString("Main")); options.AddAuditInterceptors(sp); }); builder.Services.AddDbContext<BillingDbContext>((sp, options) => { options.UseSqlServer(builder.Configuration.GetConnectionString("Billing")); options.AddAuditInterceptors(sp); }); 如果是多租户系统,建议在 ICurrentUser 之外再引入 ICurrentTenant,并在 SaveChangesInterceptor 里统一写入 TenantId 或校验租户边界,避免不同入口出现租户字段口径不一致。 4. 总结 EF Core 拦截器的价值,不在于“写得更高级”,而在于把重复且容易漏的规则变成基础设施能力。SaveChangesInterceptor 解决实体审计一致性,CommandInterceptor 解决 SQL 观测统一入口。先把这两层根基打牢,后续无论是做审计追踪、慢 SQL 治理,都会方便很多。 实验源码:ef-core/AuditlogsDemo