fix: 统一逐租户上下文执行

This commit is contained in:
root
2026-01-30 01:04:51 +00:00
parent 41cfd2e2e8
commit cf697b3889
29 changed files with 1037 additions and 490 deletions

View File

@@ -49,15 +49,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="excludeUserId">排除的用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true。</returns>
public Task<bool> ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default)
public async Task<bool> ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default)
{
// 1. 标准化账号
var normalized = account.Trim();
// 2. 构建查询(包含已删除数据)
var query = dbContext.IdentityUsers
.IgnoreQueryFilters()
.AsNoTracking()
// 2. 构建查询(包含已删除数据,但不放开租户过滤
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var query = dbContext.IdentityUsers.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Account == normalized);
if (excludeUserId.HasValue)
@@ -66,7 +65,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
}
// 3. 返回是否存在
return query.AnyAsync(cancellationToken);
return await query.AnyAsync(cancellationToken);
}
/// <summary>
@@ -77,15 +76,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="excludeUserId">排除的用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true。</returns>
public Task<bool> ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default)
public async Task<bool> ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default)
{
// 1. 标准化手机号
var normalized = phone.Trim();
// 2. 构建查询(包含已删除数据)
var query = dbContext.IdentityUsers
.IgnoreQueryFilters()
.AsNoTracking()
// 2. 构建查询(包含已删除数据,但不放开租户过滤
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var query = dbContext.IdentityUsers.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Phone == normalized);
if (excludeUserId.HasValue)
@@ -94,7 +92,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
}
// 3. 返回是否存在
return query.AnyAsync(cancellationToken);
return await query.AnyAsync(cancellationToken);
}
/// <summary>
@@ -105,15 +103,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="excludeUserId">排除的用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>存在返回 true。</returns>
public Task<bool> ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default)
public async Task<bool> ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default)
{
// 1. 标准化邮箱
var normalized = email.Trim();
// 2. 构建查询(包含已删除数据)
var query = dbContext.IdentityUsers
.IgnoreQueryFilters()
.AsNoTracking()
// 2. 构建查询(包含已删除数据,但不放开租户过滤
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var query = dbContext.IdentityUsers.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.Email == normalized);
if (excludeUserId.HasValue)
@@ -122,7 +119,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
}
// 3. 返回是否存在
return query.AnyAsync(cancellationToken);
return await query.AnyAsync(cancellationToken);
}
/// <summary>
@@ -150,14 +147,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>后台用户或 null。</returns>
public Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
public async Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
long tenantId,
long userId,
CancellationToken cancellationToken = default)
{
// 1. 构建查询(包含已删除数据,但强制租户隔离)
return dbContext.IdentityUsers
.IgnoreQueryFilters()
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
return await dbContext.IdentityUsers
.Where(x => x.TenantId == tenantId)
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
}
@@ -204,21 +201,14 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
var tenantId = filter.TenantId.Value;
using var disableSoftDeleteScope = filter.IncludeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
// 1. 构建基础查询
var query = dbContext.IdentityUsers.AsNoTracking();
if (filter.IncludeDeleted)
{
query = query.IgnoreQueryFilters();
}
// 2. 租户过滤(强制)
query = query.Where(x => x.TenantId == tenantId);
if (!filter.IncludeDeleted)
{
query = query.Where(x => x.DeletedAt == null);
}
// 3. 关键字筛选
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
@@ -242,18 +232,9 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
{
var roleId = filter.RoleId.Value;
var userRoles = dbContext.UserRoles.AsNoTracking();
if (filter.IncludeDeleted)
{
userRoles = userRoles.IgnoreQueryFilters();
}
userRoles = userRoles.Where(x => x.TenantId == tenantId);
if (!filter.IncludeDeleted)
{
userRoles = userRoles.Where(x => x.DeletedAt == null);
}
query = query.Where(user => userRoles.Any(role => role.UserId == user.Id && role.RoleId == roleId));
}
@@ -346,18 +327,10 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
}
var query = dbContext.IdentityUsers.Where(x => ids.Contains(x.Id));
if (includeDeleted)
{
query = query.IgnoreQueryFilters();
}
using var disableSoftDeleteScope = includeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
query = query.Where(x => x.TenantId == tenantId);
if (!includeDeleted)
{
query = query.Where(x => x.DeletedAt == null);
}
// 2. 返回列表
return await query.ToListAsync(cancellationToken);
}

View File

@@ -18,9 +18,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == permissionId && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
/// <summary>
/// 根据权限编码获取权限。
@@ -31,9 +30,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Code == code && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
/// <summary>
/// 根据权限编码集合批量获取权限。
@@ -51,11 +49,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
.Distinct()
.ToArray();
// 2. 读取全局权限(已固定)
// 2. 读取租户权限
return dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && normalizedCodes.Contains(x.Code))
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && normalizedCodes.Contains(x.Code))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
}
@@ -69,9 +66,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null && permissionIds.Contains(x.Id))
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && permissionIds.Contains(x.Id))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
@@ -86,9 +82,8 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
{
// 1. 构建基础查询
var query = dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.DeletedAt == null);
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
if (!string.IsNullOrWhiteSpace(keyword))
{
// 2. 追加关键字过滤
@@ -139,7 +134,7 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询目标权限
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId, cancellationToken);
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken);
if (entity != null)
{
// 2. 删除实体

View File

@@ -16,13 +16,17 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR
/// <param name="roleIds">角色 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色权限映射列表。</returns>
public Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
=> dbContext.RolePermissions
.IgnoreQueryFilters()
public async Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
{
// 1. 查询角色权限映射
var mappings = await dbContext.RolePermissions
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && roleIds.Contains(x.RoleId))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<RolePermission>)t.Result, cancellationToken);
.ToListAsync(cancellationToken);
// 2. (空行后) 返回只读列表
return mappings;
}
/// <summary>
/// 批量新增角色权限。

View File

@@ -18,7 +18,6 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <returns>角色实体或 null。</returns>
public Task<Role?> FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
@@ -31,7 +30,6 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <returns>角色实体或 null。</returns>
public Task<Role?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
@@ -42,13 +40,17 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <param name="roleIds">角色 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色列表。</returns>
public Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
=> dbContext.Roles
.IgnoreQueryFilters()
public async Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
{
// 1. 查询角色列表
var roles = await dbContext.Roles
.AsNoTracking()
.Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id) && x.DeletedAt == null)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
.ToListAsync(cancellationToken);
// 2. (空行后) 返回只读列表
return roles;
}
/// <summary>
/// 按关键字搜索角色。
@@ -57,11 +59,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
/// <param name="keyword">搜索关键字。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>角色列表。</returns>
public Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
var query = dbContext.Roles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
if (!string.IsNullOrWhiteSpace(keyword))
@@ -72,8 +73,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
}
// 3. 返回列表
return query.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
var roles = await query.ToListAsync(cancellationToken);
// 4. (空行后) 返回只读列表
return roles;
}
/// <summary>

View File

@@ -16,13 +16,17 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
/// <param name="userIds">用户 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>用户角色映射列表。</returns>
public Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
public async Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
{
// 1. 查询用户角色映射
var mappings = await dbContext.UserRoles
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && userIds.Contains(x.UserId))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
.ToListAsync(cancellationToken);
// 2. (空行后) 返回只读列表
return mappings;
}
/// <summary>
/// 获取指定用户的角色集合。
@@ -31,13 +35,17 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
/// <param name="userId">用户 ID。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>用户角色列表。</returns>
public Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
public async Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
{
// 1. 查询用户角色映射
var mappings = await dbContext.UserRoles
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.UserId == userId)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
.ToListAsync(cancellationToken);
// 2. (空行后) 返回只读列表
return mappings;
}
/// <summary>
/// 替换指定用户的角色集合。
@@ -56,8 +64,8 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
// 2. 读取当前角色映射
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
var existing = await dbContext.UserRoles
.IgnoreQueryFilters()
.Where(x => x.TenantId == tenantId && x.UserId == userId)
.ToListAsync(cancellationToken);
@@ -111,7 +119,6 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
/// <returns>用户数量。</returns>
public Task<int> CountUsersByRoleAsync(long tenantId, long roleId, CancellationToken cancellationToken = default)
=> dbContext.UserRoles
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.RoleId == roleId)
.CountAsync(cancellationToken);

View File

@@ -59,7 +59,7 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
foreach (var userOptions in options.Users)
{
// 6.1 进入租户作用域
using var tenantScope = EnterTenantScope(tenantContextAccessor, userOptions.TenantId);
using var tenantScope = tenantContextAccessor.EnterTenantScope(userOptions.TenantId, "admin-seed");
// 6.2 查询账号并收集配置
var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken);
var roles = NormalizeValues(userOptions.Roles);
@@ -112,9 +112,8 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
});
}
// 6.6 读取全局权限定义(固定权限,不再按租户生成)
// 6.6 读取当前租户权限定义
var existingPermissions = await context.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(p => permissions.Contains(p.Code))
.ToListAsync(cancellationToken);
@@ -324,17 +323,4 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)];
private static IDisposable EnterTenantScope(ITenantContextAccessor accessor, long tenantId)
{
var previous = accessor.Current;
accessor.Current = new TenantContext(tenantId, null, "admin-seed");
return new Scope(() => accessor.Current = previous);
}
private sealed class Scope(Action disposeAction) : IDisposable
{
private readonly Action _disposeAction = disposeAction;
public void Dispose() => _disposeAction();
}
}

View File

@@ -2,13 +2,14 @@ using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
/// <summary>
/// 菜单仓储 EF 实现。
/// </summary>
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContextAccessor tenantContextAccessor) : IMenuRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
@@ -26,15 +27,17 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
}
// 2. (空行后) 回退系统默认菜单TenantId=0
var systemMenus = await dbContext.MenuDefinitions
.AsNoTracking()
.IgnoreQueryFilters()
.Where(x => x.TenantId == 0 && x.DeletedAt == null)
.OrderBy(x => x.ParentId)
.ThenBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
{
var systemMenus = await dbContext.MenuDefinitions
.AsNoTracking()
.Where(x => x.TenantId == 0 && x.DeletedAt == null)
.OrderBy(x => x.ParentId)
.ThenBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return systemMenus;
return systemMenus;
}
}
/// <inheritdoc />
@@ -52,10 +55,12 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
}
// 2. (空行后) 回退查系统默认菜单TenantId=0
return await dbContext.MenuDefinitions
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
{
return await dbContext.MenuDefinitions
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
}
}
/// <inheritdoc />