chore: 提交当前变更

This commit is contained in:
2025-11-23 12:47:29 +08:00
parent cd52131c34
commit 429d4fb747
46 changed files with 1864 additions and 63 deletions

View File

@@ -0,0 +1,22 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using TakeoutSaaS.Application.Dictionary.Extensions;
using TakeoutSaaS.Infrastructure.Dictionary.Extensions;
namespace TakeoutSaaS.Module.Dictionary.Extensions;
/// <summary>
/// 字典模块服务扩展。
/// </summary>
public static class DictionaryModuleExtensions
{
/// <summary>
/// 注册字典模块应用层与基础设施。
/// </summary>
public static IServiceCollection AddDictionaryModule(this IServiceCollection services, IConfiguration configuration)
{
services.AddDictionaryApplication();
services.AddDictionaryInfrastructure(configuration);
return services;
}
}

View File

@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Module.Tenancy.Extensions;
/// <summary>
/// 多租户服务注册及中间件扩展。
/// </summary>
public static class TenantServiceCollectionExtensions
{
/// <summary>
/// 注册租户上下文、解析中间件及默认租户提供者。
/// </summary>
public static IServiceCollection AddTenantResolution(this IServiceCollection services, IConfiguration configuration)
{
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
services.TryAddScoped<ITenantProvider, TenantProvider>();
services.AddOptions<TenantResolutionOptions>()
.Bind(configuration.GetSection("Tenancy"))
.ValidateDataAnnotations();
return services;
}
/// <summary>
/// 使用多租户解析中间件。
/// </summary>
public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app)
=> app.UseMiddleware<TenantResolutionMiddleware>();
}

View File

@@ -0,0 +1,34 @@
using System.Threading;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Module.Tenancy;
/// <summary>
/// 基于 <see cref="AsyncLocal{T}"/> 的租户上下文访问器,实现请求级别隔离。
/// </summary>
public sealed class TenantContextAccessor : ITenantContextAccessor
{
private static readonly AsyncLocal<TenantContextHolder?> Holder = new();
/// <inheritdoc />
public TenantContext? Current
{
get => Holder.Value?.Context;
set
{
if (Holder.Value != null)
{
Holder.Value.Context = value;
}
else if (value != null)
{
Holder.Value = new TenantContextHolder { Context = value };
}
}
}
private sealed class TenantContextHolder
{
public TenantContext? Context { get; set; }
}
}

View File

@@ -1,39 +1,24 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Module.Tenancy;
/// <summary>
/// 默认租户提供者:优先从Header: X-Tenant-Id其次从Token Claim: tenant_id
/// 默认租户提供者:基于租户上下文访问器暴露当前租户 ID。
/// </summary>
public sealed class TenantProvider : ITenantProvider
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ITenantContextAccessor _tenantContextAccessor;
public TenantProvider(IHttpContextAccessor httpContextAccessor)
/// <summary>
/// 初始化租户提供者。
/// </summary>
/// <param name="tenantContextAccessor">租户上下文访问器</param>
public TenantProvider(ITenantContextAccessor tenantContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_tenantContextAccessor = tenantContextAccessor;
}
/// <inheritdoc />
public Guid GetCurrentTenantId()
{
var httpContext = _httpContextAccessor.HttpContext;
if (httpContext == null) return Guid.Empty;
// 1. Header 优先
if (httpContext.Request.Headers.TryGetValue("X-Tenant-Id", out var values))
{
if (Guid.TryParse(values.FirstOrDefault(), out var headerTenant))
return headerTenant;
}
// 2. Token Claim
var claim = httpContext.User?.FindFirst("tenant_id");
if (claim != null && Guid.TryParse(claim.Value, out var claimTenant))
return claimTenant;
return Guid.Empty; // 未识别到则返回空(上层可按需处理)
}
=> _tenantContextAccessor.Current?.TenantId ?? Guid.Empty;
}

View File

@@ -0,0 +1,191 @@
using System.Linq;
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>
public sealed class TenantResolutionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<TenantResolutionMiddleware> _logger;
private readonly ITenantContextAccessor _tenantContextAccessor;
private readonly IOptionsMonitor<TenantResolutionOptions> _optionsMonitor;
/// <summary>
/// 初始化中间件。
/// </summary>
public TenantResolutionMiddleware(
RequestDelegate next,
ILogger<TenantResolutionMiddleware> logger,
ITenantContextAccessor tenantContextAccessor,
IOptionsMonitor<TenantResolutionOptions> optionsMonitor)
{
_next = next;
_logger = logger;
_tenantContextAccessor = tenantContextAccessor;
_optionsMonitor = 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 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) &&
Guid.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 && Guid.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 Guid tenantId)
{
tenantId = Guid.Empty;
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];
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.ObjectModel;
namespace TakeoutSaaS.Module.Tenancy;
/// <summary>
/// 多租户解析配置项。
/// </summary>
public sealed class TenantResolutionOptions
{
/// <summary>
/// 通过 Header 解析租户 ID 时使用的头名称,默认 X-Tenant-Id。
/// </summary>
public string TenantIdHeaderName { get; set; } = "X-Tenant-Id";
/// <summary>
/// 通过 Header 解析租户编码时使用的头名称,默认 X-Tenant-Code。
/// </summary>
public string TenantCodeHeaderName { get; set; } = "X-Tenant-Code";
/// <summary>
/// 明确指定 host 与租户 ID 对应关系的映射表(精确匹配)。
/// </summary>
public IDictionary<string, Guid> DomainTenantMap { get; set; }
= new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 租户编码到租户 ID 的映射表,用于 header 或子域名解析。
/// </summary>
public IDictionary<string, Guid> CodeTenantMap { get; set; }
= new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 根域(不含子域),用于形如 {tenant}.rootDomain 的场景,例如 admin.takeoutsaas.com。
/// </summary>
public string? RootDomain { get; set; }
/// <summary>
/// 需要跳过租户解析的路径集合(如健康检查),默认仅包含 /health。
/// </summary>
public ISet<string> IgnoredPaths { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "/health" };
/// <summary>
/// 若为 true当无法解析租户时立即返回 400否则交由上层自行判定。
/// </summary>
public bool ThrowIfUnresolved { get; set; }
/// <summary>
/// 对外只读视图,便于审计日志输出。
/// </summary>
public IReadOnlyDictionary<string, Guid> DomainMappings => new ReadOnlyDictionary<string, Guid>(DomainTenantMap);
/// <summary>
/// 对外只读的编码映射。
/// </summary>
public IReadOnlyDictionary<string, Guid> CodeMappings => new ReadOnlyDictionary<string, Guid>(CodeTenantMap);
}