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:
MSuMshk
2026-02-03 13:05:52 +08:00
parent cfacbf8363
commit e88c41c11e
10 changed files with 103 additions and 49 deletions

View File

@@ -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);
}
}

View File

@@ -18,6 +18,7 @@ public static class TenantServiceCollectionExtensions
{
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
services.TryAddScoped<ITenantProvider, TenantProvider>();
services.TryAddScoped<ITenantCodeResolver, DatabaseTenantCodeResolver>();
services.AddOptions<TenantResolutionOptions>()
.Bind(configuration.GetSection("Tenancy"))

View File

@@ -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);
}

View File

@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />

View File

@@ -22,7 +22,7 @@ public sealed class TenantResolutionMiddleware(
/// <summary>
/// 解析租户并将上下文注入请求。
/// </summary>
public async Task InvokeAsync(HttpContext context)
public async Task InvokeAsync(HttpContext context, ITenantCodeResolver tenantCodeResolver)
{
var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions();
if (ShouldSkip(context.Request.Path, options))
@@ -31,7 +31,7 @@ public sealed class TenantResolutionMiddleware(
return;
}
var tenantContext = ResolveTenant(context, options);
var tenantContext = await ResolveTenantAsync(context, options, tenantCodeResolver);
tenantContextAccessor.Current = 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 isAuthenticated = context.User?.Identity?.IsAuthenticated == true;
@@ -117,9 +120,10 @@ public sealed class TenantResolutionMiddleware(
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader))
{
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;
if (!string.IsNullOrWhiteSpace(host))
{
// 4.1 精确域名映射
if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost) && tenantFromHost > 0)
{
return new TenantContext(tenantFromHost, null, $"host:{host}");
}
// 4.2 子域名解析
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;
}
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))
{
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)