refactor: 将 Permission 和 MenuDefinition 改为系统级实体

- Permission 和 MenuDefinition 改为继承 AuditableEntityBase(移除 TenantId)
- 添加 PortalType 枚举区分平台端/租户端
- Repository 使用 IgnoreQueryFilters() 查询系统级数据
- 更新所有相关 Handler 和 DTO,移除 TenantId 引用
- 与 AdminApi 保持一致的设计

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MSuMshk
2026-02-03 14:49:27 +08:00
parent e88c41c11e
commit 5a26f82628
22 changed files with 108 additions and 110 deletions

View File

@@ -7,36 +7,41 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// EF 权限仓储。
/// </summary>
/// <remarks>
/// 权限是系统级数据,使用 IgnoreQueryFilters 忽略多租户过滤。
/// </remarks>
public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermissionRepository
{
/// <summary>
/// 根据权限 ID 获取权限。
/// </summary>
/// <param name="permissionId">权限 ID。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(x => x.Id == permissionId && x.DeletedAt == null, cancellationToken);
/// <summary>
/// 根据权限编码获取权限。
/// </summary>
/// <param name="code">权限编码。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限实体或 null。</returns>
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken);
.FirstOrDefaultAsync(x => x.Code == code && x.DeletedAt == null, cancellationToken);
/// <summary>
/// 根据权限编码集合批量获取权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="codes">权限编码集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
@@ -49,10 +54,11 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
.Distinct()
.ToArray();
// 2. 读取租户权限
// 2. 读取权限(忽略租户过滤)
return dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && normalizedCodes.Contains(x.Code))
.Where(x => x.DeletedAt == null && normalizedCodes.Contains(x.Code))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
}
@@ -60,30 +66,32 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// <summary>
/// 根据权限 ID 集合批量获取权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="permissionIds">权限 ID 集合。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
=> dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && permissionIds.Contains(x.Id))
.Where(x => x.DeletedAt == null && permissionIds.Contains(x.Id))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
/// <summary>
/// 按关键字搜索权限。
/// </summary>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="keyword">搜索关键字。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>权限列表。</returns>
public Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
{
// 1. 构建基础查询
// 1. 构建基础查询(忽略租户过滤)
var query = dbContext.Permissions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null);
.Where(x => x.DeletedAt == null);
if (!string.IsNullOrWhiteSpace(keyword))
{
// 2. 追加关键字过滤
@@ -128,13 +136,15 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
/// 删除指定权限。
/// </summary>
/// <param name="permissionId">权限 ID。</param>
/// <param name="tenantId">租户 ID。</param>
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步任务。</returns>
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
{
// 1. 查询目标权限
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken);
var entity = await dbContext.Permissions
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == permissionId, cancellationToken);
if (entity != null)
{
// 2. 删除实体

View File

@@ -160,7 +160,6 @@ public sealed class IdentityDbContext(
{
builder.ToTable("permissions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.ParentId).IsRequired();
builder.Property(x => x.SortOrder).IsRequired();
builder.Property(x => x.Type).HasMaxLength(16).IsRequired();
@@ -169,9 +168,8 @@ public sealed class IdentityDbContext(
builder.Property(x => x.Description).HasMaxLength(256);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder });
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
builder.HasIndex(x => x.Code).IsUnique();
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
}
private static void ConfigureRoleTemplate(EntityTypeBuilder<RoleTemplate> builder)
@@ -226,7 +224,7 @@ public sealed class IdentityDbContext(
{
builder.ToTable("menu_definitions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
builder.Property(x => x.ParentId).IsRequired();
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Path).HasMaxLength(256).IsRequired();
@@ -241,6 +239,6 @@ public sealed class IdentityDbContext(
builder.Property(x => x.AuthListJson).HasColumnType("text");
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => new { x.TenantId, x.ParentId, x.SortOrder });
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Infrastructure.Identity.Persistence;
@@ -11,28 +12,28 @@ namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
{
/// <inheritdoc />
public async Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<MenuDefinition>> GetByPortalAsync(PortalType portal, CancellationToken cancellationToken = default)
{
// 1. 仅返回该租户的菜单,无回退逻辑
var tenantMenus = await dbContext.MenuDefinitions
// 1. 按门户类型查询菜单(忽略租户过滤器)
var menus = await dbContext.MenuDefinitions
.IgnoreQueryFilters()
.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.DeletedAt == null)
.Where(x => x.Portal == portal && x.DeletedAt == null)
.OrderBy(x => x.ParentId)
.ThenBy(x => x.SortOrder)
.ToListAsync(cancellationToken);
return tenantMenus;
return menus;
}
/// <inheritdoc />
public async Task<MenuDefinition?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
public async Task<MenuDefinition?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
{
// 1. 仅查询该租户的菜单,无回退逻辑
// 1. 按 ID 查询菜单(忽略租户过滤器)
return await dbContext.MenuDefinitions
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(
x => x.Id == id && x.TenantId == tenantId && x.DeletedAt == null,
cancellationToken);
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
}
/// <inheritdoc />
@@ -49,11 +50,12 @@ public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuReposit
}
/// <inheritdoc />
public async Task DeleteAsync(long id, long tenantId, CancellationToken cancellationToken = default)
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
{
// 1. 查询目标
// 1. 查询目标(忽略租户过滤器)
var entity = await dbContext.MenuDefinitions
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == tenantId, cancellationToken);
.IgnoreQueryFilters()
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
// 2. 存在则删除
if (entity is not null)