具體實現可參考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中實現敏感數據變動日誌

核心目標
  • 記錄關鍵字段(如密碼、金額、權限等)的變更歷史
  • 確保日誌可追溯且不可篡改
  • 平衡性能與安全性需求
技術實現方案

攔截數據變更

  • 重寫SaveChangesSaveChangesAsync方法
  • 利用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系統集成

版本回溯功能

  • 基於日誌恢復歷史狀態
  • 可視化變更對比
  • 操作回滾接口設計