5.6 软删除、审计字段与多租户
真实业务系统很少只是“增删改查”。删除通常需要可恢复和可审计,关键数据要记录创建人和修改人,多租户系统还必须确保 A 租户永远查不到 B 租户的数据。这些能力应尽量在数据访问边界统一处理,而不是散落在每个查询和接口里。
学习目标
- 能设计软删除字段,并用全局查询过滤器隐藏已删除数据。
- 能为实体增加创建时间、修改时间、创建人和修改人。
- 能在
SaveChanges中集中填充审计字段。 - 能理解多租户隔离的基本策略和常见风险。
应用场景
- 用户删除任务后,管理员仍可恢复或审计。
- 问题排查时需要知道某条数据是谁在什么时候修改的。
- SaaS 系统按公司、团队或组织隔离数据。
- 后台管理页面需要查看包含已删除记录的完整历史。
核心概念
| 概念 | 说明 |
|---|---|
| 软删除 | 不物理删除行,而是设置 IsDeleted、DeletedAt 等字段 |
| 审计字段 | 记录创建、修改、删除的时间和操作者 |
| 全局查询过滤器 | EF Core 在实体级别自动追加的查询条件 |
| 多租户 | 多个租户共享同一套应用,但数据必须隔离 |
| 租户上下文 | 当前请求所属租户信息,通常来自认证 Claim、域名或 Header |
案例:可审计的任务实体
把通用字段抽成接口,可以让 DbContext 在保存时集中处理。
public interface IAuditableEntity
{
DateTimeOffset CreatedAt { get; set; }
string CreatedBy { get; set; }
DateTimeOffset? UpdatedAt { get; set; }
string? UpdatedBy { get; set; }
}
public interface ISoftDelete
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}
public interface ITenantEntity
{
string TenantId { get; set; }
}
public sealed class TodoItem : IAuditableEntity, ISoftDelete, ITenantEntity
{
public int Id { get; set; }
public required string TenantId { get; set; }
public required string Title { get; set; }
public bool IsCompleted { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string CreatedBy { get; set; } = string.Empty;
public DateTimeOffset? UpdatedAt { get; set; }
public string? UpdatedBy { get; set; }
}
示例:租户上下文
租户 ID 不应由普通请求体传入。更常见的做法是从认证 Claim、子域名或可信网关注入的 Header 中解析。
public interface ITenantContext
{
string TenantId { get; }
string UserId { get; }
}
public sealed class HttpTenantContext(IHttpContextAccessor httpContextAccessor)
: ITenantContext
{
public string TenantId => GetRequiredClaim("tenant_id");
public string UserId => GetRequiredClaim(ClaimTypes.NameIdentifier);
private string GetRequiredClaim(string type)
{
var user = httpContextAccessor.HttpContext?.User;
var value = user?.FindFirstValue(type);
return string.IsNullOrWhiteSpace(value)
? throw new InvalidOperationException($"Missing required claim: {type}")
: value;
}
}
注册时把租户上下文放在 Scoped 生命周期中:
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, HttpTenantContext>();
示例:全局查询过滤器
全局过滤器可以自动给查询追加租户和软删除条件,避免每个查询都手写 TenantId 与 IsDeleted。
public sealed class AppDbContext(
DbContextOptions<AppDbContext> options,
ITenantContext tenantContext) : DbContext(options)
{
public DbSet<TodoItem> Todos => Set<TodoItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TodoItem>(entity =>
{
entity.Property(todo => todo.TenantId).HasMaxLength(64).IsRequired();
entity.Property(todo => todo.Title).HasMaxLength(120).IsRequired();
entity.Property(todo => todo.CreatedBy).HasMaxLength(128).IsRequired();
entity.Property(todo => todo.UpdatedBy).HasMaxLength(128);
entity.Property(todo => todo.DeletedBy).HasMaxLength(128);
entity.HasQueryFilter(todo =>
todo.TenantId == tenantContext.TenantId && !todo.IsDeleted);
entity.HasIndex(todo => new { todo.TenantId, todo.IsDeleted });
});
}
}
后台管理、数据恢复和审计查询有时需要包含软删除记录,可以显式使用 IgnoreQueryFilters(),但必须重新加上租户条件,避免绕过隔离。
var deletedTodos = await dbContext.Todos
.IgnoreQueryFilters()
.Where(todo => todo.TenantId == tenantContext.TenantId && todo.IsDeleted)
.OrderByDescending(todo => todo.DeletedAt)
.ToListAsync(cancellationToken);
示例:集中填充审计字段
重写 SaveChangesAsync 可以把审计字段的填充集中在一个地方。这样服务层只关注业务动作,不需要每次手写创建时间和修改人。
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var now = DateTimeOffset.UtcNow;
var userId = tenantContext.UserId;
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = userId;
}
if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
}
}
foreach (var entry in ChangeTracker.Entries<ITenantEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.TenantId = tenantContext.TenantId;
}
}
return base.SaveChangesAsync(cancellationToken);
}
软删除通常不要调用 Remove 后直接物理删除,而是提供明确的业务方法。
public async Task<bool> DeleteAsync(int id, CancellationToken cancellationToken)
{
var todo = await dbContext.Todos.SingleOrDefaultAsync(
item => item.Id == id,
cancellationToken);
if (todo is null)
{
return false;
}
todo.IsDeleted = true;
todo.DeletedAt = DateTimeOffset.UtcNow;
todo.DeletedBy = tenantContext.UserId;
await dbContext.SaveChangesAsync(cancellationToken);
return true;
}
多租户策略
| 策略 | 适合场景 | 取舍 |
|---|---|---|
| 共享数据库、共享表 | 中小型 SaaS、租户数量多 | 成本低,但必须严控查询过滤和索引 |
| 共享数据库、独立 Schema | 租户数量适中、隔离要求更高 | 迁移和运维更复杂 |
| 独立数据库 | 大客户、强隔离、合规要求高 | 成本和自动化要求最高 |
入门阶段通常先掌握共享表加 TenantId 的模型。生产系统如果涉及强合规、数据驻留或大客户隔离,需要在架构阶段提前设计租户边界。
重点难点
- 全局查询过滤器减少漏写条件的风险,但
IgnoreQueryFilters()会绕过它,必须谨慎使用。 - 软删除字段应参与索引,否则列表查询会在数据变多后变慢。
- 软删除不是万能回收站,仍要设计归档、保留期限和物理清理策略。
- 审计字段记录“谁做了什么”,但敏感变更通常还需要单独审计日志表。
- 租户 ID 必须来自可信上下文,不能相信前端随意传入的租户参数。
常见误区
| 误区 | 推荐做法 |
|---|---|
每个查询手写 IsDeleted == false | 使用全局查询过滤器统一处理 |
管理后台查询直接 IgnoreQueryFilters() | 绕过过滤器后手动补租户条件 |
| 把租户 ID 放在请求体让用户传 | 从认证 Claim、域名或可信 Header 获取 |
| 审计字段由前端传入 | 后端在保存时统一填充 |
| 软删除后永远不清理 | 制定归档和物理删除策略 |
练习
- 给
TodoItem增加IsDeleted、DeletedAt和DeletedBy字段。 - 为任务查询增加全局过滤器,只返回当前租户未删除任务。
- 写一个恢复软删除任务的方法,并说明需要哪些权限。
- 在
SaveChangesAsync中自动填充CreatedAt、CreatedBy、UpdatedAt和UpdatedBy。
延伸阅读
- 本手册:EF Core CRUD 与迁移
- 本手册:查询性能与常见陷阱
- 本手册:关系映射、事务与并发