跳到主要内容
版本:3.1

审计日志(Aegis.Security.AuditLog)

记录"谁在什么时候对什么对象做了什么"。两个包一组:Aegis.Security.AuditLog 是核心,Aegis.Security.AuditLog.FreeSql 负责存进数据库;第三个包 Aegis.Security.AuditLog.UI 可选,装上就多一个开箱即用的查询页。审计写入和业务在同一个数据库事务里:业务回滚审计一起回滚,业务提交审计一起提交,不会出现"业务成功了但审计丢了"。医保这类要求接口输入输出原文留痕的场景,链上加一句 .Payload(request, response),报文原文连同内容哈希一起进审计库,出争议或检查时随时查回核对(见报文附件一节)。

本组件是 v3.1.4 起的新审计组件组。旧组件组(Aegis.Security.Audit / Aegis.Audit.FreeSql / Aegis.UMeta.Audit)已冻结保留,不再新增功能,升级关系见文末破坏性升级指南。

什么时候要用它​

适合场景:

  • 医疗、财务等对操作留痕有合规要求的系统,审计丢失不可接受
  • 医保等监管要求审计日志带接口输入输出原文、出争议时要查回原文核对的对接场景(报文附件)
  • 需要按对象追溯历史:"这个患者近三天被谁动过什么"、"谁看过这份病历"
  • 需要审计和业务一起成功或一起失败
  • 需要控制哪些字段能进审计:密码散列这类字段永远不进审计库

不适合场景:

  • 审计库必须独立于业务库部署(当前版本要求同库,独立库是后续版本的场景)
  • 高频埋点式的行为分析日志(用日志组件,审计日志面向合规追溯)

五步接入​

第一步:安装两包​

dotnet add package Aegis.Security.AuditLog
dotnet add package Aegis.Security.AuditLog.FreeSql

第二步:声明组件​

Component.deps.json 只写存储包,核心包会自动拉入:

{
"Components": {
"Load": ["Security.AuditLog.FreeSql"]
}
}

第三步:配置选项​

建表和查询默认复用业务库连接,不用再配连接串。appsettings.json 只需要:

{
"Audit": {
"Schema": "audit",
"AutoCreateTable": true,
"ThrowOnFailure": true
}
}

AuditConnection 是可选项:配了就用独立连接池(当前版本仍必须与业务库同一个库——写路径和业务用同一个事务,跨库做不到)。

第四步:Startup 绑定​

using Aegis.Security.AuditLog.FreeSql;

public void ConfigureServices(IServiceCollection services)
{
// ... AddDbSource<AegisDb> 等业务数据源注册 ...

// 把审计日志绑定到 AegisDb:写入走业务库连接(和业务同一个事务),
// 建表、查询、提交后补写默认复用业务库实例——不用配 AuditConnection
services.AddAuditLog<AegisDb>();
}

public void Configure(IApplicationBuilder app)
{
// 自动记录:查单条自动留痕 + 实体更新自动留痕(见"自动记录"一章)
app.UseAuditChangeHook<AegisDb>();
}

第五步:定义动作,写第一条审计​

先在代码里定义"有哪些操作、操作哪些对象"(下一章详述),然后写审计只要一行链式调用:

await _auditLog.Record(DiagnosisAudit.OrderCreate)
.Target(order) // 操作对象:直接传实体,标识和显示名自动取
.Anchor(visit) // 关联对象:与本次操作相关的其它实体(这里带了就诊)
.WriteAsync(); // 必须写在 uow.Commit 之前

操作人、时间、请求信息、写库全部由框架处理,调用点只提供动作和运行时数据。

启动时建表自动完成(audit schema 下的 audit_record、audit_related、audit_payload 三张表;第三张是报文附件表,只在链上调用过 .Payload(...) 的记录才有行),不用手写 SQL。

定义实体和动作​

写审计前,先在代码里定义两样东西:实体(操作涉及哪些对象)和动作(有哪些操作)。定义是静态类里的静态字段,写错启动时就会报错,不会拖到运行时。

实体定义(在目录类里)​

一个业务模块建一个目录类,类名习惯用复数:

public class PatientEntities : AuditCatalog
{
public static readonly AuditEntity<PatientEntity> Patient =
Define.Entity<PatientEntity>("Patient", "患者")
.Key(p => p.PatientNo); // 业务标识,必填

public static readonly AuditEntity<VisitEntity> Visit =
Define.Entity<VisitEntity>("Visit", "就诊")
.Key(v => v.CureNo)
.Display(v => $"{v.WardName}·{v.CureNo}"); // 显示名,可选

// 外部系统的单号(本地没有对应实体类):用非泛型写法,查询和关联时传字符串
public static readonly AuditEntity HisOrder = Define.Entity("HisOrder", "HIS 医嘱单号");
}

要点:

  • .Key() 必填,填业务编号(患者号、就诊号、单号),不要填数据库自增 ID。它决定记录里"对象是谁",也是"查单条自动留痕"识别单条查询的依据——医院习惯按业务编码查询的话,把 Key 指向编码列就能被识别。
  • .Display() 可选,填一个人类可读的名字(记录里会保存这个值)。
  • 实体编码全局唯一;一个实体类只能在一个目录里声明。

动作定义(在领域类里)​

一个业务域建一个领域类,[AuditDomain] 标记领域名:

[AuditDomain("Diagnosis", "诊疗")]
public class DiagnosisAudit : AuditDomain<DiagnosisAudit>
{
// 注意:SubDomain 字段必须写在 Action 字段前面(静态字段按书写顺序初始化,写反了启动会报错)
public static readonly AuditSubDomain OrderMgmt = Define.SubDomain("order", "医嘱管理");

public static readonly AuditAction<OrderEntity> OrderCreate =
Define.Action("OrderCreate", "开立医嘱").Subdomain(OrderMgmt)
.Target(DiagnosisEntities.Order) // 这个动作操作什么实体,必填
.Anchors(PatientEntities.Visit) // 推荐同时记录哪些关联对象
.Describe((o, visit) => $"开立{o.ItemName}(就诊 {visit})"); // 记录的文字
}

动作链各环节:

环节必填说明
.Subdomain(...)是分组用;字段要写在 Action 前面
.Target(定义)是操作哪种实体,只能一个(一个动作涉及多种实体就拆成多个动作)。Describe/Render 里的 o 参数就是这个实体类型
.Anchors(...)否推荐记录的关联对象
.Level(...)否Normal(默认)/Critical,只作分类筛选用
.Describe(...) 或 .Render(...)二选一记录的文字怎么生成,见下
.Summarize(...)否批量操作时汇总行的文字,不写就用默认的"共 N 条"

记录文字的两种写法​

  • Describe:文字里只用到关联对象的值。参数是 (对象, 关联值1, 关联值2, ...),最多 3 个关联值,个数必须和 .Anchors(...) 里声明的数量一致(启动时检查):
.Describe((o, visit, his) => $"回写医嘱 {o.OrderId}(就诊 {visit},HIS 单号 {his})")
  • Render:文字里要用字段变更明细(改了什么、旧值新值)时用它。参数是 (对象, ctx),ctx 提供 ctx.Changes(拼好的变更文字)、ctx.ChangeList(结构化列表)、ctx[实体定义](关联对象的值):
.Render((p, ctx) => "修改患者信息:" + string.Join(";",
ctx.ChangeList.Select(c => $"修改{c.Label}【{c.Old}】更改为【{c.New}】。")))

启动时的检查​

启动时会检查全部定义,把所有问题一次性列出来,有一处不对启动就失败。常见的检查项:

  • 编码重复(动作编码、实体编码全局唯一)
  • .Key() 没写
  • Describe 参数个数和 .Anchors(...) 数量不一致
  • SubDomain 字段写在 Action 后面(绑到 null)
  • 动作没设 .Target(...)
  • 实体上的 [Audit] 特性里动作编码写错(nameof 拼错)

常见用法​

用法一:单条操作(最常用)​

开单、审核这类单对象操作,直接传实体:

var uow = _uowManager.Begin(Propagation.Required);

var order = /* 业务插入 */;
var visit = await orm.Select<VisitEntity>().Where(v => v.CureNo == cureNo).FirstAsync();

await _auditLog.Record(DiagnosisAudit.OrderCreate)
.Target(order) // 对象、标识、显示名全部自动取
.Anchor(visit) // 关联对象:传实体(也可以传字符串,见用法二)
.WriteAsync();
uow.Commit(); // WriteAsync 写在 Commit 之前,审计和业务同一个事务

写进库的内容:audit_record 一行,描述文字是 Describe 生成的"开立转科医嘱(就诊 C-2026-8871)",audit_related 一行关联对象(就诊)。

操作人默认取登录态;要指定别人(比如自助机代录入、操作发生在没有登录态的终端),在链上加一句 .Operator("D-3001", "钱代录", "Nurse")——显式指定的操作人优先于登录态。完整规则见操作人从哪来,可运行的对照示例是 BaseDemo 端点 api/AuditLog/Scenario1Delegated/CreateTransferOrder:和场景 1 同一个开单流程,只多了 .Operator(...) 一句。

用法二:批量操作​

一批对象同一个动作(比如 HIS 回写 32 条医嘱),用 ForItems 逐条构建,每条可以带各自的关联对象和操作人:

await _auditLog.Record(DiagnosisAudit.TransferWriteBack)
.Anchor(PatientEntities.Visit, cureNo) // 字符串形式的关联对象(只有编号没有实体时)
.ForItems(results, r => Audit.Target(r.Entity)
.Anchor(PatientEntities.HisOrder, r.HisOrderId)
.Operator(r.OpCode, r.OpName, "Doctor", "his-integration")) // 操作人在 HIS 载荷里,不在本系统登录态
.GroupSummary(results.Count, ok.Count) // 汇总计数
.WriteAsync();
uow.Commit();

写进库的内容:1 条汇总行(描述"本次转科回写共 32 条,成功 29 条",Visit 关联对象在汇总行上)+ 32 条明细行(每条各自的描述、HIS 单号、操作人)。Operator 带 assertedBy 参数表示"已认证的系统声称是这个人做的",记录里会保存声称方。

注意:只有一条明细时不产生汇总行(单独一条记录就够了),两条以上才记汇总。

用法三:不开事务直接记​

业务没有开事务时,Record(...).WriteAsync() 照样能用:审计直接写库(不包在业务事务里),立即生效。查询接口、后台任务这类本来就没有事务的场景,这是正常用法:

// 查询接口里:查完顺手记一笔,没有 uow
var record = await _db.Select<MedicalRecordEntity>().Where(r => r.RecordId == recordId).FirstAsync();

await _auditLog.Record(MedicalRecordAudit.RecordView)
.Target(record)
.Anchor(PatientEntities.Visit, record.CureNo)
.WriteAsync(); // 直接写库,立即生效

要点:

  • 不开事务不会告警,这是设计内的用法(对应写入的四种情形里的情形二)
  • 写失败是否抛给业务由 Audit:ThrowOnFailure 控制(默认 true 抛出)
  • 代价:审计和业务不在一个事务里。对查看留痕、后台记录这类场景无所谓;对"写入类"业务建议还是开事务,让审计失败能带动业务回滚

用法四:更新自动记改了什么​

给实体加两个特性,更新时框架自动把"哪个字段从什么改成什么"记进审计:

[Audit(DomainType = typeof(PatientCareAudit), UpdateAction = nameof(PatientCareAudit.PatientUpdate))]
public class PatientInfoEntity
{
public string Id { get; set; }

[AuditAnchor(typeof(PatientEntities), nameof(PatientEntities.Patient))]
public string PatientNo { get; set; } // 关联对象自动带上

[AuditField("名称")] public string Name { get; set; } // 只有打了标记的字段才记变更
[AuditField("性别")] public string Sex { get; set; }

public string PasswordHash { get; set; } // 没打标记,永远不进审计库
}

业务代码里只剩一行(推荐写法,审计在事务内):

var repo = uow.Orm.GetRepository<PatientInfoEntity>();
_uowManager.Binding(repo); // 注意:GetRepository 之后必须 Binding,否则框架看不到变更

var info = await repo.Select.Where(p => p.Id == "PI-1024").FirstAsync();
info.Name = newName;
repo.Update(info);

await _auditLog.Record(PatientCareAudit.PatientUpdate).WriteAsync(); // 变更自动并入
uow.Commit();

写进库的内容:描述"修改患者信息:修改名称【张三】更改为【李四】。"(Render 模板生成),变更明细里只有名称和性别,PasswordHash 的改动完全不在记录里。

连这一行都不想写的话,可以完全省略:只要实体挂了 [Audit(UpdateAction = ...)],业务照常更新、照常 Commit,提交之后框架会自动补一条审计(记录里标注了"提交后补写")。代价是审计不在业务事务里——程序在提交和补写之间崩溃的话这条审计会丢。写入类的业务建议用上面一行式的写法。

用法五:查看自动留痕​

给实体挂 [Audit](SelectAction = ...)],之后按唯一编号查单条会自动留痕,业务代码零改动:

// 实体声明
[Audit(DomainType = typeof(MedicalRecordAudit), SelectAction = nameof(MedicalRecordAudit.RecordView))]
public class MedicalRecordEntity { ... }

// 业务代码:普通查询,什么也不用加
var visit = await _db.Select<VisitEntity>().Where(v => v.CureNo == cureNo).FirstAsync();
// → 自动落一条审计:谁、什么时间、查了哪个对象

记不记的判定标准是"是不是在查具体一条":查询条件里用了主键或实体定义 .Key() 指向的列做等值匹配,并且实际返回恰好 1 行,才记录。返回 0 行(查了个不存在的编号)默认不记,开了 Audit:ReadAudit:RecordMiss 才记(记为"试图查看",谁在试探编号可以查出来)。批量查询(按科室、按姓名、分页列表)永远不记——要审计这类查询就在业务代码里显式写 Record(...)。

自动留痕的记录里不保存 SQL 原文,只存表名和参数名清单(参数值只保留与关联对象匹配的项),数据库密码、患者姓名这类值不会跟着查询语句进审计库。

报文附件(接口输入输出留痕)​

医保这类监管场景要求审计日志能带接口的输入输出原文,出争议或接受检查时随时查回。链上加一句 .Payload(request, response),报文原文连同内容哈希一起写进审计库,和业务在同一个事务。

默认不记输入输出:不调用 .Payload(...) 就什么都不记,没有开关配置。

用法:先调接口,再记录​

// 先构造请求、调用对方系统、拿到响应(这次调用算成功还是失败,由业务代码自己判断)
var request = new SettleRequest(settleId, cureNo, amount, DateTimeOffset.Now);
var response = await CallYibaoAsync(request); // 超时/失败时 response 为 null

// 之后一条链式语句,和常规用法同一形状
await _auditLog.Record(MedicalInsuranceAudit.SettleUpload)
.Target(settle) // 报文只随单对象记录,Target 必须在 Payload 之前
.Payload(request, response) // 两个对象,各自转成字符串后入库
.WriteAsync();
uow.Commit(); // 契约不变:WriteAsync 前置于 Commit

Record 不必写在调用对方系统之前。事务契约只约束 WriteAsync 前置于 uow.Commit,调接口、写业务库、Record 谁先谁后都可以;先调接口只是因为要先拿到请求和响应才有内容可挂。

四个重载:

重载入参形式行为
Payload(string? request, string? response)字符串直接存原样入库:框架不解析、不管格式,JSON/XML/HL7/纯文本一视同仁
Payload(string? request)字符串直接存只挂请求(响应侧为 null)
Payload<TRequest>(TRequest? request)对象经 IAuditPayloadSerializer(默认 JSON)转字符串后入库
Payload<TRequest, TResponse>(TRequest? request, TResponse? response)对象请求/响应可以是不同类型,各自转字符串

默认 JSON 转换的口径:属性名保持 CLR 原名(不转 camelCase——转了就不是原文)、中文不转义(检查时可读)、紧凑输出(格式化留给展示层)。要别的格式(医保前置机的定长报文、HL7)见换序列化器。

传 null 的含义​

两个参数都可以传 null:null 表示这一侧没有内容。调用超时没收到响应,这个事实本身要记录下来——响应传 null 照样落库,不是错误。表里能看到三种情况:

audit_payload 表含义
没有行这条记录没调过 .Payload(...)
有行,请求或响应列是 NULL调用过,那一侧传了 null(响应 NULL = 超时或失败,没收到响应)
有行,两列都有内容完整的一次交互

两个参数都传 null 也落一行——这行表示"这次交互发生过,但没有留下报文内容"。

调用要满足的三个条件(违反当场抛异常)​

  • 必须先 Target(单对象):一次交互 = 一条记录,报文只随单对象记录走
  • 批量/组模式(ForItems/GroupSummary)不能挂报文,语义不匹配
  • 只能调用一次,重复调用抛异常——重试请另开一条记录

怎么证明报文没被改过​

有内容的一侧原文不截断、完整入库(截断过的原文没法复现,所以不走主表的截断规则)。落库时对原文计算 SHA-256 哈希、统计 UTF-8 字节数,与原文一起存。检查时取库中原文重算比对:

var payload = await _auditQuery.GetPayloadAsync(recordId);   // 直取,不需要时间范围;没挂过报文返回 null
// 有内容的一侧重算比对(null 的一侧没有内容,不用比);true = 原文自落库起没被改动
var requestOk = payload.Request == null
|| AuditPayload.ComputeHash(payload.Request) == payload.RequestHash;

hash_alg 列存算法标识(当前 SHA256)。将来合规要求国密时按这个标识换 SM3,存量数据按每行自己的标识判读,不受新算法影响。

没接存储方时(ConsoleAuditSink 输出到日志)报文只打摘要不打全文:有内容的一侧打字节数和哈希前 8 位,传 null 的一侧标 absent。

表结构与升级建表​

列类型说明
record_id主键1:1 挂 audit_record.id,非自增
request / response大文本,可空报文原文,不截断
request_hash / response_hash字符串(128),可空SHA-256 哈希
request_length / response_length整数,可空原文 UTF-8 字节数,与哈希互为佐证
hash_alg字符串(16),非空算法标识,默认 SHA256

Audit:AutoCreateTable 为 true(默认)时启动自动建这张表,和另两张表一样不用管。AutoCreateTable=false 的手工建表现场要自己建(列结构照上表);不建的话框架在启动时探测到缺表:常规审计查询照常工作,只是列表的 HasPayload 全部为 false、GetPayloadAsync 返回 null,同时打一条告警指路。注意:挂了报文的写入碰上缺表会失败并回滚业务——要把报文存进一张不存在的表,失败是对的。

没挂 .Payload(...) 的记录不产生任何针对 audit_payload 的 SQL,现有用法行为不变。查询列表每页多一次按主键查 record_id 的查询(只查 id 列、不拉原文,和关联对象表的加载一样),缺表时这次查询也省掉。

换序列化器​

对象转字符串的规则由 IAuditPayloadSerializer 决定(默认 JSON)。换实现不碰 DI,在 AddAuditLog 的 setup 委托里赋实例:

// Startup ConfigureServices
services.AddAuditLog<AegisDb>(setup => setup.PayloadSerializer = new YibaoPayloadSerializer());
public class YibaoPayloadSerializer : IAuditPayloadSerializer
{
public string Serialize<T>(T value) => YibaoCodec.ToWireFormat(value); // 你的定长报文/HL7 编码
}

序列化输出的字符串就是入库的原文(哈希对它计算),实现必须同输入同输出——输出不稳定的话核对比对永远通不过。字符串直接存的那两个重载不经序列化器。

重载选择的坑(改 API 前必读)​

带可选参数的非泛型重载,编译器选方法时会输给参数个数正好匹配的泛型重载(.NET 8 实测)。假如把字符串入参写成 Payload(string? request, string? response = null) 一个带默认参数的方法,只传一个 string 的调用会掉进 Payload<TRequest>(TRequest?),被当成对象做 JSON 序列化,原文多出一层引号和转义。所以字符串重载拆成单参/双参两个、都没有默认参数。以后给 Payload 加重载保持这条规则:非泛型重载不带默认参数。

查询​

查询入口 IAuditQuery 单独注入。链式调用分三步:先给条件,再给时间范围,最后执行——顺序不对编译不过:

// 这个患者近三天被谁动过什么(对象或关联对象,沾到就查出来)
await _auditQuery
.Subject(PatientEntities.Patient, patientNo)
.During(DateTime.UtcNow.AddDays(-3))
.ToListAsync();

// 某条医嘱的全部日志
await _auditQuery
.Subject(order) // 直接传实体
.During(DateTime.UtcNow.AddMonths(-3))
.ToListAsync();

// 谁看过这份就诊的病历(关联对象 × 动作)
await _auditQuery
.Anchor(PatientEntities.Visit, cureNo)
.Action(MedicalRecordAudit.RecordView)
.During(DateTime.UtcNow.AddDays(-30))
.ToListAsync();

// 多个动作 + 分页
await _auditQuery
.Action(PharmacyAudit.PriceAdjust, PharmacyAudit.DrugVoid)
.During(DateTime.UtcNow.AddMonths(-3))
.Page(pageIndex, pageSize)
.ToListAsync();

// 只记得内容(搜描述和备注)
await _auditQuery
.TextContains("阿莫西林") // 至少 2 个字符
.During(DateTime.UtcNow.AddDays(-30))
.ToListAsync();

// 某人的全部操作
await _auditQuery
.Operator("D20231")
.During(DateTime.UtcNow.AddMonths(-1))
.ToListAsync();

// 只看关键操作(也可以多个级别一起:Level(AuditLevel.Critical, AuditLevel.Normal))
await _auditQuery
.Level(AuditLevel.Critical)
.During(DateTime.UtcNow.AddDays(-30))
.ToListAsync();

// 按领域筛(字符串入口:运行时才知道领域码时用,比如自己拼查询界面;
// 代码里领域类型已知时用泛型入口 Domain<PharmacyAudit>())
await _auditQuery
.Domain("Pharmacy")
.During(DateTime.UtcNow.AddDays(-30))
.ToListAsync();

// 按子域码筛(子域码在域内唯一、查询按码全表等值:
// 跨域重名的子域会一起命中,"reading" 同时命中就诊调阅和病历调阅)
await _auditQuery
.SubDomain("pricing")
.During(DateTime.UtcNow.AddDays(-30))
.ToListAsync();

// 列表折叠组:批量操作在结果里只占一条汇总行(组头),无组的记录不受影响
await _auditQuery
.Level(AuditLevel.Critical)
.CollapseGroups()
.During(DateTime.UtcNow.AddDays(-30))
.ToListAsync();

// 展开一次批量操作的全部明细(不需要时间范围)
var group = await _auditQuery.GetGroupAsync(groupId);

// 报文附件直取(不需要时间范围;该记录没挂过 Payload 返回 null)
var payload = await _auditQuery.GetPayloadAsync(recordId);

规则:

  • 时间用 UTC(传北京时间会自动转换并打一条告警日志)
  • 时间跨度上限默认 366 天,可用 Audit:Query:DefaultWindowDays 调整——TextContains 没有专用索引,靠时间范围控制扫描量,这是上限存在的原因
  • 多个条件之间是与的关系;Action(...) 里多个动作是或的关系
  • Domain(...)/SubDomain(...) 传字符串码,码不存在时结果为空、不报错。领域码、子域码可以从词表元数据取(查询页就是这么拿的)
  • CollapseGroups() 把组压成一条汇总行,明细经 GetGroupAsync 展开——列表页翻页时,一次批量回写 500 条不会在结果里刷 500 行
  • 列表结果里的 HasPayload 是存在性标记(Payload 恒为 null——列表不拉大字段),报文正文经 GetPayloadAsync(recordId) 按需取

内置查询页​

不想自己拼查询界面,装 Aegis.Security.AuditLog.UI 包就有现成的:引用包 + Component.deps.json 的 Load 数组加 "Security.AuditLog.UI",启动后浏览器打开 /auditlog-ui,得到一个综合查询页——领域/子域/动作三级联动筛选(动作也可以单独选)、时间范围(24 小时制)、操作人、关键词、级别、操作对象条件可任意叠加,批量操作默认折叠成一条汇总行、点"展开"看明细,点行尾"详情"看整条内容(变更明细、关联对象、上下文、报文附件)。接入步骤和页面细节见单独手册:审计日志查询页(Aegis.Security.AuditLog.UI)。

操作人从哪来​

默认取登录态(CurrentUser),开发者可以显式指定,显式的优先:

  • 条目级声明(ForItems 里 .Operator(...))最优先;
  • 其次是本次记录的声明(Record(...).Operator(...),单条/汇总行/没有条目声明的明细都用它)。
来源含义什么时候产生
Context登录用户本人真人登录态(最常见,不显式指定时的默认)
Delegated已认证的系统声称是某人做的声明时带了 assertedBy 参数(如 .Operator(id, name, role, assertedBy: "his-integration"))
System系统身份直接作为操作人定时任务、系统间调用(服务凭证登录)
Data操作人随业务数据带过来声明时不带 assertedBy(开发者说这人是谁就是谁)
Unknown没有登录也没有声明编号记为空,不猜测

显式声明按传入的值原样记录:登录态和声明不一致时,记录里是声明的人,框架不做压制,也不拿登录态改写声明。操作人落在表里的六个字段(编号/姓名/角色/来源/声称方/冲突方),后两个当前的填充情况:

  • operator_asserted_by(声称方):只在带 assertedBy 的显式声明里出现,记声称方标识(如 his-integration)
  • operator_conflict_with(冲突方):预留列,当前恒为空。声明与登录态冲突怎么留痕,等身份链功能定案后启用

写入的四种情形​

WriteAsync 按当时的环境选写入方式:

情形环境行为
一业务事务开着,WriteAsync 在 Commit 之前(推荐)审计写进业务同一个事务:审计失败业务回滚,业务回滚审计消失
二没有业务事务(查询接口、后台任务)直接写库,不告警;失败是否抛出看 Audit:ThrowOnFailure
三有事务但 WriteAsync 写在 Commit 之后直接写库并打告警(审计已不在事务里)
四提交后自动补写(更新自动记、查看自动留痕)单独开一个事务写库,记录里标注"提交后补写";失败只记日志不抛出(业务已经提交了,再抛会造成业务假失败)

不配 AuditConnection 时建表/查询/提交后补写都复用业务库实例;配了则走独立连接池(情形四的补写用独立事务)。当前版本两者都必须与业务库同库——同一个事务得是同一个库。

实体上的三个特性​

特性作用
[Audit(DomainType, InsertAction/UpdateAction/DeleteAction/SelectAction)]声明这个实体的增删改查分别对应哪个动作。至少设一个动作,不设启动会报错
[AuditField("名称")]更新时只记打了标记的字段。标记的参数就是变更明细里的字段名。没打标记的字段任何情况下不进审计库——这是有意设计:标记漏了最多缺一条变更(可以补),排除名单漏了就是密码这类数据进了审计库(收不回来)
[AuditAnchor(typeof(目录类), nameof(目录类.实体))]声明关联对象自动带上:传实体或自动记录时不用每次手写 .Anchor(...)。属性类型必须是 string

配置项​

配置键类型默认值说明
AuditConnectionstring不配可选独立连接池;不配则复用业务库连接(推荐)。配了仍必须与业务库同库(写路径和业务同一个事务)
AuditDataTypestringPostgreSQL数据库类型
Audit:Schemastringaudit审计表所在 schema
Audit:AutoCreateTablebooltrue启动时自动建 schema 和三张表(audit_record/audit_related/audit_payload)
Audit:ThrowOnFailurebooltrue情形二写失败是否抛给业务;情形一失败必然回滚不受这个键控制
Audit:Query:DefaultWindowDaysint366查询时间跨度上限(天)
Audit:ReadAudit:RecordMissboolfalse查了不存在的编号要不要记。默认不记;设 true 后记为"试图查看"
Audit:MaxPageSizeint200查询分页大小上限

代码面的设置走 AddAuditLog 的 setup 委托(入参类型 AuditLogSetup),不经裸 DI 注册。当前成员是 PayloadSerializer(泛型报文通道的序列化器,null = 默认 JSON),用法见换序列化器:

services.AddAuditLog<AegisDb>(setup => setup.PayloadSerializer = new YibaoPayloadSerializer());

表结构:audit_record(主表)+ audit_related(关联对象表,复合主键 record_id/entity_type/entity_id)+ audit_payload(报文附件表,record_id 主键 1:1,结构见报文附件一节)。主键是按时间递增的数字 ID(雪花 ID),按 ID 排序就是写入顺序。列名是小写下划线风格(occurred_at、operator_id、is_group_header)。动作两列(action/action_name)、领域(domain)、级别(level)、审计句(sentence)共五列 NOT NULL——这只约束新建的表,升级前已存在的表不会自动收紧(SyncStructure 不改已有列的空值约束)。查询接口返回的 AuditRecord 只含业务字段,存储层的细节(查看详情、提交后补写标注)不出现在接口响应里。

Extensions 键的保留前缀​

extensions 列是键值对通道,里面放两类东西:

  • 框架内部标注:_terminal(终端)、_caller(调用方系统)、_writeMode(写入模式,提交后补写标成 post-commit)。框架写入,读出来时自动剥离——终端和调用方回到记录的 Context 字段,查询结果里看不到下划线键
  • 业务自定义键:当前版本还没有对业务开放写入的 API(后续版本开放),保留规则先定死:下划线开头的键名是框架保留的,业务键以下划线开头会在写入时抛 ArgumentException。现在就遵守这条规则,将来通道开放时不会和内部标注撞名

换存储、改操作人来源​

接口用途
IAuditSink换存储:实现 WriteAsync(records, mode) 即可接入别的数据库或消息队列,核心零改动
IAuditPayloadSerializer换泛型报文通道的序列化格式(默认 JSON)。实现 Serialize<T>(T value) 后经 AddAuditLog<TDbSource>(setup => setup.PayloadSerializer = 实例) 替换,不要裸 DI 注册
IAuditOperatorResolver操作人来源改造:认证组件补齐部门等信息后在这里接入
IAuditScopeEnricher把别的 ORM 的变更报告翻译成审计条目

注意点​

WriteAsync 写在 uow.Commit 之前。 审计和业务同一个事务的前提。写在 Commit 之后事务已经结束,审计直接写库并告警。Record 之后忘了 WriteAsync 也不丢——请求结束时框架会发现没提交的记录,丢弃草稿并打告警。

GetRepository 之后必须 Binding。 uow.Orm.GetRepository<T>() 拿到的仓储要调 _uowManager.Binding(repo),否则仓储的变更框架看不到,自动记录的写库结果是空的。

控制器命名空间以 Audit 结尾时 Audit.Target(...) 编译不过。 命名空间名和工厂类名冲突,加别名解决:

using AuditEntry = Aegis.Security.AuditLog.Audit;

.ForItems(results, r => AuditEntry.Target(r.Entity) ...)

repo.Delete(predicate) 这类按条件批量删除不会自动记审计。 这种操作框架拿不到被删的数据,只打告警。要审计就在删除前把数据查出来逐条删,或显式写 Record(...)。

按内容搜索没有专用索引。 TextContains 在时间范围内逐行匹配,范围越大越慢——这是查询跨度上限存在的原因。库大了以后考虑加 trgm 索引(后续版本支持)。

旧表和新表同名。 旧组件的表和新组件的表默认都在 audit schema 且主表都叫 audit_record,但结构不兼容。同一个库里升级时把新组件的 Audit:Schema 指到新值(如 audit2),旧表留在原地供查询。

从旧版安全审计迁移(破坏性升级指南)​

冻结政策​

旧三包(Aegis.Security.Audit、Aegis.Audit.FreeSql、Aegis.UMeta.Audit)自 3.1.4-preview1 起冻结:保留在仓库与 NuGet,只接 hotfix,不再新增功能,无迁移计划。新项目直接用新组件组;存量项目按本节对照迁移。旧组件手册已随 3.1.4 正式版从文档站移除,迁移前的 API 细节看各包源码 README。

行为变化:审计失败从"吞异常"变为"业务回滚"​

旧版审计在业务 Commit 之后发送,写失败吞异常记日志,业务照常成功,审计可能丢。新版 WriteAsync 前置于 Commit,审计写在业务事务内,失败等于业务回滚(Audit:ThrowOnFailure 缺省 true)。这是有意的设计决定:医疗场景里审计丢失不可接受。唯一例外是提交后自动补写:业务已提交,补写失败只记日志。

API 迁移对照表​

旧 API(Aegis.Security.Audit)新写法(Aegis.Security.AuditLog)
SetOperator("1", OperatorType.医生, "张三")不再手写:操作人自动解析;HIS 载荷里带的操作人用条目级 .Operator(id, name, role, assertedBy)
PreSetOperator(...)无对应:操作人每条记录按来源自动解析
SetDomain(Domain.结算单)[AuditDomain("...", "...")] 领域类,动作定义在领域类里
SetContent(OperationType.创建, id, title, content)Define.Action(...).Describe(...) / .Render(...) 生成记录文字
SetChangeData("金额,状态", newData, oldData)[AuditField] 自动记变更,或条目 .Change(label, old, new)
枚举(Domain / OperationType / OperatorType)实体目录 Define.Entity<T>() + 动作链 .Subdomain().Target().Anchors()
LogAsync()WriteAsync(),写在 uow.Commit 之前
WithIp(...) / WithOperationTime(...)无对应:请求信息自动采集
IAuditStorageIAuditSink
IAuditQueryService.QueryAsync(condition)IAuditQuery 链式查询:条件、时间范围、执行

表与数据​

旧表冻结只读,新表为 audit.audit_record(结构与旧表不兼容)加 audit_related。同一个数据库里原地升级时,把新组件的 Audit:Schema 指到一个新值(如 audit2),或让 AuditConnection 指向新库;旧表留在原处供历史查询。旧数据不迁移,需要跨代查询时在应用层做两张表的并查。

接入后怎么确认生效​

  • 启动横幅里 Security.AuditLog.FreeSql 装配成功,Security.AuditLog 出现在"自动加载"提示里;忘了调 AddAuditLog<业务数据源>() 时存储包在中间件阶段报错指路
  • 数据库出现 audit schema 下的三张表
  • 调一次带审计的业务接口,audit_record 出现记录,描述文字是模板生成的原文
  • 调一次带 .Payload(request, response) 的记录:audit_payload 出现对应行,GetPayloadAsync 取回原文、AuditPayload.ComputeHash 重算与哈希列一致
  • 制造一次审计写失败(如表权限收回),业务事务回滚——同事务生效
  • 对挂了 [Audit] 的实体做一次更新并提交(不写任何审计代码),审计表出现标注"提交后补写"的记录
  • 装了查询页组件的,浏览器打开 /auditlog-ui 能看到查询页,领域下拉列出词表里声明的领域

配套阅读​