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
相关产品推荐
相关产品推荐

