实体审计字段自动填充(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.FreeSql、FreeSql |
| 是否可扩展 | 是 |
如何引入
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 / UpdateTime用Set而不是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 时填充 | 更新时间、更新人等每次更新都需刷新的字段 |
InsertOrUpdate | Insert 和 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避免约束异常。 Skip与AuditValueOverwriteBehavior无关——一旦返回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_create、created_at、c_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");
});
SystemLogEntity 是 EntityBase 的派生类,但因为它显式注册了自己的规则,会覆盖基类扩散下来的那一条。
多方式叠加:优先级
当同一字段同时被多种方式匹配到时,运行期按下列顺序查找命中规则,先找到即用:
- 实体专属规则(
For<T>()注册)优先于全局规则(MatchColumn/MatchProperty) - 同一层级内,按 DB 列名匹配的规则优先于按属性名匹配的规则
- 同一匹配维度内,后注册者优先
实践中一种常见的叠加模式:用方式一给新模块的基类做主规则,再用方式二给少量"未继承基类的历史实体"打补丁:
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);
执行模型:
- 进入实体写入流程 → 首次
GetSnapshot("user")时调用工厂并缓存结果。 - 同一实体后续所有列回调里
GetSnapshot("user")直接取缓存。 - 切换到另一实体(
ReferenceEquals不等)时槽位重建,工厂再次调用。
使用要点
key按StringComparer.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 规则,但插入后字段还是默认值
按顺序排查:
- 写入路径是否是"实体路径"。
Orm.Insert<T>().AppendData(...)与原生 SQL 不触发审计填充。 AuditValueOverwriteBehavior是WhenDefault,且业务自己提前给字段赋了非默认值。AddFreeSqlAuditValue<TDb>是否绑定到了正确的 TDb(多库项目易踩)。
Q2:基类上的规则在派生实体没生效
For<TBase>() 默认只对 TBase 本身生效,要让派生类型也命中,必须显式调用 .IncludeAllDerived()。
Q3:同一列写了两条规则,哪条生效
后注册者优先。建议同一列同一触发器只写一条规则;如确实需要分层,应通过"基类规则 + 派生实体特化规则"的方式表达。
Q4:希望某段代码不走审计填充
using (AuditValueSuppression.Begin())
{
// 范围内的 Insert / Update 不走任何规则
}
Q5:性能会有影响吗
热路径设计了多层早退(抑制开关、无规则直接跳过、默认值检查委托缓存),规则集合在启动期编译成 FrozenDictionary 查找,常规业务场景下感知不到额外开销。
Q6:能不能让 SDK 自动从 JWT / Header 拿当前用户
不能,本组件只负责"按规则改写实体字段",取值来源由业务方在规则委托内自行决定。常见做法详见配合 Aegis 认证组件使用。
Aegis 认证与鉴权相关文档: