具体实现可参考NetCoreKevin中的Kevin.EntityFrameworkCore下的SaveChangesWithSaveLog方法
一个基于NET8搭建DDD-微服务-现代化Saas企业级WebAPI前后端分离架构:前端Vue3、IDS4单点登录、多级缓存、自动任务、分布式、AI智能体、一库多租户、日志、授权和鉴权、CAP事件、SignalR、领域事件、MCP协议服务、IOC模块化注入、Cors、Quartz自动任务、多短信、AI、AgentFramework、SemanticKernel集成、RAG检索增强+Qdrant矢量数据库、OCR识别、API多版本、单元测试、RabbitMQ
项目地址:github:https://github.com/junkai-li/NetCoreKevin
Gitee: https://gitee.com/netkevin-li/NetCoreKevin
技术文章大纲:在.NET项目的EFCore中实现敏感数据变动日志
核心目标
- 记录关键字段(如密码、金额、权限等)的变更历史
- 确保日志可追溯且不可篡改
- 平衡性能与安全性需求
技术实现方案
拦截数据变更
- 重写
SaveChanges或SaveChangesAsync方法 - 利用
ChangeTracker获取变更的实体状态 - 筛选标记为敏感字段的属性变更
自定义日志模型设计
public partial class TOSLog : CD { /// <summary> /// 外链表名 /// </summary> [StringLength(50)] [Description("外链表名")] public string? Table { get; set; } /// <summary> /// 外链表ID /// </summary> [Description("外链表ID")] public Guid TableId { get; set; } /// <summary> /// 标记 /// </summary> [StringLength(100)] [Description("标记")] public string? Sign { get; set; } /// <summary> /// 变动内容 /// </summary> [Description("变动内容")] public string? Content { get; set; } /// <summary> /// 操作人信息 /// </summary> [Description("操作人信息")] public Guid? ActionUserId { get; set; } public virtual TUser? ActionUser { get; set; } /// <summary> /// 备注 /// </summary> [Description("备注")] public string? Remarks { get; set; } /// <summary> /// Ip地址 /// </summary> [StringLength(100)] [Description("Ip地址")] public string? IpAddress { get; set; } /// <summary> /// 设备标记 /// </summary> [Description("设备标记")] public string? DeviceMark { get; set; } }
敏感数据脱敏处理
- 对密码等字段采用哈希存储
- 金额类字段保留变更差值
- 使用
[SensitiveData]自定义属性标记敏感字段
*核心代码数据变化比较
///// <summary> ///// 数据变化比较 ///// </summary> ///// <typeparam name="T"></typeparam> ///// <param name="original"></param> ///// <param name="after"></param> ///// <returns></returns> public string ComparisonEntity<T>(T original, T after) where T : new() { var retValue = ""; var fields = typeof(T).GetProperties(); var baseTypeNames = new List<string>(); var baseType = original.GetType().BaseType; while (baseType != null) { baseTypeNames.Add(baseType.FullName); baseType = baseType.BaseType; } for (int i = 0; i < fields.Length; i++) { var pi = fields[i]; string oldValue = pi.GetValue(original)?.ToString(); string newValue = pi.GetValue(after)?.ToString(); string typename = pi.PropertyType.FullName; if ((typename != "System.Decimal" && oldValue != newValue) || (typename == "System.Decimal" && decimal.Parse(oldValue) != decimal.Parse(newValue))) { var descriptionAttr = pi.GetCustomAttributes(typeof(DescriptionAttribute), true); if (descriptionAttr.Length > 0) { retValue += ((DescriptionAttribute)descriptionAttr[0]).Description + ":"; } else { retValue += pi.Name + ":"; } if (pi.Name != "Id" & pi.Name.EndsWith("Id")) { var foreignTable = fields.FirstOrDefault(t => t.Name == pi.Name.Replace("Id", "")); using var db = new KevinDbContext(); var foreignName = foreignTable.PropertyType.GetProperties().Where(t => t.CustomAttributes.Where(c => c.AttributeType.Name == "ForeignNameAttribute").Count() > 0).FirstOrDefault(); if (foreignName != null) { if (oldValue != null) { var oldForeignInfo = db.Find(foreignTable.PropertyType, Guid.Parse(oldValue)); oldValue = foreignName.GetValue(oldForeignInfo).ToString(); } if (newValue != null) { var newForeignInfo = db.Find(foreignTable.PropertyType, Guid.Parse(newValue)); newValue = foreignName.GetValue(newForeignInfo).ToString(); } } retValue += (oldValue ?? "") + " -> "; retValue += (newValue ?? "") + "; n"; } else if (typename == "System.Boolean") { retValue += (oldValue != null ? (bool.Parse(oldValue) ? "是" : "否") : "") + " -> "; retValue += (newValue != null ? (bool.Parse(newValue) ? "是" : "否") : "") + "; n"; } else if (typename == "System.DateTime") { retValue += (oldValue != null ? DateTime.Parse(oldValue).ToString("yyyy-MM-dd") : "") + " ->"; retValue += (newValue != null ? DateTime.Parse(newValue).ToString("yyyy-MM-dd") : "") + "; n"; } else { retValue += (oldValue ?? "") + " -> "; retValue += (newValue ?? "") + "; n"; } } } return retValue; }
核心代码重写SaveChanges
KevinDbContext db = this; var list = db.ChangeTracker.Entries().Where(t => t.State == EntityState.Modified).ToList(); foreach (var item in list) { #region 更改值时处理乐观并发 item.Entity.GetType().GetProperty("RowVersion")?.SetValue(item.Entity, Guid.NewGuid()); #endregion var type = item.Entity.GetType(); var oldEntity = item.OriginalValues.ToObject(); var newEntity = item.CurrentValues.ToObject(); var entityId = item.CurrentValues.GetValue<Guid>("Id"); object[] parameters = { oldEntity, newEntity }; var result = new KevinDbContext().GetType().GetMethod("ComparisonEntity").MakeGenericMethod(type).Invoke(new KevinDbContext(), parameters); var osLog = new TOSLog(); osLog.Id = Guid.NewGuid(); osLog.CreateTime = DateTime.Now; osLog.Table = type.Name; osLog.TableId = entityId; osLog.Sign = "Modified"; osLog.Content = result.ToString(); osLog.IpAddress = HttpContextAccessor.GetIpAddress(); osLog.DeviceMark = HttpContextAccessor.GetDevice(); osLog.ActionUserId = CurrentUser.UserId; osLog.TenantId = TenantId; db.Set<TOSLog>().Add(osLog); } #region 新增处理多租户 var Addedlist = db.ChangeTracker.Entries().Where(t => t.State == EntityState.Added).ToList(); foreach (var item in Addedlist) { item.Entity.GetType().GetProperty("TenantId")?.SetValue(item.Entity, TenantId); } #endregion return base.SaveChanges();
高级优化策略
异步日志写入
- 通过
Task.Run实现非阻塞写入 - 考虑使用内存队列缓冲日志
- 设置合理的重试机制
性能监控
- 记录日志写入耗时
- 配置日志表索引优化查询
- 定期归档历史日志
安全增强措施
日志加密存储
- 对敏感字段采用AES加密
- 实现日志签名防篡改
- 设置最小化访问权限
合规性检查
- GDPR等法规要求处理
- 设置合理的日志保留周期
- 提供日志清理接口
扩展应用场景
实时告警机制
- 关键字段变更触发通知
- 可疑操作模式检测
- 与SIEM系统集成
版本回溯功能
- 基于日志恢复历史状态
- 可视化变更对比
- 操作回滚接口设计