



























审计不是“给表补几个 CreatedBy 字段”,也不是“在业务方法里顺手记日志”。它本质上是系统级可追溯能力,设计目标是让系统在任何写路径下都能稳定回答四个问题:谁发起、改了什么、何时发生、通过哪条链路触发。
真正的难点不在 API 用法,而在系统设计阶段是否把审计定义成基础设施能力。这里聚焦两层落地:SaveChangesInterceptor 负责实体变更审计,CommandInterceptor 负责 SQL 执行审计,两者一起组成可观测、可追溯、可审计的最小闭环。
如果你刚开始做系统设计,通常会先把功能跑通,再逐步补监控、日志和审计。这条路径很正常,但系统从单一写入口演进到多入口后,审计会出现一些典型断层:
TraceId,但数据库变更记录无法关联到具体调用链路。这些现象不是某个人“写错了”,而是系统设计阶段还没有建立统一审计模型:
这篇文章要解决的核心问题不是“把审计代码写到哪一层”,而是“如何在系统层提供默认生效、可验证、可扩展的审计能力”,并且让这套能力能跟着系统规模一起演进。
如果你是第一次从系统设计角度做审计,最稳妥的方法是先做职责拆分,再做实现落地。这里按“三步法”来理解:先统一实体变更审计,再统一 SQL 观测入口,最后明确拦截器边界。
SaveChangesInterceptor 适合处理实体状态相关规则,例如:
Added 时补 CreatedAt/CreatedByModified 时补 UpdatedAt/UpdatedByDeleted 改写成 Modified + IsDeleted这类逻辑和实体状态强相关,放在拦截器里能覆盖所有调用路径,避免每个 Service 重复写一遍。
DbCommandInterceptor 适合做 SQL 级别的统一观测:
它不适合做业务决策,但非常适合做“排障入口统一化”。
拦截器是横切能力,不是业务规则容器。像“订单状态机是否允许跳转”这类业务校验,仍然应该放在领域或应用服务层。把业务规则塞进拦截器,后期只会让行为变得不可预测。
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);
}
}
这套写法的核心问题不是某个开发者“忘了写”,而是审计规则绑定在调用入口。入口一多,规则必然分裂:字段含义不一致、更新时间口径不一致、排障链路也无法统一。
先约定实体接口:
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;
}
}
}
}
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);
}
}
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);
单体示例里只注册一个 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 或校验租户边界,避免不同入口出现租户字段口径不一致。
EF Core 拦截器的价值,不在于“写得更高级”,而在于把重复且容易漏的规则变成基础设施能力。SaveChangesInterceptor 解决实体审计一致性,CommandInterceptor 解决 SQL 观测统一入口。先把这两层根基打牢,后续无论是做审计追踪、慢 SQL 治理,都会方便很多。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。