feat: 实现动态租户解析与移除菜单回退逻辑
1. 新增 ITenantCodeResolver 接口和 DatabaseTenantCodeResolver 实现 2. 修改 TenantResolutionMiddleware 支持从数据库动态解析租户编码 3. ITenantRepository 新增 FindIdByCodeAsync 方法 4. 移除 EfMenuRepository 中危险的系统菜单回退逻辑 5. 调整服务注册顺序确保依赖正确注入 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,8 +85,7 @@ if (isDevelopment)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 注册多租户解析、鉴权授权与权限策略
|
// 5. 注册鉴权授权与权限策略
|
||||||
builder.Services.AddTenantResolution(builder.Configuration);
|
|
||||||
builder.Services.AddJwtAuthentication(builder.Configuration);
|
builder.Services.AddJwtAuthentication(builder.Configuration);
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddPermissionAuthorization();
|
builder.Services.AddPermissionAuthorization();
|
||||||
@@ -98,6 +97,9 @@ builder.Services.AddIdentityApplication(enableMiniSupport: false);
|
|||||||
builder.Services.AddAppInfrastructure(builder.Configuration);
|
builder.Services.AddAppInfrastructure(builder.Configuration);
|
||||||
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false);
|
||||||
|
|
||||||
|
// 7. 注册多租户解析(依赖 ITenantRepository,需在 Infrastructure 之后)
|
||||||
|
builder.Services.AddTenantResolution(builder.Configuration);
|
||||||
|
|
||||||
// 6. (空行后) 注册字典模块(系统参数、字典项、缓存等)
|
// 6. (空行后) 注册字典模块(系统参数、字典项、缓存等)
|
||||||
builder.Services.AddDictionaryApplication();
|
builder.Services.AddDictionaryApplication();
|
||||||
builder.Services.AddDictionaryInfrastructure(builder.Configuration);
|
builder.Services.AddDictionaryInfrastructure(builder.Configuration);
|
||||||
|
|||||||
@@ -56,7 +56,9 @@
|
|||||||
"/health",
|
"/health",
|
||||||
"/healthz"
|
"/healthz"
|
||||||
],
|
],
|
||||||
"RootDomain": ""
|
"RootDomain": "laosankeji.com",
|
||||||
|
"CodeTenantMap": {},
|
||||||
|
"ThrowIfUnresolved": false
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Tenant": []
|
"Tenant": []
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ public interface ITenantRepository
|
|||||||
/// <returns>存在返回 true,否则 false。</returns>
|
/// <returns>存在返回 true,否则 false。</returns>
|
||||||
Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
|
Task<bool> ExistsByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 依据租户编码查询租户 ID。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="code">租户编码。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>租户 ID,未找到返回 null。</returns>
|
||||||
|
Task<long?> FindIdByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 判断租户名称是否存在(支持排除指定租户)。
|
/// 判断租户名称是否存在(支持排除指定租户)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -162,6 +162,20 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD
|
|||||||
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
|
return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<long?> FindIdByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 标准化编码
|
||||||
|
var normalized = code.Trim();
|
||||||
|
|
||||||
|
// 2. 查询租户 ID(仅查询未删除且状态正常的租户)
|
||||||
|
return context.Tenants
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Code == normalized && x.DeletedAt == null)
|
||||||
|
.Select(x => (long?)x.Id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
|
public Task<bool> ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,65 +2,37 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using TakeoutSaaS.Domain.Identity.Entities;
|
using TakeoutSaaS.Domain.Identity.Entities;
|
||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
||||||
|
|
||||||
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
|
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 菜单仓储 EF 实现。
|
/// 菜单仓储 EF 实现。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContextAccessor tenantContextAccessor) : IMenuRepository
|
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
|
public async Task<IReadOnlyList<MenuDefinition>> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. 优先返回租户自定义菜单
|
// 1. 仅返回该租户的菜单,无回退逻辑
|
||||||
var tenantMenus = await dbContext.MenuDefinitions
|
var tenantMenus = await dbContext.MenuDefinitions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId)
|
.Where(x => x.TenantId == tenantId && x.DeletedAt == null)
|
||||||
.OrderBy(x => x.ParentId)
|
.OrderBy(x => x.ParentId)
|
||||||
.ThenBy(x => x.SortOrder)
|
.ThenBy(x => x.SortOrder)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (tenantMenus.Count > 0)
|
|
||||||
{
|
|
||||||
return tenantMenus;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 回退系统默认菜单(TenantId=0)
|
return tenantMenus;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<MenuDefinition?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
|
public async Task<MenuDefinition?> FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. 优先查租户菜单
|
// 1. 仅查询该租户的菜单,无回退逻辑
|
||||||
var tenantMenu = await dbContext.MenuDefinitions
|
return await dbContext.MenuDefinitions
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
x => x.Id == id && x.TenantId == tenantId,
|
x => x.Id == id && x.TenantId == tenantId && x.DeletedAt == null,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (tenantMenu != null)
|
|
||||||
{
|
|
||||||
return tenantMenu;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. (空行后) 回退查系统默认菜单(TenantId=0)
|
|
||||||
using (tenantContextAccessor.EnterTenantScope(0, "menu"))
|
|
||||||
{
|
|
||||||
return await dbContext.MenuDefinitions
|
|
||||||
.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.TenantId == 0 && x.DeletedAt == null, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||||
|
|
||||||
|
namespace TakeoutSaaS.Module.Tenancy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 基于数据库的租户编码解析器实现。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DatabaseTenantCodeResolver(ITenantRepository tenantRepository) : ITenantCodeResolver
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<long?> ResolveAsync(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 参数校验
|
||||||
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
|
{
|
||||||
|
return Task.FromResult<long?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从数据库查询租户 ID
|
||||||
|
return tenantRepository.FindIdByCodeAsync(code, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public static class TenantServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
||||||
services.TryAddScoped<ITenantProvider, TenantProvider>();
|
services.TryAddScoped<ITenantProvider, TenantProvider>();
|
||||||
|
services.TryAddScoped<ITenantCodeResolver, DatabaseTenantCodeResolver>();
|
||||||
|
|
||||||
services.AddOptions<TenantResolutionOptions>()
|
services.AddOptions<TenantResolutionOptions>()
|
||||||
.Bind(configuration.GetSection("Tenancy"))
|
.Bind(configuration.GetSection("Tenancy"))
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TakeoutSaaS.Module.Tenancy;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 租户编码解析器:根据租户编码查询租户 ID。
|
||||||
|
/// </summary>
|
||||||
|
public interface ITenantCodeResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 根据租户编码查询租户 ID。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="code">租户编码。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
/// <returns>租户 ID,未找到返回 null。</returns>
|
||||||
|
Task<long?> ResolveAsync(string code, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public sealed class TenantResolutionMiddleware(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 解析租户并将上下文注入请求。
|
/// 解析租户并将上下文注入请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context, ITenantCodeResolver tenantCodeResolver)
|
||||||
{
|
{
|
||||||
var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions();
|
var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions();
|
||||||
if (ShouldSkip(context.Request.Path, options))
|
if (ShouldSkip(context.Request.Path, options))
|
||||||
@@ -31,7 +31,7 @@ public sealed class TenantResolutionMiddleware(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tenantContext = ResolveTenant(context, options);
|
var tenantContext = await ResolveTenantAsync(context, options, tenantCodeResolver);
|
||||||
tenantContextAccessor.Current = tenantContext;
|
tenantContextAccessor.Current = tenantContext;
|
||||||
context.Items[TenantConstants.HttpContextItemKey] = tenantContext;
|
context.Items[TenantConstants.HttpContextItemKey] = tenantContext;
|
||||||
|
|
||||||
@@ -86,7 +86,10 @@ public sealed class TenantResolutionMiddleware(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TenantContext ResolveTenant(HttpContext context, TenantResolutionOptions options)
|
private static async Task<TenantContext> ResolveTenantAsync(
|
||||||
|
HttpContext context,
|
||||||
|
TenantResolutionOptions options,
|
||||||
|
ITenantCodeResolver tenantCodeResolver)
|
||||||
{
|
{
|
||||||
var request = context.Request;
|
var request = context.Request;
|
||||||
var isAuthenticated = context.User?.Identity?.IsAuthenticated == true;
|
var isAuthenticated = context.User?.Identity?.IsAuthenticated == true;
|
||||||
@@ -117,9 +120,10 @@ public sealed class TenantResolutionMiddleware(
|
|||||||
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader))
|
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader))
|
||||||
{
|
{
|
||||||
var code = codeHeader.FirstOrDefault();
|
var code = codeHeader.FirstOrDefault();
|
||||||
if (TryResolveByCode(code, options, out var tenantFromCode))
|
var tenantFromCode = await ResolveByCodeAsync(code, options, tenantCodeResolver, context.RequestAborted);
|
||||||
|
if (tenantFromCode.HasValue)
|
||||||
{
|
{
|
||||||
return new TenantContext(tenantFromCode, code, $"header:{options.TenantCodeHeaderName}");
|
return new TenantContext(tenantFromCode.Value, code, $"header:{options.TenantCodeHeaderName}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,30 +131,43 @@ public sealed class TenantResolutionMiddleware(
|
|||||||
var host = request.Host.Host;
|
var host = request.Host.Host;
|
||||||
if (!string.IsNullOrWhiteSpace(host))
|
if (!string.IsNullOrWhiteSpace(host))
|
||||||
{
|
{
|
||||||
|
// 4.1 精确域名映射
|
||||||
if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost) && tenantFromHost > 0)
|
if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost) && tenantFromHost > 0)
|
||||||
{
|
{
|
||||||
return new TenantContext(tenantFromHost, null, $"host:{host}");
|
return new TenantContext(tenantFromHost, null, $"host:{host}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4.2 子域名解析
|
||||||
var codeFromHost = ResolveCodeFromHost(host, options.RootDomain);
|
var codeFromHost = ResolveCodeFromHost(host, options.RootDomain);
|
||||||
if (TryResolveByCode(codeFromHost, options, out var tenantFromSubdomain))
|
var tenantFromSubdomain = await ResolveByCodeAsync(codeFromHost, options, tenantCodeResolver, context.RequestAborted);
|
||||||
|
if (tenantFromSubdomain.HasValue)
|
||||||
{
|
{
|
||||||
return new TenantContext(tenantFromSubdomain, codeFromHost, $"host:{host}");
|
return new TenantContext(tenantFromSubdomain.Value, codeFromHost, $"host:{host}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return TenantContext.Empty;
|
return TenantContext.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryResolveByCode(string? code, TenantResolutionOptions options, out long tenantId)
|
private static async Task<long?> ResolveByCodeAsync(
|
||||||
|
string? code,
|
||||||
|
TenantResolutionOptions options,
|
||||||
|
ITenantCodeResolver tenantCodeResolver,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
tenantId = 0;
|
|
||||||
if (string.IsNullOrWhiteSpace(code))
|
if (string.IsNullOrWhiteSpace(code))
|
||||||
{
|
{
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return options.CodeTenantMap.TryGetValue(code, out tenantId) && tenantId > 0;
|
// 1. 优先从静态配置查找(兼容旧配置)
|
||||||
|
if (options.CodeTenantMap.TryGetValue(code, out var tenantId) && tenantId > 0)
|
||||||
|
{
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从数据库动态查询
|
||||||
|
return await tenantCodeResolver.ResolveAsync(code, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ResolveCodeFromHost(string host, string? rootDomain)
|
private static string? ResolveCodeFromHost(string host, string? rootDomain)
|
||||||
|
|||||||
Reference in New Issue
Block a user