You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

EF Core Change Tracking无法捕获子记录变更 审计日志实现问题

EF Core 变更日志无法捕获子实体变更问题解决方案

核心原因

问题本质是EF Core的属性追踪逻辑设计导致的:

  • entry.Properties 仅包含当前实体的标量属性、复杂类型属性,一对多/一对一的导航属性(关联的子实体、子集合)不属于该集合范围,自然无法通过遍历entry.Properties获取子实体变更
  • 子实体本身是独立的EF Core追踪对象,默认就会被包含在ChangeTracker.Entries()的遍历结果中,你现有代码如果只需要生成全量实体的独立审计记录,其实已经支持,只是父子实体的审计记录是分开存储的

按需选择解决方案

场景1:仅需记录所有实体(含子实体)的独立变更日志

不需要修改核心逻辑,子实体会作为独立的Entry被遍历到,生成单独的审计记录。如果需要区分父子实体,只需要在审计表中增加父实体类型、父实体主键两个字段,后续通过关联关系匹配即可。

场景2:需要将子实体变更直接关联绑定到主实体的审计记录中

需要额外遍历实体的导航属性,关联子实体变更,修改后的核心代码如下:

private void OnBeforeSaveChanges()
{
    ChangeTracker.DetectChanges();
    var auditEntries = new List<AuditEntry>();
    // 存储已处理的实体,避免循环引用重复审计
    var processedEntities = new HashSet<object>();

    foreach (var entry in ChangeTracker.Entries())
    {
        if (entry.Entity is AuditLog || entry.State == EntityState.Detached || entry.State == EntityState.Unchanged || processedEntities.Contains(entry.Entity))
            continue;

        var auditEntry = new AuditEntry(entry)
        {
            TableName = entry.Entity.GetType().Name,
            UserId = _aplicationUser.GetId
        };
        auditEntries.Add(auditEntry);
        processedEntities.Add(entry.Entity);

        // 处理当前主实体的标量属性(原有逻辑保留)
        foreach (var property in entry.Properties)
        {
            string propertyName = property.Metadata.Name;
            if (property.Metadata.IsPrimaryKey())
            {
                auditEntry.KeyValues[propertyName] = property.CurrentValue;
                continue;
            }
            switch (entry.State)
            {
                case EntityState.Added:
                    auditEntry.AuditType = AuditType.Create;
                    auditEntry.NewValues[propertyName] = property.CurrentValue;
                    break;
                case EntityState.Deleted:
                    auditEntry.AuditType = AuditType.Delete;
                    auditEntry.OldValues[propertyName] = property.OriginalValue;
                    break;
                case EntityState.Modified:
                    if (property.IsModified)
                    {
                        auditEntry.ChangedColumns.Add(propertyName);
                        auditEntry.AuditType = AuditType.Update;
                        auditEntry.OldValues[propertyName] = property.OriginalValue;
                        auditEntry.NewValues[propertyName] = property.CurrentValue;
                    }
                    break;
            }
        }

        // 新增:处理导航属性(子实体/子集合)
        foreach (var navigation in entry.Navigations)
        {
            // 跳过不需要审计的导航,按需配置过滤规则
            if (!navigation.IsLoaded) continue; // 未加载的导航跳过,避免触发懒加载额外查询
            
            if (navigation is CollectionEntry collectionEntry)
            {
                // 处理一对多子集合
                if (collectionEntry.CurrentValue == null) continue;
                foreach (var childEntity in collectionEntry.CurrentValue as IEnumerable<object>)
                {
                    if (childEntity == null || processedEntities.Contains(childEntity)) continue;
                    var childEntry = Entry(childEntity);
                    if (childEntry.State is EntityState.Detached or EntityState.Unchanged) continue;
                    
                    // 生成子实体审计记录,关联主实体信息
                    var childAuditEntry = new AuditEntry(childEntry)
                    {
                        TableName = childEntity.GetType().Name,
                        UserId = _aplicationUser.GetId,
                        // 额外扩展字段存储父实体信息
                        ParentTableName = auditEntry.TableName,
                        ParentKeyValues = auditEntry.KeyValues
                    };
                    processedEntities.Add(childEntity);
                    
                    // 子实体属性处理逻辑和主实体一致,可封装为公共方法复用
                    ProcessEntityProperties(childEntry, childAuditEntry);
                    auditEntries.Add(childAuditEntry);
                }
            }
            else if (navigation is ReferenceEntry referenceEntry)
            {
                // 处理一对一子实体
                var childEntity = referenceEntry.CurrentValue;
                if (childEntity == null || processedEntities.Contains(childEntity)) continue;
                var childEntry = Entry(childEntity);
                if (childEntry.State is EntityState.Detached or EntityState.Unchanged) continue;
                
                var childAuditEntry = new AuditEntry(childEntry)
                {
                    TableName = childEntity.GetType().Name,
                    UserId = _aplicationUser.GetId,
                    ParentTableName = auditEntry.TableName,
                    ParentKeyValues = auditEntry.KeyValues
                };
                processedEntities.Add(childEntity);
                ProcessEntityProperties(childEntry, childAuditEntry);
                auditEntries.Add(childAuditEntry);
            }
        }
    }

    foreach (var auditEntry in auditEntries)
    {
        var auditDto = auditEntry.ToAudit();
        AuditLogs.Add(new AuditLog(auditDto.Id, auditDto.UserId, auditDto.Type, auditDto.TableName, auditDto.DateTime,
            auditDto.OldValues, auditDto.NewValues, auditDto.AffectedColumns, auditDto.PrimaryKey)
            // 按需保存父实体关联字段
            { ParentTable = auditDto.ParentTableName, ParentKey = auditDto.ParentKeyValues });
    }
}

// 提取公共的属性处理方法
private void ProcessEntityProperties(EntityEntry entry, AuditEntry auditEntry)
{
    foreach (var property in entry.Properties)
    {
        string propertyName = property.Metadata.Name;
        if (property.Metadata.IsPrimaryKey())
        {
            auditEntry.KeyValues[propertyName] = property.CurrentValue;
            continue;
        }
        switch (entry.State)
        {
            case EntityState.Added:
                auditEntry.AuditType = AuditType.Create;
                auditEntry.NewValues[propertyName] = property.CurrentValue;
                break;
            case EntityState.Deleted:
                auditEntry.AuditType = AuditType.Delete;
                auditEntry.OldValues[propertyName] = property.OriginalValue;
                break;
            case EntityState.Modified:
                if (property.IsModified)
                {
                    auditEntry.ChangedColumns.Add(propertyName);
                    auditEntry.AuditType = AuditType.Update;
                    auditEntry.OldValues[propertyName] = property.OriginalValue;
                    auditEntry.NewValues[propertyName] = property.CurrentValue;
                }
                break;
        }
    }
}

注意事项

  • 如果开启懒加载,未主动加载的导航属性会在遍历的时候触发额外的数据库查询,建议需要审计子实体的场景,提前用Include/ThenInclude加载关联导航
  • 新增的processedEntities哈希集是为了避免循环导航(比如子实体又引用父实体)导致的重复审计问题
  • 如果不需要记录所有子实体的变更,可以在导航遍历逻辑里加类型过滤,只处理指定的关联实体

内容的提问来源于stack exchange,提问作者Vinicius Teixeira

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.29 23:36:03