审计不是"给表补几个 CreatedBy 字段",也不是"在业务方法里顺手记日志"。它本质上是系统级可追溯能力,设计目标是让系统在任何写路径下都能稳定回答四个问题:谁发起、改了什么、何时发生、通过哪条链路触发。
真正的难点不在 API 用法,而在系统设计阶段是否把审计定义成基础设施能力。这里聚焦两层落地:SaveChangesInterceptor 负责实体变更审计,CommandInterceptor 负责 SQL 执行审计,两者一起组成可观测、可追溯、可审计的最小闭环。
1. 问题背景:为什么审计必须在系统设计期落地
如果你刚开始做系统设计,通常会先把功能跑通,再逐步补监控、日志和审计。这条路径很正常,但系统从单一写入口演进到多入口后,审计会出现一些典型断层:
- HTTP 请求有
TraceId,但数据库变更记录无法关联到具体调用链路。 - Web 接口有审计字段,批处理、后台任务、集成事件消费链路没有统一审计。
- SQL 慢查询能看到语句本身,但看不到对应业务场景和调用来源。
- 合规追查时能找到结果,找不到完整过程和责任主体。
这些现象不是某个人"写错了",而是系统设计阶段还没有建立统一审计模型:
- 还没先定义统一的审计契约(身份、时间、来源、关联链路)。
- 写入路径没有统一审计入口,不同调用通道口径自然会分化。
- 变更审计和 SQL 观测没有打通,排障时很难快速复原完整链路。
- 只有开发约定,没有系统级自动机制,随着迭代推进就容易出现漏记。
这篇文章要解决的核心问题不是"把审计代码写到哪一层",而是"如何在系统层提供默认生效、可验证、可扩展的审计能力",并且让这套能力能跟着系统规模一起演进。
2. 原理解析:先拆职责,再落地拦截器
如果你是第一次从系统设计角度做审计,最稳妥的方法是先做职责拆分,再做实现落地。这里按"三步法"来理解:先统一实体变更审计,再统一 SQL 观测入口,最后明确拦截器边界。
2.1 第一步:用 SaveChangesInterceptor 统一实体变更审计
SaveChangesInterceptor 适合处理实体状态相关规则,例如:
Added时补CreatedAt/CreatedByModified时补UpdatedAt/UpdatedBy- 软删除时把
Deleted改写成Modified + IsDeleted
这类逻辑和实体状态强相关,放在拦截器里能覆盖所有调用路径,避免每个 Service 重复写一遍。
2.2 第二步:用 CommandInterceptor 统一 SQL 观测入口
DbCommandInterceptor 适合做 SQL 级别的统一观测:
- 慢 SQL 记录
- SQL 失败统一日志
- 参数快照和调用耗时
它不适合做业务决策,但非常适合做"排障入口统一化"。
2.3 第三步:守住边界,避免拦截器承载业务规则
拦截器是横切能力,不是业务规则容器。像"订单状态机是否允许跳转"这类业务校验,仍然应该放在领域或应用服务层。把业务规则塞进拦截器,后期只会让行为变得不可预测。
3. 示例代码:从分散审计到拦截器统一落地
3.1 问题写法:按入口各自补审计,系统层没有统一策略
csharp
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 统一补齐审计字段
先约定实体接口:
csharp
public interface IAuditableEntity
{
DateTimeOffset CreatedAt { get; set; }
string CreatedBy { get; set; }
DateTimeOffset? UpdatedAt { get; set; }
string? UpdatedBy { get; set; }
}
再实现审计拦截器:
csharp
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
csharp
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 统一生效
csharp
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:
csharp
var order = await _db.Orders
.TagWith("OrderQuery:GetByOrderNo")
.FirstOrDefaultAsync(x => x.OrderNo == orderNo, ct);
3.5 多 DbContext / 多租户场景:统一注册与上下文透传
单体示例里只注册一个 AppDbContext,但真实项目常见多个上下文(交易库、账务库、报表库)。如果每个上下文都手写一份注册代码,后续很容易出现"有的上下文挂了审计拦截器,有的没挂"。
更稳妥的做法是把拦截器挂载动作抽成统一扩展方法:
csharp
public static class DbContextOptionsBuilderExtensions
{
public static DbContextOptionsBuilder AddAuditInterceptors(
this DbContextOptionsBuilder options,
IServiceProvider serviceProvider)
{
options.AddInterceptors(
serviceProvider.GetRequiredService<AuditSaveChangesInterceptor>(),
serviceProvider.GetRequiredService<AuditCommandInterceptor>());
return options;
}
}
多个上下文复用同一套挂载:
csharp
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 治理,都会方便很多。