diff --git a/src/Api/TakeoutSaaS.TenantApi/Program.cs b/src/Api/TakeoutSaaS.TenantApi/Program.cs index b20bcb6..9802d8d 100644 --- a/src/Api/TakeoutSaaS.TenantApi/Program.cs +++ b/src/Api/TakeoutSaaS.TenantApi/Program.cs @@ -85,8 +85,7 @@ if (isDevelopment) }); } -// 5. 注册多租户解析、鉴权授权与权限策略 -builder.Services.AddTenantResolution(builder.Configuration); +// 5. 注册鉴权授权与权限策略 builder.Services.AddJwtAuthentication(builder.Configuration); builder.Services.AddAuthorization(); builder.Services.AddPermissionAuthorization(); @@ -98,6 +97,9 @@ builder.Services.AddIdentityApplication(enableMiniSupport: false); builder.Services.AddAppInfrastructure(builder.Configuration); builder.Services.AddIdentityInfrastructure(builder.Configuration, enableMiniFeatures: false, enableAdminSeed: false); +// 7. 注册多租户解析(依赖 ITenantRepository,需在 Infrastructure 之后) +builder.Services.AddTenantResolution(builder.Configuration); + // 6. (空行后) 注册字典模块(系统参数、字典项、缓存等) builder.Services.AddDictionaryApplication(); builder.Services.AddDictionaryInfrastructure(builder.Configuration); diff --git a/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json b/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json index 28e2b17..68b96da 100644 --- a/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.TenantApi/appsettings.Development.json @@ -56,7 +56,9 @@ "/health", "/healthz" ], - "RootDomain": "" + "RootDomain": "laosankeji.com", + "CodeTenantMap": {}, + "ThrowIfUnresolved": false }, "Cors": { "Tenant": [] diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs index 3046546..e03ee2f 100644 --- a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/ITenantRepository.cs @@ -84,6 +84,14 @@ public interface ITenantRepository /// 存在返回 true,否则 false。 Task ExistsByCodeAsync(string code, CancellationToken cancellationToken = default); + /// + /// 依据租户编码查询租户 ID。 + /// + /// 租户编码。 + /// 取消标记。 + /// 租户 ID,未找到返回 null。 + Task FindIdByCodeAsync(string code, CancellationToken cancellationToken = default); + /// /// 判断租户名称是否存在(支持排除指定租户)。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs index 2b091a0..cd56dff 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Repositories/EfTenantRepository.cs @@ -162,6 +162,20 @@ public sealed class EfTenantRepository(TakeoutAppDbContext context, TakeoutLogsD return context.Tenants.AnyAsync(x => x.Code == normalized, cancellationToken); } + /// + public Task 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); + } + /// public Task ExistsByNameAsync(string name, long? excludeTenantId = null, CancellationToken cancellationToken = default) { diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs index 7f6ad99..ed63222 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Repositories/EfMenuRepository.cs @@ -2,65 +2,37 @@ 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; /// /// 菜单仓储 EF 实现。 /// -public sealed class EfMenuRepository(IdentityDbContext dbContext, ITenantContextAccessor tenantContextAccessor) : IMenuRepository +public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository { /// public async Task> GetByTenantAsync(long tenantId, CancellationToken cancellationToken = default) { - // 1. 优先返回租户自定义菜单 + // 1. 仅返回该租户的菜单,无回退逻辑 var tenantMenus = await dbContext.MenuDefinitions .AsNoTracking() - .Where(x => x.TenantId == tenantId) + .Where(x => x.TenantId == tenantId && x.DeletedAt == null) .OrderBy(x => x.ParentId) .ThenBy(x => x.SortOrder) .ToListAsync(cancellationToken); - if (tenantMenus.Count > 0) - { - return tenantMenus; - } - // 2. (空行后) 回退系统默认菜单(TenantId=0) - 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 tenantMenus; } /// public async Task FindByIdAsync(long id, long tenantId, CancellationToken cancellationToken = default) { - // 1. 优先查租户菜单 - var tenantMenu = await dbContext.MenuDefinitions + // 1. 仅查询该租户的菜单,无回退逻辑 + return await dbContext.MenuDefinitions .AsNoTracking() .FirstOrDefaultAsync( - x => x.Id == id && x.TenantId == tenantId, + x => x.Id == id && x.TenantId == tenantId && x.DeletedAt == null, 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); - } } /// diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/DatabaseTenantCodeResolver.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/DatabaseTenantCodeResolver.cs new file mode 100644 index 0000000..943f3a7 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/DatabaseTenantCodeResolver.cs @@ -0,0 +1,22 @@ +using TakeoutSaaS.Domain.Tenants.Repositories; + +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 基于数据库的租户编码解析器实现。 +/// +public sealed class DatabaseTenantCodeResolver(ITenantRepository tenantRepository) : ITenantCodeResolver +{ + /// + public Task ResolveAsync(string code, CancellationToken cancellationToken = default) + { + // 1. 参数校验 + if (string.IsNullOrWhiteSpace(code)) + { + return Task.FromResult(null); + } + + // 2. 从数据库查询租户 ID + return tenantRepository.FindIdByCodeAsync(code, cancellationToken); + } +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs index 72afde3..eeaa0a7 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/Extensions/TenantServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class TenantServiceCollectionExtensions { services.TryAddSingleton(); services.TryAddScoped(); + services.TryAddScoped(); services.AddOptions() .Bind(configuration.GetSection("Tenancy")) diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/ITenantCodeResolver.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/ITenantCodeResolver.cs new file mode 100644 index 0000000..064137c --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/ITenantCodeResolver.cs @@ -0,0 +1,15 @@ +namespace TakeoutSaaS.Module.Tenancy; + +/// +/// 租户编码解析器:根据租户编码查询租户 ID。 +/// +public interface ITenantCodeResolver +{ + /// + /// 根据租户编码查询租户 ID。 + /// + /// 租户编码。 + /// 取消令牌。 + /// 租户 ID,未找到返回 null。 + Task ResolveAsync(string code, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj b/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj index fdb3727..facba47 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs index be230e0..60fa5ca 100644 --- a/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TenantResolutionMiddleware.cs @@ -22,7 +22,7 @@ public sealed class TenantResolutionMiddleware( /// /// 解析租户并将上下文注入请求。 /// - 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 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 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)