You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

如何在EF Core+MySQL环境下为TPT实体实现可靠的属性级审计系统以跟踪完整更新历史

如何在EF Core+MySQL环境下为TPT实体实现可靠的属性级审计系统以跟踪完整更新历史

我完全理解你的处境——用EF Core配合MySQL做TPT继承的报表系统,想要实现属性级的审计追踪,但MySQL不像SQL Server有现成的时态表可用。下面我会给你一套可靠的落地方案,覆盖所有Report派生类的变更记录,包括更新前后的属性值、删除操作,完美适配你的TPT场景。

1. 设计审计日志实体模型

首先我们需要定义一个审计日志实体,用来存储所有变更的核心细节,确保能追溯到每一次操作的完整信息:

public enum AuditChangeType
{
    Update,
    Delete
}

[Table("AuditLogs")]
public class AuditLog
{
    [Key]
    public int Id { get; set; }
    
    // 关联的数据库表名(TPT场景下区分基表/派生表)
    [Required, MaxLength(100)]
    public string TableName { get; set; } = null!;
    
    // 被变更的实体ID
    public int EntityId { get; set; }
    
    // 变更类型:更新/删除
    public AuditChangeType ChangeType { get; set; }
    
    // 被变更的属性名
    [Required, MaxLength(100)]
    public string PropertyName { get; set; } = null!;
    
    // 旧值(删除时为实体当前值,更新时为变更前的值)
    public string? OldValue { get; set; }
    
    // 新值(删除时为null,更新时为变更后的值)
    public string? NewValue { get; set; }
    
    // 操作人
    [Required, MaxLength(100)]
    public string ChangedBy { get; set; } = null!;
    
    // 操作时间(建议用UTC时间避免时区问题)
    public DateTime ChangedAt { get; set; }
}

2. 实现EF Core保存拦截器

EF Core的SaveChangesInterceptor是实现全局变更追踪的最佳方式,它能在保存操作执行前后拦截并处理实体变更,完美适配TPT继承场景——EF Core的ChangeTracker会自动追踪所有派生类实体的状态和属性变更。

我们自定义拦截器,重写保存方法,在执行保存前捕获所有Report及其派生类的变更:

public class AuditSaveChangesInterceptor : SaveChangesInterceptor
{
    // Web应用中可注入IHttpContextAccessor获取当前登录用户
    private readonly IHttpContextAccessor? _httpContextAccessor;

    public AuditSaveChangesInterceptor(IHttpContextAccessor? httpContextAccessor = null)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        if (eventData.Context is not null)
        {
            ProcessAuditLogs(eventData.Context);
        }
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
    {
        if (eventData.Context is not null)
        {
            ProcessAuditLogs(eventData.Context);
        }
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    private void ProcessAuditLogs(DbContext context)
    {
        // 获取当前操作人:可根据业务场景调整(比如从会话、配置中心获取)
        string GetCurrentUser()
        {
            if (_httpContextAccessor?.HttpContext?.User?.Identity?.Name is string username)
            {
                return username;
            }
            // 非Web场景可替换为固定值或从其他渠道获取
            throw new InvalidOperationException("无法获取当前操作人,请检查配置");
        }

        var currentUser = GetCurrentUser();
        var currentTime = DateTime.UtcNow;

        // 拦截所有状态为Modified/Deleted的Report及其派生类实体
        foreach (var entityEntry in context.ChangeTracker.Entries<Report>())
        {
            if (entityEntry.State is EntityState.Modified or EntityState.Deleted)
            {
                // 自动维护Report的UpdatedDate和UpdatedBy字段
                if (entityEntry.State == EntityState.Modified)
                {
                    entityEntry.Property(nameof(Report.UpdatedDate)).CurrentValue = currentTime;
                    entityEntry.Property(nameof(Report.UpdatedBy)).CurrentValue = currentUser;
                }

                var entityId = entityEntry.Property(nameof(Report.Id)).CurrentValue as int? ?? 0;
                var auditLogs = new List<AuditLog>();

                switch (entityEntry.State)
                {
                    case EntityState.Modified:
                        // 遍历所有有变更的属性
                        foreach (var propertyEntry in entityEntry.Properties.Where(p => p.IsModified))
                        {
                            var propertyMetadata = propertyEntry.Metadata;
                            // TPT场景下,准确获取属性所属的表名(基类属性对应Reports,派生类属性对应各自表)
                            var tableName = propertyMetadata.DeclaringType.GetTableName() ?? string.Empty;
                            if (string.IsNullOrEmpty(tableName)) continue;

                            // 序列化属性值为字符串,适配所有数据类型
                            var oldValue = SerializeValue(propertyEntry.OriginalValue);
                            var newValue = SerializeValue(propertyEntry.CurrentValue);

                            auditLogs.Add(new AuditLog
                            {
                                TableName = tableName,
                                EntityId = entityId,
                                ChangeType = AuditChangeType.Update,
                                PropertyName = propertyMetadata.Name,
                                OldValue = oldValue,
                                NewValue = newValue,
                                ChangedBy = currentUser,
                                ChangedAt = currentTime
                            });
                        }
                        break;

                    case EntityState.Deleted:
                        // 记录删除操作的所有属性值
                        foreach (var propertyEntry in entityEntry.Properties)
                        {
                            var propertyMetadata = propertyEntry.Metadata;
                            var tableName = propertyMetadata.DeclaringType.GetTableName() ?? string.Empty;
                            if (string.IsNullOrEmpty(tableName)) continue;

                            var oldValue = SerializeValue(propertyEntry.OriginalValue);

                            auditLogs.Add(new AuditLog
                            {
                                TableName = tableName,
                                EntityId = entityId,
                                ChangeType = AuditChangeType.Delete,
                                PropertyName = propertyMetadata.Name,
                                OldValue = oldValue,
                                NewValue = null,
                                ChangedBy = currentUser,
                                ChangedAt = currentTime
                            });
                        }
                        break;
                }

                // 将审计日志加入上下文,和主操作一起提交
                context.Set<AuditLog>().AddRange(auditLogs);
            }
        }
    }

    // 通用值序列化方法,兼容所有数据类型
    private string? SerializeValue(object? value)
    {
        if (value == null) return null;
        if (value.GetType().IsValueType || value is string)
        {
            return value.ToString();
        }
        // 复杂类型用JSON序列化
        return JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = false });
    }
}

3. 注册拦截器到DbContext

最后需要把自定义拦截器注册到你的DbContext,ASP.NET Core应用推荐用DI容器注册的方式:

步骤1:在Program.cs中注册服务

builder.Services.AddHttpContextAccessor(); // 注册HttpContextAccessor获取当前用户

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseMySQL(builder.Configuration.GetConnectionString("DefaultConnection"));
    // 从DI容器获取拦截器依赖并注册
    var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>();
    options.AddInterceptors(new AuditSaveChangesInterceptor(httpContextAccessor));
});

步骤2:配置你的AppDbContext

public class AppDbContext : DbContext
{
    public DbSet<Report> Reports => Set<Report>();
    public DbSet<QualityReport> QualityReports => Set<QualityReport>();
    public DbSet<MaintenanceReport> MaintenanceReports => Set<MaintenanceReport>();
    public DbSet<SafetyReport> SafetyReports => Set<SafetyReport>();
    public DbSet<AuditLog> AuditLogs => Set<AuditLog>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 显式配置派生类的表名(和TPT策略匹配)
        modelBuilder.Entity<QualityReport>().ToTable("QualityReports");
        modelBuilder.Entity<MaintenanceReport>().ToTable("MaintenanceReports");
        modelBuilder.Entity<SafetyReport>().ToTable("SafetyReports");
    }
}

4. 关键细节说明

  1. TPT场景完美适配:通过EF Core元数据propertyMetadata.DeclaringType.GetTableName(),能准确区分基类属性(对应Reports表)和派生类属性(对应各自表),审计日志和实际数据库操作完全对齐。
  2. 自动维护实体字段:拦截器中自动更新UpdatedDateUpdatedBy,避免业务代码重复编写,同时这些字段的变更也会被记录到审计日志(如果不需要可在代码中排除)。
  3. 灵活的操作人获取:支持Web场景从HttpContext获取用户,非Web场景可直接修改GetCurrentUser方法适配你的业务逻辑。
  4. 全类型值序列化:通用序列化方法支持所有数据类型,包括值类型、字符串和复杂对象,确保审计日志的完整性。

内容来源于stack exchange

火山引擎 最新活动