feat: initialize mini api skeleton

This commit is contained in:
2026-03-09 13:13:41 +08:00
commit c6465480a9
38 changed files with 1038 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
<Project>
<ItemGroup>
<Using Include="Asp.Versioning" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,30 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.App.Mini.Bootstrap;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.MiniApi.Controllers;
/// <summary>
/// 小程序端启动引导接口。
/// </summary>
[ApiVersion("1.0")]
[Route("api/mini/v{version:apiVersion}/bootstrap")]
public sealed class BootstrapController(ISender sender) : BaseApiController
{
/// <summary>
/// 返回当前服务基础能力与运行环境信息。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>引导信息。</returns>
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<MiniBootstrapResponse>), StatusCodes.Status200OK)]
public async Task<ApiResponse<MiniBootstrapResponse>> GetAsync(CancellationToken cancellationToken)
{
var response = await sender.Send(new GetMiniBootstrapQuery(), cancellationToken);
return ApiResponse<MiniBootstrapResponse>.Ok(response);
}
}

View File

@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Web.Api;
namespace TakeoutSaaS.MiniApi.Controllers;
/// <summary>
/// 小程序端健康检查。
/// </summary>
[ApiVersion("1.0")]
[Route("api/mini/v{version:apiVersion}/health")]
public sealed class HealthController : BaseApiController
{
/// <summary>
/// 获取服务健康状态。
/// </summary>
/// <returns>健康状态。</returns>
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public ApiResponse<object> Get()
{
var payload = new
{
status = "OK",
service = "TakeoutSaaS.MiniApi",
time = DateTime.UtcNow
};
return ApiResponse<object>.Ok(payload);
}
}

View File

@@ -0,0 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY publish/ .
EXPOSE 7803
ENV ASPNETCORE_URLS=http://+:7803
ENTRYPOINT ["dotnet", "TakeoutSaaS.MiniApi.dll"]

View File

@@ -0,0 +1,148 @@
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Options;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Serilog;
using TakeoutSaaS.Application.App.Extensions;
using TakeoutSaaS.Infrastructure.App.Extensions;
using TakeoutSaaS.Module.Tenancy.Extensions;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Web.Extensions;
using TakeoutSaaS.Shared.Web.Swagger;
using TakeoutSaaS.Shared.Kernel.Ids;
var builder = WebApplication.CreateBuilder(args);
const string logTemplate = "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] [TraceId:{TraceId}] [SpanId:{SpanId}] [Service:{Service}] {SourceContext} {Message:lj}{NewLine}{Exception}";
var isDevelopment = builder.Environment.IsDevelopment();
builder.Host.UseSerilog((_, _, configuration) =>
{
configuration
.Enrich.FromLogContext()
.Enrich.WithProperty("Service", "TakeoutSaaS.MiniApi")
.WriteTo.Console(outputTemplate: logTemplate)
.WriteTo.File(
"logs/mini-api-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true,
outputTemplate: logTemplate);
});
builder.Services.AddSharedWebCore();
if (isDevelopment)
{
builder.Services.AddSharedSwagger(options =>
{
options.Title = "外卖SaaS - 小程序端";
options.Description = "小程序 API 文档";
options.EnableAuthorization = false;
});
}
builder.Services.AddAppInfrastructure(builder.Configuration);
builder.Services.AddAppApplication();
builder.Services.AddTenantResolution(builder.Configuration);
builder.Services.AddHealthChecks();
builder.Services.AddOptions<IdGeneratorOptions>()
.Bind(builder.Configuration.GetSection(IdGeneratorOptions.SectionName))
.ValidateDataAnnotations();
builder.Services.AddSingleton<IIdGenerator>(provider =>
{
var options = provider.GetRequiredService<IOptions<IdGeneratorOptions>>().Value;
return new SnowflakeIdGenerator(options.WorkerId, options.DatacenterId);
});
var otelSection = builder.Configuration.GetSection("Otel");
var otelEndpoint = otelSection.GetValue<string>("Endpoint");
var useConsoleExporter = otelSection.GetValue<bool?>("UseConsoleExporter") ?? isDevelopment;
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(
serviceName: "TakeoutSaaS.MiniApi",
serviceVersion: "1.0.0",
serviceInstanceId: Environment.MachineName))
.WithTracing(tracing =>
{
tracing
.SetSampler(new ParentBasedSampler(new AlwaysOnSampler()))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
if (!string.IsNullOrWhiteSpace(otelEndpoint))
{
tracing.AddOtlpExporter(exporter => exporter.Endpoint = new Uri(otelEndpoint));
}
if (useConsoleExporter)
{
tracing.AddConsoleExporter();
}
})
.WithMetrics(metrics =>
{
metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter();
if (!string.IsNullOrWhiteSpace(otelEndpoint))
{
metrics.AddOtlpExporter(exporter => exporter.Endpoint = new Uri(otelEndpoint));
}
if (useConsoleExporter)
{
metrics.AddConsoleExporter();
}
});
var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini");
builder.Services.AddCors(options =>
{
options.AddPolicy("MiniApiCors", policy =>
{
ConfigureCorsPolicy(policy, miniOrigins);
});
});
var app = builder.Build();
app.UseCors("MiniApiCors");
app.UseTenantResolution();
app.UseSharedWebCore();
if (app.Environment.IsDevelopment())
{
app.UseSharedSwagger();
}
app.MapHealthChecks("/healthz");
app.MapPrometheusScrapingEndpoint();
app.MapControllers();
app.Run();
static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey)
{
var origins = configuration.GetSection(sectionKey).Get<string[]>();
return origins?
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? [];
}
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
{
if (origins.Length == 0)
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(origins);
}
policy
.AllowAnyHeader()
.AllowAnyMethod();
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"TakeoutSaaS.MiniApi": {
"commandName": "Project",
"launchBrowser": true,
"launchUrl": "api/docs",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:2683"
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>../../..</DockerfileContext>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.14.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.14.0-beta.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Kernel\TakeoutSaaS.Shared.Kernel.csproj" />
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Web\TakeoutSaaS.Shared.Web.csproj" />
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
<ProjectReference Include="..\..\Infrastructure\TakeoutSaaS.Infrastructure\TakeoutSaaS.Infrastructure.csproj" />
<ProjectReference Include="..\..\Modules\TakeoutSaaS.Module.Tenancy\TakeoutSaaS.Module.Tenancy.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,9 @@
{
"Cors": {
"Mini": []
},
"Otel": {
"Endpoint": "",
"UseConsoleExporter": true
}
}

View File

@@ -0,0 +1,9 @@
{
"Cors": {
"Mini": []
},
"Otel": {
"Endpoint": "",
"UseConsoleExporter": false
}
}

View File

@@ -0,0 +1,23 @@
{
"AllowedHosts": "*",
"IdGenerator": {
"WorkerId": 0,
"DatacenterId": 0
},
"Tenancy": {
"TenantIdHeaderName": "X-Tenant-Id",
"TenantCodeHeaderName": "X-Tenant-Code",
"IgnoredPaths": [
"/healthz",
"/api/mini/v1/health"
],
"ThrowIfUnresolved": false
},
"Cors": {
"Mini": []
},
"Otel": {
"Endpoint": "",
"UseConsoleExporter": false
}
}

View File

@@ -0,0 +1,44 @@
using FluentValidation;
using MediatR;
using SharedValidationException = TakeoutSaaS.Shared.Abstractions.Exceptions.ValidationException;
namespace TakeoutSaaS.Application.App.Common.Behaviors;
/// <summary>
/// MediatR 校验行为。
/// </summary>
/// <typeparam name="TRequest">请求类型。</typeparam>
/// <typeparam name="TResponse">响应类型。</typeparam>
public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
/// <inheritdoc />
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!validators.Any())
{
return await next();
}
var context = new ValidationContext<TRequest>(request);
var validationResults = await Task.WhenAll(validators.Select(validator => validator.ValidateAsync(context, cancellationToken)));
var failures = validationResults
.SelectMany(result => result.Errors)
.Where(error => error is not null)
.GroupBy(error => error.PropertyName)
.ToDictionary(
group => group.Key,
group => group.Select(error => error.ErrorMessage).Distinct().ToArray());
if (failures.Count > 0)
{
throw new SharedValidationException(failures);
}
return await next();
}
}

View File

@@ -0,0 +1,26 @@
using FluentValidation;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
using TakeoutSaaS.Application.App.Common.Behaviors;
namespace TakeoutSaaS.Application.App.Extensions;
/// <summary>
/// 业务应用层服务注册。
/// </summary>
public static class AppApplicationServiceCollectionExtensions
{
/// <summary>
/// 注册应用层处理器与验证管道。
/// </summary>
/// <param name="services">服务集合。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddAppApplication(this IServiceCollection services)
{
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()));
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
return services;
}
}

View File

@@ -0,0 +1,8 @@
using MediatR;
namespace TakeoutSaaS.Application.App.Mini.Bootstrap;
/// <summary>
/// 获取小程序端启动引导信息。
/// </summary>
public sealed record GetMiniBootstrapQuery : IRequest<MiniBootstrapResponse>;

View File

@@ -0,0 +1,33 @@
using MediatR;
using Microsoft.Extensions.Hosting;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.App.Mini.Bootstrap;
/// <summary>
/// 小程序端启动引导查询处理器。
/// </summary>
public sealed class GetMiniBootstrapQueryHandler(
IHostEnvironment environment,
ITenantContextAccessor tenantContextAccessor)
: IRequestHandler<GetMiniBootstrapQuery, MiniBootstrapResponse>
{
private static readonly string[] Scenes = ["Delivery", "Pickup", "DineIn"];
private static readonly string[] Channels = ["WeChatMiniProgram"];
/// <inheritdoc />
public Task<MiniBootstrapResponse> Handle(GetMiniBootstrapQuery request, CancellationToken cancellationToken)
{
var response = new MiniBootstrapResponse
{
Service = "TakeoutSaaS.MiniApi",
Environment = environment.EnvironmentName,
ServerTime = DateTime.UtcNow,
SupportedScenes = Scenes,
SupportedChannels = Channels,
TenantCode = tenantContextAccessor.Current?.TenantCode
};
return Task.FromResult(response);
}
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Application.App.Mini.Bootstrap;
/// <summary>
/// 小程序端启动引导响应。
/// </summary>
public sealed record MiniBootstrapResponse
{
/// <summary>
/// 服务标识。
/// </summary>
public string Service { get; init; } = string.Empty;
/// <summary>
/// 当前环境名称。
/// </summary>
public string Environment { get; init; } = string.Empty;
/// <summary>
/// 服务端时间。
/// </summary>
public DateTime ServerTime { get; init; }
/// <summary>
/// 支持的履约场景。
/// </summary>
public IReadOnlyList<string> SupportedScenes { get; init; } = [];
/// <summary>
/// 支持的渠道。
/// </summary>
public IReadOnlyList<string> SupportedChannels { get; init; } = [];
/// <summary>
/// 当前请求解析到的租户编码。
/// </summary>
public string? TenantCode { get; init; }
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="14.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,22 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace TakeoutSaaS.Infrastructure.App.Extensions;
/// <summary>
/// 基础设施服务注册。
/// </summary>
public static class AppServiceCollectionExtensions
{
/// <summary>
/// 注册骨架所需的基础设施能力。
/// </summary>
/// <param name="services">服务集合。</param>
/// <param name="configuration">配置源。</param>
/// <returns>服务集合。</returns>
public static IServiceCollection AddAppInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
_ = configuration;
return services;
}
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Application\TakeoutSaaS.Application\TakeoutSaaS.Application.csproj" />
<ProjectReference Include="..\..\Domain\TakeoutSaaS.Domain\TakeoutSaaS.Domain.csproj" />
<ProjectReference Include="..\..\..\TakeoutSaaS.BuildingBlocks\src\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View 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;
}

View File

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

View File

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