178 lines
5.7 KiB
C#
178 lines
5.7 KiB
C#
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
using TakeoutSaaS.Shared.Abstractions.Results;
|
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
|
|
namespace TakeoutSaaS.Module.Tenancy;
|
|
|
|
/// <summary>
|
|
/// 多租户解析中间件:支持 Header、域名与 Token Claim 的优先级解析。
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 初始化中间件。
|
|
/// </remarks>
|
|
public sealed class TenantResolutionMiddleware(
|
|
RequestDelegate next,
|
|
ILogger<TenantResolutionMiddleware> logger,
|
|
ITenantContextAccessor tenantContextAccessor,
|
|
IOptionsMonitor<TenantResolutionOptions> optionsMonitor)
|
|
{
|
|
/// <summary>
|
|
/// 解析租户并将上下文注入请求。
|
|
/// </summary>
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions();
|
|
if (ShouldSkip(context.Request.Path, options))
|
|
{
|
|
await next(context);
|
|
return;
|
|
}
|
|
|
|
var tenantContext = ResolveTenant(context, options);
|
|
tenantContextAccessor.Current = tenantContext;
|
|
context.Items[TenantConstants.HttpContextItemKey] = tenantContext;
|
|
|
|
if (!tenantContext.IsResolved)
|
|
{
|
|
logger.LogDebug("未能解析租户:{Path}", context.Request.Path);
|
|
|
|
if (options.ThrowIfUnresolved)
|
|
{
|
|
var response = ApiResponse.Error(ErrorCodes.BadRequest, "缺少租户标识");
|
|
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
|
await context.Response.WriteAsJsonAsync(response, cancellationToken: context.RequestAborted);
|
|
tenantContextAccessor.Current = null;
|
|
context.Items.Remove(TenantConstants.HttpContextItemKey);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
await next(context);
|
|
}
|
|
finally
|
|
{
|
|
tenantContextAccessor.Current = null;
|
|
context.Items.Remove(TenantConstants.HttpContextItemKey);
|
|
}
|
|
}
|
|
|
|
private static bool ShouldSkip(PathString path, TenantResolutionOptions options)
|
|
{
|
|
if (!path.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var value = path.Value ?? string.Empty;
|
|
if (options.IgnoredPaths.Contains(value))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return options.IgnoredPaths.Any(ignore =>
|
|
{
|
|
if (string.IsNullOrWhiteSpace(ignore))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var ignorePath = new PathString(ignore);
|
|
return path.StartsWithSegments(ignorePath);
|
|
});
|
|
}
|
|
|
|
private static TenantContext ResolveTenant(HttpContext context, TenantResolutionOptions options)
|
|
{
|
|
var request = context.Request;
|
|
|
|
// 1. Header 中的租户 ID
|
|
if (!string.IsNullOrWhiteSpace(options.TenantIdHeaderName) &&
|
|
request.Headers.TryGetValue(options.TenantIdHeaderName, out var tenantHeader) &&
|
|
long.TryParse(tenantHeader.FirstOrDefault(), out var headerTenantId))
|
|
{
|
|
return new TenantContext(headerTenantId, null, $"header:{options.TenantIdHeaderName}");
|
|
}
|
|
|
|
// 2. Header 中的租户编码
|
|
if (!string.IsNullOrWhiteSpace(options.TenantCodeHeaderName) &&
|
|
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var codeHeader))
|
|
{
|
|
var code = codeHeader.FirstOrDefault();
|
|
if (TryResolveByCode(code, options, out var tenantFromCode))
|
|
{
|
|
return new TenantContext(tenantFromCode, code, $"header:{options.TenantCodeHeaderName}");
|
|
}
|
|
}
|
|
|
|
// 3. Host 映射/子域名解析
|
|
var host = request.Host.Host;
|
|
if (!string.IsNullOrWhiteSpace(host))
|
|
{
|
|
if (options.DomainTenantMap.TryGetValue(host, out var tenantFromHost))
|
|
{
|
|
return new TenantContext(tenantFromHost, null, $"host:{host}");
|
|
}
|
|
|
|
var codeFromHost = ResolveCodeFromHost(host, options.RootDomain);
|
|
if (TryResolveByCode(codeFromHost, options, out var tenantFromSubdomain))
|
|
{
|
|
return new TenantContext(tenantFromSubdomain, codeFromHost, $"host:{host}");
|
|
}
|
|
}
|
|
|
|
// 4. Token Claim
|
|
var claim = context.User?.FindFirst("tenant_id");
|
|
if (claim != null && long.TryParse(claim.Value, out var claimTenant))
|
|
{
|
|
return new TenantContext(claimTenant, null, "claim:tenant_id");
|
|
}
|
|
|
|
return TenantContext.Empty;
|
|
}
|
|
|
|
private static bool TryResolveByCode(string? code, TenantResolutionOptions options, out long tenantId)
|
|
{
|
|
tenantId = 0;
|
|
if (string.IsNullOrWhiteSpace(code))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return options.CodeTenantMap.TryGetValue(code, out tenantId);
|
|
}
|
|
|
|
private static string? ResolveCodeFromHost(string host, string? rootDomain)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(rootDomain))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var normalizedRoot = rootDomain.TrimStart('.');
|
|
if (!host.EndsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var suffixLength = normalizedRoot.Length;
|
|
if (host.Length <= suffixLength)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var withoutRoot = host[..(host.Length - suffixLength)];
|
|
if (withoutRoot.EndsWith('.'))
|
|
{
|
|
withoutRoot = withoutRoot[..^1];
|
|
}
|
|
|
|
var segments = withoutRoot.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
|
return segments.Length == 0 ? null : segments[0];
|
|
}
|
|
}
|