feat: initialize mini api skeleton
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
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>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
public static IServiceCollection AddTenantResolution(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.TryAddSingleton<ITenantContextAccessor, TenantContextAccessor>();
|
||||
services.TryAddScoped<ITenantProvider, TenantProvider>();
|
||||
services.TryAddScoped<ITenantCodeResolver, NoOpTenantCodeResolver>();
|
||||
|
||||
services.AddOptions<TenantResolutionOptions>()
|
||||
.Bind(configuration.GetSection("Tenancy"))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启用租户解析中间件。
|
||||
/// </summary>
|
||||
/// <param name="app">应用实例。</param>
|
||||
/// <returns>应用实例。</returns>
|
||||
public static IApplicationBuilder UseTenantResolution(this IApplicationBuilder app)
|
||||
=> app.UseMiddleware<TenantResolutionMiddleware>();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TakeoutSaaS.Module.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 租户编码解析器。
|
||||
/// </summary>
|
||||
public interface ITenantCodeResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据租户编码解析租户 ID。
|
||||
/// </summary>
|
||||
/// <param name="code">租户编码。</param>
|
||||
/// <param name="cancellationToken">取消令牌。</param>
|
||||
/// <returns>租户 ID;未找到时返回 <c>null</c>。</returns>
|
||||
Task<long?> ResolveAsync(string code, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace TakeoutSaaS.Module.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 默认空实现,不做数据库或远程租户解析。
|
||||
/// </summary>
|
||||
public sealed class NoOpTenantCodeResolver : ITenantCodeResolver
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<long?> ResolveAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = code;
|
||||
_ = cancellationToken;
|
||||
return Task.FromResult<long?>(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,33 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
13
src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs
Normal file
13
src/Modules/TakeoutSaaS.Module.Tenancy/TenantProvider.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Module.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 默认租户提供者。
|
||||
/// </summary>
|
||||
public sealed class TenantProvider(ITenantContextAccessor tenantContextAccessor) : ITenantProvider
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public long GetCurrentTenantId()
|
||||
=> tenantContextAccessor.Current?.TenantId ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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>
|
||||
/// 轻量多租户解析中间件。
|
||||
/// </summary>
|
||||
public sealed class TenantResolutionMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<TenantResolutionMiddleware> logger,
|
||||
ITenantContextAccessor tenantContextAccessor,
|
||||
IOptionsMonitor<TenantResolutionOptions> optionsMonitor)
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行租户解析并写入上下文。
|
||||
/// </summary>
|
||||
/// <param name="context">HTTP 上下文。</param>
|
||||
/// <param name="tenantCodeResolver">租户编码解析器。</param>
|
||||
/// <returns>任务。</returns>
|
||||
public async Task InvokeAsync(HttpContext context, ITenantCodeResolver tenantCodeResolver)
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue ?? new TenantResolutionOptions();
|
||||
if (ShouldSkip(context.Request.Path, options))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantContext = await ResolveTenantAsync(context, options, tenantCodeResolver);
|
||||
tenantContextAccessor.Current = tenantContext;
|
||||
context.Items[TenantConstants.HttpContextItemKey] = tenantContext;
|
||||
|
||||
if (!tenantContext.IsResolved && options.ThrowIfUnresolved)
|
||||
{
|
||||
logger.LogDebug("未能解析租户:{Path}", context.Request.Path);
|
||||
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;
|
||||
}
|
||||
|
||||
return options.IgnoredPaths.Any(ignore =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ignore))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return path.StartsWithSegments(ignore, StringComparison.OrdinalIgnoreCase);
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<TenantContext> ResolveTenantAsync(
|
||||
HttpContext context,
|
||||
TenantResolutionOptions options,
|
||||
ITenantCodeResolver tenantCodeResolver)
|
||||
{
|
||||
var request = context.Request;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.TenantIdHeaderName) &&
|
||||
request.Headers.TryGetValue(options.TenantIdHeaderName, out var tenantIdHeader) &&
|
||||
long.TryParse(tenantIdHeader.FirstOrDefault(), out var tenantId) &&
|
||||
tenantId > 0)
|
||||
{
|
||||
return new TenantContext(tenantId, null, $"header:{options.TenantIdHeaderName}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.TenantCodeHeaderName) &&
|
||||
request.Headers.TryGetValue(options.TenantCodeHeaderName, out var tenantCodeHeader))
|
||||
{
|
||||
var tenantCode = tenantCodeHeader.FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(tenantCode))
|
||||
{
|
||||
var resolvedTenantId = await tenantCodeResolver.ResolveAsync(tenantCode, context.RequestAborted);
|
||||
return new TenantContext(resolvedTenantId ?? 0, tenantCode, $"header:{options.TenantCodeHeaderName}");
|
||||
}
|
||||
}
|
||||
|
||||
return TenantContext.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace TakeoutSaaS.Module.Tenancy;
|
||||
|
||||
/// <summary>
|
||||
/// 多租户解析配置。
|
||||
/// </summary>
|
||||
public sealed class TenantResolutionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Header 租户 ID 名称。
|
||||
/// </summary>
|
||||
public string TenantIdHeaderName { get; set; } = "X-Tenant-Id";
|
||||
|
||||
/// <summary>
|
||||
/// Header 租户编码名称。
|
||||
/// </summary>
|
||||
public string TenantCodeHeaderName { get; set; } = "X-Tenant-Code";
|
||||
|
||||
/// <summary>
|
||||
/// 跳过解析的路径列表。
|
||||
/// </summary>
|
||||
public ISet<string> IgnoredPaths { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/healthz",
|
||||
"/api/mini/v1/health"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 未解析租户时是否立即返回 400。
|
||||
/// </summary>
|
||||
public bool ThrowIfUnresolved { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 对外只读的忽略路径视图。
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> IgnoredPathItems => new ReadOnlyCollection<string>(IgnoredPaths.ToList());
|
||||
}
|
||||
Reference in New Issue
Block a user