自动业务审计的采集与事务
这里的审计记录业务数据发生了什么变化。它与访问日志、CreatedAt / UpdatedAt、可靠消息的 Outbox 分别承担不同职责。现有业务专用审计也不自动迁入本模块;宿主接入时应避免重复记录同一变化。接入示例见 EFCore README。
从一次操作到多条明细
AuditOperationContext 是 scoped 的操作身份容器,不依赖 ASP.NET 或 EF。MVC 适配器在带标签的 action 前建立它,后台任务可在工作流入口显式建立。嵌套调用沿用根操作的名称、身份和关联标识,不能切换 Scope 或 Actor。它不使用动态代理,也不逐方法拦截业务服务。
EF 模型用 AuditChanges 声明资源名称和选定字段。表达式只在建模时读取,保存路径按 IModel 缓存实体元数据、字段和主键;不在每次请求扫描属性标签。字符串注解可保留在模型元数据中,映射和迁移仍属于宿主。
AuditedDbContext 保存时按以下流程工作:
- 保留
AutoDetectChangesEnabled。启用时主动检测一次,然后暂时关闭自动检测。 - 枚举跟踪实体,只处理已声明的 Added / Modified / Deleted。Modified 仅收集选定且发生变化的字段,未选字段不会产生记录。
- 为本次有审计变化的保存取得序号、UTC 时刻和 Scope 摘要,生成有限大小的字段差异 JSON。
- 将明细作为同一 DbContext 的 Added 实体交给 EF,与业务变化一起调用一次底层保存。
- 成功后更新原值可信标记;失败时移除本次仍待保存的审计实体。在
finally中恢复自动检测设置和保存状态。
一次审批涉及订单、两条库存和审批记录,会生成四条 AuditRecord。它们共享 OperationId 和 SaveSequence,避免为每张表另建操作头,也没有额外的操作头 INSERT。一次操作中多次保存使用不同序号;失败重试可能留下序号间隙,序号不是业务版本或无间隙计数。
默认操作 ID 和明细 ID 使用 UUID v7,改善按这些键建立的 B-tree 索引的写入局部性。调用者也可提供自己的操作 ID。标识不代表授权、无间隙序列或严格的业务发生顺序。
原值可信度
EF 的 OriginalValue 是跟踪快照,不必然是数据库旧值。查询得到的跟踪实体以及本上下文成功保存后的实体有可信快照;直接 Attach / Update 的实体没有这个保证。采集器用弱引用旁表记录这种来源,不为审计回查数据库。重新跟踪时先移除旧标记,因而 ChangeTracker.Clear() 后重用同一对象也不会错误地声称知道旧值。
[{"field":"Status","beforeKnown":true,"before":"Pending","after":"Approved"}]
未知原值时为 beforeKnown:false,并省略 before;这与已知原值为 null 不同。新增省略 before,删除省略 after。已知旧值与新值相同的 Modified 字段不记录。宿主主动关闭自动检测时,仍须像普通 EF 一样标记修改状态,采集器不会替宿主重新启用检测。
为什么封装 DbContext 保存
EF 的 SavingChanges 回调发生在默认变更检测之前。单独放入一个拦截器,要么重复检测,要么需要跨越其他回调维护检测开关。基类直接围绕底层保存建立 try/finally,能够在采集、其他 SavingChanges 回调或数据库失败时统一恢复状态,并只检测一次。
相应的接入约束是:规范化、时间戳和业务字段补全应先完成,再进入基类保存。后续保存事件或拦截器不能继续修改业务实体;否则采集快照与实际 SQL 可能不一致。框架不试图通过多次重扫和重新序列化兼容这种时序。保存被后续拦截器抑制时,已采集记录不能视为成功保存。
事务、异常与重试
采集器不开始另一条连接、不自行提交事务,也不把审计排到异步队列。默认 EF 事务覆盖本次业务和审计命令;存在外层工作单元事务时,二者参与同一个事务。外层回滚也会撤销审计,包括前面已经成功执行的多次 SaveChanges。非关系数据库和显式禁止自动事务的保存不满足这里的契约,因此有审计变化时拒绝执行。
SaveChanges 成功只表示本次保存成功,不表示外层事务已经提交。外层回滚后必须释放上下文或清空跟踪状态,不能把已接受的内存快照当作数据库现状。SelfManaged 工作单元现有的失败清理遵循这一规则。
采集失败发生在提交 SQL 之前;数据库失败交给 EF 事务或保存点处理。后续 SavedChanges 回调抛错或提交期间连接中断,不能仅凭客户端异常断言数据库没有提交。调用者须使用业务幂等与结果核对策略;本模块不提供跨请求 exactly-once,也不会盲目重新执行外层事务。取消会传给 EF,采集时也检查取消;已经提交的业务和审计不因随后取消而撤销。
暂存审计实体在失败后移除,防止同一上下文正常重试时重复附加。业务实体由宿主处理,不替宿主撤销业务修改。SaveChanges(false) 无法满足本版接受快照和明细去重契约,因此有采集内容时拒绝。资源主键必须在 SQL 前已知;支持客户端 GUID、明确赋值的主键和组合主键,不支持临时自增键或选定的数据库生成字段。
成本与边界
选定字段的 Sensitive 分类在模型规则缓存建立时解析,保存路径只读取缓存规则。先比较真实快照,再将 Phone/Email 转为掩码,写入 protection=Masked;Secret 只写字段和 ChangedOnly 标识,不读取快照值到 JSON。未知原值语义不变。掩码相同不能证明原值相同,查询方不得用它重建业务状态。已分类主键会被模型规则拒绝,避免通过 ResourceId 绕过字段保护;操作人、关联标识等元数据由宿主提供允许保存的稳定标识。保护不改变业务表,持久化加密仍属独立职责。
字段用 Utf8JsonWriter 按受支持的标量类型写入,不序列化实体、导航图或请求对象。单条 JSON、条数和每次保存 JSON 总量均有预算;超限使本次保存失败,而不是静默截断。数值保留 JSON 类型,非法浮点和不合法 UTF-16 字符串拒绝记录。Scope 经严格 UTF-8 后用 SHA-256 建索引,大小写保持区分;摘要不代替权限校验或数据加密。
审计关闭时直接调用普通保存,不执行审计扫描或写入。开启但无选定变化时仍需检查跟踪状态,没有审计 SQL;开启并有变化时增加 JSON、跟踪实体、INSERT、索引和 WAL 成本。缓存模型规则只能降低采集成本,无法消除同事务落库的写放大。
宿主负责字段敏感性、访问权限、保留期、分区和清理。当前提供按 Scope+Operation、Scope+Resource+时间的索引,没有新增调度器或审计查询服务。批量 SQL、其他 ORM 和其他 DbContext 的变更不会被该上下文观察。生产发布须显式执行宿主迁移,不由组件运行 DDL。真实性防篡改、跨库聚合和审计失败的降级策略不在本版契约中;声明的审计失败会阻止相应保存。
性能必须区分无审计写入和有审计写入,也应区分采集与数据库 I/O。PostgreSQL 性能记录报告实际结果和未达到的目标;基准程序提供复现入口,不能用 SQLite 的事务测试替代性能验收。