跳到主要内容
版本:3.0.0

实体审计字段自动填充(Aegis.Core.FreeSql.AuditValue)

当系统使用 FreeSql 持久化,实体上存在一组公共审计字段(创建时间 / 创建人 / 更新时间 / 更新人 / 租户标识等),希望在 Insert / Update 时由框架统一填充,避免在业务代码里逐处赋值时,可以使用 Aegis.Core.FreeSql.AuditValue

适用场景:

  • 实体上的公共审计字段需要在写入时自动填充,避免业务侧逐处赋值与漏写
  • 同一字段在不同实体上有不同填充策略(统一规则 + 个别实体特化覆盖)
  • 数据迁移、历史回放等场景,需要在一段范围内临时停掉自动填充

不适用场景:

  • 非 FreeSql 的 ORM 场景(EFCore / Dapper / 原生 ADO.NET 等)
  • 走非实体写入路径(如 Orm.Update<T>(id).Set(...) 字段级 SQL 更新、原生 SQL、ExecuteSql),不会触发审计填充
  • 物理 Delete 前需要改写字段的场景(FreeSql 不暴露 Delete 钩子)

组件概览

字段说明
组件名称实体审计字段自动填充
NuGet 包Aegis.Core.FreeSql.AuditValue
组件定位封装 FreeSql Aop.AuditValue 事件,按声明的规则在 Insert / Update 时自动填充实体字段
引入方式NuGet 包引用 + Startup 中调用 AddFreeSqlAuditValue<TDb> 注册规则
依赖Aegis.Core.FreeSqlFreeSql
是否可扩展

如何引入

NuGet 包

角色NuGet 包是否必须说明
主组件Aegis.Core.FreeSql.AuditValue提供审计规则注册 API、FreeSql Aop 接入逻辑
依赖Aegis.Core.FreeSql / FreeSql

安装 Aegis.Core.FreeSql.AuditValue 包:

<ItemGroup>
<PackageReference Include="Aegis.Core.FreeSql.AuditValue" Version="3.1.0" />
</ItemGroup>

快速使用

下面演示从零接入的完整示例:定义实体基类 → 定义业务实体 → 在 Startup 中注册规则 → 业务代码无感知地完成 Insert / Update。

第一步:定义实体基类,集中放置审计字段(推荐做法)

把所有公共审计字段集中到一个抽象基类中,业务实体一律继承它,避免在每个实体里重复定义:

using FreeSql.DataAnnotations;

/// <summary>
/// 实体基类:集中放置公共审计字段
/// </summary>
public abstract class EntityBase
{
/// <summary>
/// 租户标识
/// </summary>
[Column(Name = "tenant_id", StringLength = 50)]
public string TenantId { get; set; } = null!;

/// <summary>
/// 创建时间
/// </summary>
[Column(Name = "create_time")]
public DateTime CreateTime { get; set; }

/// <summary>
/// 创建人编码
/// </summary>
[Column(Name = "create_code", StringLength = 50)]
public string CreateCode { get; set; } = null!;

/// <summary>
/// 更新时间
/// </summary>
[Column(Name = "update_time")]
public DateTime? UpdateTime { get; set; }

/// <summary>
/// 更新人编码
/// </summary>
[Column(Name = "update_code", StringLength = 50)]
public string UpdateCode { get; set; } = null!;
}

第二步:业务实体继承基类

业务实体只关心自己的业务字段,审计字段由基类提供:

using FreeSql.DataAnnotations;

/// <summary>
/// 订单
/// </summary>
[Table(Name = "order")]
public class OrderEntity : EntityBase
{
/// <summary>
/// 主键
/// </summary>
[Column(Name = "id", IsPrimary = true)]
public long Id { get; set; }

/// <summary>
/// 订单号
/// </summary>
[Column(Name = "order_no", StringLength = 50)]
public string OrderNo { get; set; } = null!;

/// <summary>
/// 金额
/// </summary>
[Column(Name = "amount")]
public decimal Amount { get; set; }
}

第三步:在 Startup 中注册规则

必须在 AddDbSource<TDb>(即注册 FreeSql 实例)之后再调用 AddFreeSqlAuditValue<TDb> 该方法内部需要解析已注册的 IFreeSql<TMark> 实例并挂载 Aop.AuditValue 事件。 多个 TDb 各自独立注册,互不影响。

using Aegis.Core.FreeSql;
using Aegis.Core.FreeSql.AuditValue;

public sealed class Startup : ICustomStartup
{
public void ConfigureServices(IServiceCollection services)
{
// 1) 先注册 FreeSql 数据源
services.AddDbSource<MyDb>(x =>
{
x.ConnectionString = ConfigManager.Get("SqlConnection");
x.DataType = "PostgreSQL";
// …… FreeSql 其他配置
});

// 2) 再注册审计字段自动填充规则:基类规则一次注册,所有派生实体生效
services.AddFreeSqlAuditValue<MyDb>(options =>
{
options.For<EntityBase>()
.IncludeAllDerived() // 让规则对EntityBase所有的派生实体生效
.OnInsert(x => x.CreateTime, _ => DateTime.Now)
.OnInsert(x => x.CreateCode, _ => "0001")
.OnInsert(x => x.TenantId, _ => "9999")
.OnUpdate(x => x.UpdateTime, _ => DateTime.Now, AuditValueOverwriteBehavior.Always)
.OnUpdate(x => x.UpdateCode, _ => "0001", AuditValueOverwriteBehavior.Always);
});
}
}

第四步:业务代码无感知写入

public class OrderService(BaseRepository<OrderEntity, long> repo)
{
public async Task CreateAsync(decimal amount)
{
var entity = new OrderEntity
{
OrderNo = Guid.NewGuid().ToString("N"),
Amount = amount,
};
// 业务代码不需要赋值 CreateTime / CreateCode / TenantId 等审计字段
await repo.InsertAsync(entity);
}

public async Task SettleAsync(long id)
{
var entity = await repo.FindAsync(id);
entity!.Amount += 1;
// 不需要赋值 UpdateTime / UpdateCode
await repo.UpdateAsync(entity);
}
}

哪些写入路径会触发审计填充

Aegis.Core.FreeSql.AuditValue 是对 FreeSql 提供的 fsql.Aop.AuditValue AOP 事件的一层封装,本身不发起不拦截 SQL,只在 FreeSql 触发该事件时按声明的规则改写属性值。根据 FreeSql 官方文档审计属性值的描述与示例,AuditValue 事件是按实体属性驱动的:只要写入入口接收的是「完整的实体对象」,FreeSql 就会在把它翻译成 Insert / Update SQL 之前,遍历实体上的每个列,逐列触发一次事件。

判断口径只有一条:调用是否传入了完整的实体(或实体集合)。传实体的写入触发审计填充,传表达式片段、字段值、DTO 或原始 SQL 的写入不会触发。

会触发的写入路径

// Insert:单条 / 批量 / BulkCopy / PgCopy 都走 AppendData,逐列触发
freeSql.Insert(entity).ExecuteAffrows();
freeSql.Insert(list).ExecuteAffrows();
freeSql.Insert(entity).ExecuteSqlBulkCopy();
freeSql.Insert(entity).ExecutePgCopy();

// Update:必须通过 SetSource 提供完整实体
freeSql.Update<T>().SetSource(entity).ExecuteAffrows();
freeSql.Update<T>().SetSource(list).Where(...).ExecuteAffrows();
freeSql.Update<T>().SetSourceIgnore(entity).ExecuteAffrows();

// InsertOrUpdate
freeSql.InsertOrUpdate<T>().SetSource(entity).ExecuteAffrows();

// DbContext / Repository / 仓储基类,内部最终都走上面三条
dbCtx.Set<T>().Add(entity); dbCtx.SaveChanges();
dbCtx.Set<T>().Update(entity); dbCtx.SaveChanges();
dbCtx.Set<T>().AddOrUpdate(entity); dbCtx.SaveChanges();
repo.Insert(entity); repo.Update(entity); repo.InsertOrUpdate(entity);
repo.SaveMany(entity, "navProp"); // 级联保存最终拆成 Insert / Update

不会触发的写入路径

// 1) Update 走表达式 / DTO / 自增 / 原生 SQL —— 没有"实体"可供遍历
freeSql.Update<T>().Set(a => a.Name, "x").Where(...).ExecuteAffrows();
freeSql.Update<T>().Set(a => a.Hits + 1).Where(...).ExecuteAffrows();
freeSql.Update<T>().SetRaw("Name = ?n", new { n = "x" }).Where(...).ExecuteAffrows();
freeSql.Update<T>().SetDto(new { Name = "x" }).Where(...).ExecuteAffrows();
freeSql.Update<T>().SetDto(dict).Where(...).ExecuteAffrows();

// 2) 所有删除路径(FreeSql 在 Delete 链路上没有审计埋点)
freeSql.Delete<T>().Where(...).ExecuteAffrows();
repo.Delete(entity);
dbCtx.Set<T>().Remove(entity);

// 3) 所有查询路径
freeSql.Select<T>().ToList(); freeSql.Select<T>().First(); freeSql.Queryable<T>()...;

// 4) 原生 SQL / ADO —— 完全绕过 AOP 层
freeSql.Ado.ExecuteNonQuery("update ...");
freeSql.Ado.Query<T>(...);
freeSql.Select<T>().WithSql("select ...").ToList();

// 5) DDL / 结构同步
freeSql.CodeFirst.SyncStructure<T>(); // 走 SyncStructureBefore/After,不是 AuditValue

触发时机与几个易混淆的细节

  • 触发发生在"加入数据时",不在 Execute*Insert(entity) / Update<T>().SetSource(entity) 这一步就已经触发并改写实体属性,后续的 ExecuteAffrows / ToSql / ExecuteIdentity 不会再触发一次。这也意味着 Update<T>().SetSource(entity).Set(x => x.UpdateTime, ...) 后面那个手动 Set覆盖规则委托写入的 UpdateTime
  • 触发对每个实体的每个列触发一次:批量写入 N 条 × M 列 = N×M 次回调,因此规则委托内的取值应当尽量轻量,重计算 / IO 请放入实体快照做按行缓存。
  • IgnoreColumns 不影响触发:被忽略的列照样会触发回调;规则委托若改写了它,FreeSql 仍会把它强制纳入最终 SQL。
  • InsertOrUpdate<T>().IfExistsDoNothing():SetSource 阶段照常触发规则委托,即使最终 SQL 不会真正更新该行。
  • DbContext 跟踪场景下"未变更"也会触发dbCtx.Update(entity) 时规则委托会被调用,即便变更跟踪比对结果是无字段变化、最终未生成 SQL。

应对建议

如果某条更新链路上必须自动填充审计字段,请改走实体路径——先把实体查出(或构造一个仅含主键 + 待更新字段的对象),修改字段后调用 UpdateAsync(entity);不要用 Update<T>().Set(...) 之类的字段级表达式 API。

配合 Aegis 认证组件使用

本组件不依赖任何认证组件,规则委托内「当前用户从哪里取」完全由业务方决定。但审计字段里的「创建人 / 更新人 / 租户」必须先经过认证才能拿到,而 Aegis 认证组件会把登录用户信息存放在 AsyncLocal<CustomUser>,规则委托里通过静态入口 CurrentUser.Value 即可读取:

using Aegis.Core.Authentication;

services.AddFreeSqlAuditValue<MyDb>(options =>
{
// 默认仅在字段值为默认值时才覆盖,不误覆盖已有值
options.DefaultOverwriteBehavior = AuditValueOverwriteBehavior.WhenDefault;
// 生产推荐 LogAndSkip,避免审计填充失败影响业务写入;开发期可改 Throw 快速暴露问题
options.ErrorBehavior = AuditValueErrorBehavior.LogAndSkip;

// 整个 CustomUser 一次性快照——所有列回调拿到的是同一份对象
options.WithEntitySnapshot(snap =>
{
snap.Capture("user", _ => CurrentUser.Value); // 请求外为 null
snap.Capture("ts", _ => DateTime.Now);
});

options.For<EntityBase>()
.IncludeAllDerived()
// Insert:拿不到用户时跳过该列,保护 NOT NULL 约束
.OnInsert(x => x.CreateTime, ctx => AuditValueDecision.Set(ctx.GetSnapshot<DateTime>("ts")))
.OnInsert(x => x.CreateCode, ctx => AuditValueDecision.SetOrSkipIfNull(ctx.GetSnapshot<CustomUser?>("user")?.UserCode))
.OnInsert(x => x.CreateName, ctx => AuditValueDecision.SetOrSkipIfNull(ctx.GetSnapshot<CustomUser?>("user")?.UserName))
.OnInsert(x => x.TenantId, ctx => AuditValueDecision.SetOrSkipIfNull(ctx.GetSnapshot<CustomUser?>("user")?.TenantId))
// Update:默认假设由已登录用户发起,拿不到也照常 Set(不一致问题见下方要点)
.OnUpdate(x => x.UpdateTime, ctx => AuditValueDecision.Set(ctx.GetSnapshot<DateTime>("ts")), AuditValueOverwriteBehavior.Always)
.OnUpdate(x => x.UpdateCode, ctx => AuditValueDecision.Set(ctx.GetSnapshot<CustomUser?>("user")?.UserCode), AuditValueOverwriteBehavior.Always)
.OnUpdate(x => x.UpdateName, ctx => AuditValueDecision.Set(ctx.GetSnapshot<CustomUser?>("user")?.UserName), AuditValueOverwriteBehavior.Always);
});

几处需要解释的设计选择:

  • 只用一个快照 key 缓存整个 CustomUser,而不是按字段拆三个 key:示例里 Capture("user", ...) 一次性把当前用户对象缓存下来,后面 CreateCode / CreateName / TenantId 三列都从这一份对象上 ?.UserCode / ?.UserName / ?.TenantId 取值,保证同一行的几列一定属于同一个人;如果拆成 uid / uname / tid 三个 key 分别 Capture,效果是等价的,但每个 key 的工厂里都要重复写一遍「读 CurrentUser → 再取一个属性」,没什么收益。

  • Insert 列用 SetOrSkipIfNull,Update 列直接 Set:这是有意保留的非对称——业务的预期是「数据由登录用户创建并修改」,请求作用域外(后台定时任务、IHostedService、应用初始化、ClientAuthorization 机器到机器调用)不应触发 Update。如果业务确实存在非请求场景下的 Update(如系统刷历史数据),把对应 Update 委托也改成 SetOrSkipIfNull 即可。

  • CreateTime / UpdateTimeSet 而不是 SetOrSkipIfNull:快照里的 DateTime.Now 永远非 null(值类型),用 SetOrSkipIfNull 反而暗示「这里可能为 null」,会误导读者。

  • 非请求场景下给审计字段一个兜底值:后台定时任务、IHostedService、应用初始化这类入口没有经过认证流程,CurrentUser.Value 拿到的是 null,按上面的示例 Insert 路径会跳过审计列。如果业务希望这些场景下也写入一个固定的"系统账号"留痕,在快照工厂里整体兜底即可:

    snap.Capture("user", _ => CurrentUser.Value ?? new CustomUser
    {
    UserCode = "system",
    UserName = "系统",
    TenantId = "-",
    });

    具体填什么值(system / admin / - 等)以及对应的租户号由业务方决定,本组件不替业务方做选择。


具体使用详情

单条规则该怎么写

每条规则都需要回答三个问题:何时触发?已有值时是否覆盖?委托返回什么? 三者贯穿下文所有注册方式。

何时触发:Insert / Update / InsertOrUpdate

每条规则都要声明一个触发器,决定规则在什么写入操作下生效:

触发器生效时机适用字段
Insert仅 Insert 时填充创建时间、创建人、租户号等创建期一次性写入的字段
Update仅 Update 时填充更新时间、更新人等每次更新都需刷新的字段
InsertOrUpdateInsert 和 Update 都填充"最后修改时间"这类无论新增还是更新都需要刷新的字段

后文「规则注册的三种方式」里,每种方式都有对应的触发器写法:

  • 方式一 For<T>():直接用链式方法名表达——.OnInsert(...) / .OnUpdate(...) / .OnInsertOrUpdate(...)
  • 方式二 MatchColumn / 方式三 MatchProperty:触发器作为第二个参数显式传入,例如 MatchColumn("update_time", AuditValueType.Update, ...);如果触发器是 InsertOrUpdate,也可改用便捷方法 MatchColumnOnInsertOrUpdate / MatchPropertyOnInsertOrUpdate 省去枚举参数。

已有值时是否覆盖:AuditValueOverwriteBehavior

规则被触发时,如果业务代码已经给字段赋了非默认值(例如数据导入时手工指定了历史时间),框架要不要把它覆盖掉?由可选参数 AuditValueOverwriteBehavior 决定:

选项行为典型用法
WhenDefault仅当字段为 CLR 默认值时才写入创建时间、创建人——尊重业务侧已经赋的值(如数据导入指定的历史时间)
Always总是覆写更新时间、更新人——每次 Update 都要刷新,业务侧赋的值会被丢弃

三种配置方式中,该参数都作为可选的最后一个参数:

// 方式二
options.MatchColumn("update_time", AuditValueType.Update, _ => DateTime.Now, AuditValueOverwriteBehavior.Always);

// 方式一
options.For<OrderEntity>()
.OnUpdate(x => x.UpdateTime, _ => DateTime.Now, AuditValueOverwriteBehavior.Always);

不传该参数时沿用 options.DefaultOverwriteBehavior 全局默认值(见后文 全局选项 → 默认覆盖行为)。

委托返回什么:直接返回值,或返回 AuditValueDecision

所有注册 API(.OnInsert / .OnUpdate / .OnInsertOrUpdate / MatchColumn / MatchProperty)都提供两种委托重载,按需选用即可:

委托签名返回值含义适用场景
Func<AuditValueContext, object?>直接返回要写入的值(内部按 Set(value) 处理)大多数场景——拿到值就写下去
Func<AuditValueContext, AuditValueDecision>显式返回写入决策需要在委托内根据条件决定"跳过该列"或"为 null 时跳过"

第一种是默认形态,写起来最直观:

options.For<EntityBase>()
.IncludeAllDerived()
.OnInsert(x => x.CreateTime, ctx => DateTime.Now) // 直接返回 DateTime
.OnInsert(x => x.CreateCode, ctx => CurrentUser.Value?.UserCode); // 直接返回字符串(可能为 null,会写 null)

第二种用 AuditValueDecision 表达更精细的写入意图,目前有三种决策:

决策语义典型场景
AuditValueDecision.Set(value)写入 value(允许显式 null)等价于第一种重载,一般不必显式调用
AuditValueDecision.Skip跳过本次填充,该列不出现在 SQL 中拿不到合法值且不能写 null(非空列)
AuditValueDecision.SetOrSkipIfNull(value)value 为 null 时等价于 Skip,否则等价于 Set(value)取值源可能为 null + 目标列 NOT NULL 的常见组合
options.For<EntityBase>()
.IncludeAllDerived()
// 创建类字段:拿不到用户时跳过该列,保护非空约束
.OnInsert(x => x.CreateCode, ctx => AuditValueDecision.SetOrSkipIfNull(CurrentUser.Value?.UserCode))
// 更新类字段:时间戳总是能拿到,直接 Set
.OnUpdate(x => x.UpdateTime, ctx => AuditValueDecision.Set(DateTime.Now), AuditValueOverwriteBehavior.Always);

要点:

  • 默认使用 object? 重载即可;只有需要表达「跳过该列」语义时才需要显式返回 AuditValueDecision
  • 直接返回 null(或 Set(null))会显式把 null 写入数据库(适用于可空列希望被清空的场景);NOT NULL 列请改用 SetOrSkipIfNull 避免约束异常。
  • SkipAuditValueOverwriteBehavior 无关——一旦返回 Skip,不论是否 Always,该列都不会被覆写。

规则注册的三种方式

组件提供三种匹配维度不同的注册方式,按项目情况选择:

方式入口 API命中维度适用场景
options.For<TEntity>()实体类型 + 属性表达式新项目,对实体有完全控制权,所有实体统一继承 EntityBase,追求编译期检查与重构安全
options.MatchColumn(...)数据库列名数据库列命名规范但实体侧无统一基类
options.MatchProperty(...)C# 属性名实体属性命名规范但数据库跨多套 schema

三种方式可任意组合,命中优先级见下文"多方式叠加"。

方式一:按实体类型配置(For<TEntity>()

新项目接入的首选方式:用强类型表达式选择属性,编译期检查字段名、重构安全,IDE 也能跳转引用,避免任何魔法字符串。

推荐写法:抽出一个实体基类 EntityBase 集中放置审计字段,所有业务实体一律继承它,规则只在基类上注册一次、自动扩散到所有派生实体——这是该组件最简洁、最易维护的接入形态:

services.AddFreeSqlAuditValue<MyDb>(options =>
{
options.For<EntityBase>()
.IncludeAllDerived() // 关键:让规则对 EntityBase 的所有派生实体生效
.OnInsert(x => x.CreateTime, _ => DateTime.Now)
.OnUpdate(x => x.UpdateTime, _ => DateTime.Now, AuditValueOverwriteBehavior.Always);
});

如果只想对某个具体实体单独配置(例如个别表的填充策略与基类不同),可针对该实体单独 For<T>() 注册——同字段后注册者优先,会覆盖从基类扩散下来的规则:

options.For<OrderEntity>()
.OnInsert(x => x.TenantId, ctx => ctx.GetService<ITenantContext>()?.Code ?? "0000");

要点:

  • 不调用 IncludeAllDerived() 时,规则只对 TEntity 本身生效,不会扩散到派生类。
  • IncludeAllDerived() 一旦调用,后续同一链上所有规则都会向派生类型扩散。
  • 表达式只支持简单的属性访问(x => x.Foo),不支持嵌套或表达式计算。

方式二:按数据库列名配置(MatchColumn

适用于希望"凡是数据库列名为 xxx 的字段,都按同一套规则填充"的场景,常见于没有公共基类、但 DB 表结构层面已有统一审计列约定的历史项目。命中维度是 FreeSql 解析得到的真实数据库列名(来自 [Column(Name = "...")],未声明则回退到属性名)。

推荐用一个集中放置常量的静态类管理列名,避免散落在各处的魔法字符串:

/// <summary>
/// 审计列名常量(与数据库 DDL 保持一致)
/// </summary>
public static class AuditColumns
{
public const string CreateTime = "create_time";
public const string UpdateTime = "update_time";
public const string TenantId = "tenant_id";
}

services.AddFreeSqlAuditValue<MyDb>(options =>
{
options.MatchColumn(AuditColumns.CreateTime, AuditValueType.Insert, _ => DateTime.Now);
options.MatchColumn(AuditColumns.UpdateTime, AuditValueType.Update, _ => DateTime.Now);
});

要点:

  • 列名严格区分大小写,必须与 FreeSql 解析后的真实列名完全一致。
  • 推荐集中定义常量类管理列名(如上例的 AuditColumns),避免魔法字符串散落。
  • 同一列可以分别注册 Insert / Update 规则;若需要 Insert 和 Update 都触发,可直接用 AuditValueType.InsertOrUpdate,或便捷方法 MatchColumnOnInsertOrUpdate

方式三:按 C# 属性名配置(MatchProperty

适用于"实体侧统一规范了属性名,但数据库列名不一致(历史遗留、跨多套表结构)"的场景。命中维度是 C# 属性名

services.AddFreeSqlAuditValue<MyDb>(options =>
{
options.MatchProperty(nameof(EntityBase.CreateTime), AuditValueType.Insert, _ => DateTime.Now);
options.MatchProperty(nameof(EntityBase.UpdateTime), AuditValueType.Update, _ => DateTime.Now);
});

只要实体里有 CreateTime / UpdateTime 属性,无论它们对应的数据库列名叫什么(gmt_createcreated_atc_time …),都会被命中。

要点:

  • 属性名严格区分大小写,推荐用 nameof 替代魔法字符串。
  • 当数据库列名规范、与属性名一致时,方式二与方式三效果等价——这种情况下优先用方式二(与 DB schema 直接对应,更贴近数据本意)。
  • 当数据库列名混乱、但实体属性命名统一时,方式三更省心。

同方式内的重复配置:后写覆盖先写

无论哪种方式,同一字段、同一触发器、注册了多条规则时,后注册者优先。这条规则常用于"先写默认值、按条件覆盖":

services.AddFreeSqlAuditValue<MyDb>(options =>
{
// 通用默认:所有派生实体的 TenantId 取自当前租户上下文
options.For<EntityBase>()
.IncludeAllDerived()
.OnInsert(x => x.TenantId, ctx => ctx.GetService<ITenantContext>()?.Code ?? "0000");

// 个别实体特化:系统级表强制写固定值
options.For<SystemLogEntity>()
.OnInsert(x => x.TenantId, _ => "SYSTEM");
});

SystemLogEntityEntityBase 的派生类,但因为它显式注册了自己的规则,会覆盖基类扩散下来的那一条。

多方式叠加:优先级

当同一字段同时被多种方式匹配到时,运行期按下列顺序查找命中规则,先找到即用:

  1. 实体专属规则For<T>() 注册)优先于全局规则MatchColumn / MatchProperty
  2. 同一层级内,按 DB 列名匹配的规则优先于按属性名匹配的规则
  3. 同一匹配维度内,后注册者优先

实践中一种常见的叠加模式:用方式一给新模块的基类做主规则,再用方式二给少量"未继承基类的历史实体"打补丁:

services.AddFreeSqlAuditValue<MyDb>(options =>
{
// 新模块:基类强类型规则
options.For<EntityBase>()
.IncludeAllDerived()
.OnInsert(x => x.CreateTime, _ => DateTime.Now)
.OnUpdate(x => x.UpdateTime, _ => DateTime.Now);

// 历史遗留实体:未继承基类,但 DB 列名规范,按列名补充规则
options.MatchColumn(AuditColumns.CreateTime, AuditValueType.Insert, _ => DateTime.Now);
options.MatchColumn(AuditColumns.UpdateTime, AuditValueType.Update, _ => DateTime.Now);
});

派生自 EntityBase 的实体由方式一命中,其它实体由方式二命中,两者互不冲突。

跨字段共享同一份取值:实体快照(WithEntitySnapshot

WithEntitySnapshot 提供按 key 划分的按需缓存:每个 key 注册一个工厂,工厂返回的结果由该实体在本次写入中所有列委托共享。缓存槽位按实体引用划分(沿 AsyncLocal 在异步调用链上传递),不同实体之间互不影响。

为什么需要

AuditValue 事件是逐列触发的。以 OrderEntity : EntityBase 为例,Insert 时需要填的列有 CreateCode / CreateName / TenantId 等多列;若每列独立注册委托:

.OnInsert(x => x.CreateCode, _ => CurrentUser.Value?.UserCode)
.OnInsert(x => x.CreateName, _ => CurrentUser.Value?.UserName)
.OnInsert(x => x.TenantId, _ => CurrentUser.Value?.TenantId)

一次 InsertAsync(entity) 会让这三个委托各执行一次。问题不在 CurrentUser.Value 本身(读 AsyncLocal 本身开销极小),而在:

  • 当工厂包含 IO 或较重的计算(解析 JWT claim、查 Redis、读配置、调用外部服务等)时,N 列即 N 次重复执行,浪费明显。
  • 当多列需要严格同源(例如同一份解析出的对象、同一时刻的时间戳)时,每列各执行一次工厂可能拿到不同的对象实例或时间值。

注:Insert 委托与 Update 委托不会在同一次写入中同时触发(一次 InsertAsync 只走 Insert 分支,UpdateAsync 只走 Update 分支),快照解决的是同一 trigger 内多列之间的一致性。

加上快照后

options.WithEntitySnapshot(snap =>
{
snap.Capture("user", _ => CurrentUser.Value); // 整个 CustomUser 只取一次
snap.Capture("ts", _ => DateTime.Now); // 当前时刻只取一次
});

options.For<EntityBase>()
.IncludeAllDerived()
.OnInsert(x => x.CreateTime, ctx => ctx.GetSnapshot<DateTime>("ts"))
.OnInsert(x => x.CreateCode, ctx => ctx.GetSnapshot<CustomUser?>("user")?.UserCode)
.OnInsert(x => x.CreateName, ctx => ctx.GetSnapshot<CustomUser?>("user")?.UserName)
.OnInsert(x => x.TenantId, ctx => ctx.GetSnapshot<CustomUser?>("user")?.TenantId)
.OnUpdate(x => x.UpdateTime, ctx => ctx.GetSnapshot<DateTime>("ts"), AuditValueOverwriteBehavior.Always)
.OnUpdate(x => x.UpdateCode, ctx => ctx.GetSnapshot<CustomUser?>("user")?.UserCode, AuditValueOverwriteBehavior.Always)
.OnUpdate(x => x.UpdateName, ctx => ctx.GetSnapshot<CustomUser?>("user")?.UserName, AuditValueOverwriteBehavior.Always);

执行模型:

  1. 进入实体写入流程 → 首次 GetSnapshot("user") 时调用工厂并缓存结果。
  2. 同一实体后续所有列回调里 GetSnapshot("user") 直接取缓存。
  3. 切换到另一实体(ReferenceEquals 不等)时槽位重建,工厂再次调用。

使用要点

  • keyStringComparer.Ordinal 比较(区分大小写);同 key 重复 Capture 以最后一次为准。
  • GetSnapshot<T>(key) 要求 T 与工厂返回类型兼容,类型不匹配立即抛 InvalidCastException;未注册 key 立即抛 InvalidOperationException
  • 工厂可能返回 null 且目标列为 NOT NULL 时,委托用 AuditValueDecision.SetOrSkipIfNull(...) 跳过该列,避免触发约束异常。
  • 未调用 WithEntitySnapshot 时,热路径不走快照分支,零额外开销。

什么时候不需要

  • 工厂本身极轻量、且多列之间无"严格同源"诉求(例如只是各取一次 DateTime.Now、各读一次本地常量)。
  • 实体上只有单列依赖某个工厂结果——根本不存在"共享"需求。

全局选项

直接挂在 options 上的全局开关,影响所有规则的默认行为。

默认覆盖行为:DefaultOverwriteBehavior

规则注册时未显式传 AuditValueOverwriteBehavior 参数时,沿用此默认值;不显式设置时框架默认为 WhenDefault

services.AddFreeSqlAuditValue<MyDb>(options =>
{
options.DefaultOverwriteBehavior = AuditValueOverwriteBehavior.WhenDefault;
// 后续规则不传该参数时全部按 WhenDefault 处理
});

异常兜底:ErrorBehavior

规则委托内部抛异常时(例如某次拿当前用户失败),框架的处理方式由 options.ErrorBehavior 决定:

选项行为
Throw(默认)异常向上抛出,业务事务失败
LogAndSkip记录诊断后跳过该列,不影响业务

生产环境推荐显式设置为 LogAndSkip,避免审计填充失败影响业务写入:

services.AddFreeSqlAuditValue<MyDb>(options =>
{
options.ErrorBehavior = AuditValueErrorBehavior.LogAndSkip;
});

运行期临时关闭:AuditValueSuppression

数据导入、历史回放等场景,希望保留实体上的原始审计字段值,不让规则覆盖,可在一段范围内用 AuditValueSuppression 局部抑制:

using (AuditValueSuppression.Begin())
{
// 范围内所有 Insert / Update 都不触发任何审计规则
fsql.Insert(historyRows).ExecuteAffrows();
}

要点:

  • 基于 AsyncLocal + 引用计数,嵌套使用安全,在异步任务里会正确传播。
  • Dispose 之后立即恢复,不影响其它请求。

常见问题

Q1:配了 Insert 规则,但插入后字段还是默认值

按顺序排查:

  1. 写入路径是否是"实体路径"。Orm.Insert<T>().AppendData(...) 与原生 SQL 不触发审计填充。
  2. AuditValueOverwriteBehaviorWhenDefault,且业务自己提前给字段赋了非默认值。
  3. AddFreeSqlAuditValue<TDb> 是否绑定到了正确的 TDb(多库项目易踩)。

Q2:基类上的规则在派生实体没生效

For<TBase>() 默认只对 TBase 本身生效,要让派生类型也命中,必须显式调用 .IncludeAllDerived()

Q3:同一列写了两条规则,哪条生效

后注册者优先。建议同一列同一触发器只写一条规则;如确实需要分层,应通过"基类规则 + 派生实体特化规则"的方式表达。

Q4:希望某段代码不走审计填充

using (AuditValueSuppression.Begin())
{
// 范围内的 Insert / Update 不走任何规则
}

Q5:性能会有影响吗

热路径设计了多层早退(抑制开关、无规则直接跳过、默认值检查委托缓存),规则集合在启动期编译成 FrozenDictionary 查找,常规业务场景下感知不到额外开销。

Q6:能不能让 SDK 自动从 JWT / Header 拿当前用户

不能,本组件只负责"按规则改写实体字段",取值来源由业务方在规则委托内自行决定。常见做法详见配合 Aegis 认证组件使用

Aegis 认证与鉴权相关文档: