审计日志(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 |
配置项
| 配置键 | 类型 | 默认值 | 说明 |
|---|---|---|---|
AuditConnection | string | 不配 | 可选独立连接池;不配则复用业务库连接(推荐)。配了仍必须与业务库同库(写路径和业务同一个事务) |
AuditDataType | string | PostgreSQL | 数据库类型 |
Audit:Schema | string | audit | 审计表所在 schema |
Audit:AutoCreateTable | bool | true | 启动时自动建 schema 和三张表(audit_record/audit_related/audit_payload) |
Audit:ThrowOnFailure | bool | true | 情形二写失败是否抛给业务;情形一失败必然回滚不受这个键控制 |
Audit:Query:DefaultWindowDays | int | 366 | 查询时间跨度上限(天) |
Audit:ReadAudit:RecordMiss | bool | false | 查了不存在的编号要不要记。默认不记;设 true 后记为"试图查看" |
Audit:MaxPageSize | int | 200 | 查询分页大小上限 |
代码面的设置走 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(...) | 无对应:请求信息自动采集 |
IAuditStorage | IAuditSink |
IAuditQueryService.QueryAsync(condition) | IAuditQuery 链式查询:条件、时间范围、执行 |
表与数据
旧表冻结只读,新表为 audit.audit_record(结构与旧表不兼容)加 audit_related。同一个数据库里原地升级时,把新组件的 Audit:Schema 指到一个新值(如 audit2),或让 AuditConnection 指向新库;旧表留在原处供历史查询。旧数据不迁移,需要跨代查询时在应用层做两张表的并查。
接入后怎么确认生效
- 启动横幅里
Security.AuditLog.FreeSql装配成功,Security.AuditLog出现在"自动加载"提示里;忘了调AddAuditLog<业务数据源>()时存储包在中间件阶段报错指路 - 数据库出现
auditschema 下的三张表 - 调一次带审计的业务接口,
audit_record出现记录,描述文字是模板生成的原文 - 调一次带
.Payload(request, response)的记录:audit_payload出现对应行,GetPayloadAsync取回原文、AuditPayload.ComputeHash重算与哈希列一致 - 制造一次审计写失败(如表权限收回),业务事务回滚——同事务生效
- 对挂了
[Audit]的实体做一次更新并提交(不写任何审计代码),审计表出现标注"提交后补写"的记录 - 装了查询页组件的,浏览器打开
/auditlog-ui能看到查询页,领域下拉列出词表里声明的领域
配套阅读
- 审计日志查询页(Aegis.Security.AuditLog.UI):开箱即用的综合查询页手册
- FreeSql 数据访问(Aegis.Core.FreeSql):数据源、事务与仓储绑定
- 源码 README:
src/Security/Aegis.Security.AuditLog/README.md、src/Security/Aegis.Security.AuditLog.FreeSql/README.md、src/Security/Aegis.Security.AuditLog.UI/README.md