在.Net项目的EFCore中如何实现敏感数据或者关键数据的变动日志记录关键字段(如密码、金额、权限等)的变更历史-确保日志可追溯且不可篡改
具体实现可参考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系统集成
版本回溯功能
- 基于日志恢复历史状态
- 可视化变更对比
- 操作回滚接口设计

浙公网安备 33010602011771号