具體實現可參考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系統集成
版本回溯功能
- 基於日誌恢復歷史狀態
- 可視化變更對比
- 操作回滾接口設計