From b587e8c1e11f6de26443faa95430afcac3569de5 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Sat, 22 Nov 2025 21:20:49 +0800 Subject: [PATCH] feat(shared-web): add shared swagger and tracing utilities --- 0_Document/10_TODO.md | 62 +++++++++++ .../Controllers/HealthController.cs | 28 +++++ src/Api/TakeoutSaaS.AdminApi/Program.cs | 76 +++++++++++++ .../Controllers/HealthController.cs | 28 +++++ src/Api/TakeoutSaaS.MiniApi/Program.cs | 76 +++++++++++++ .../Controllers/HealthController.cs | 28 +++++ src/Api/TakeoutSaaS.UserApi/Program.cs | 76 +++++++++++++ .../Diagnostics/TraceContext.cs | 25 +++++ .../Results/ApiResponse.NonGeneric.cs | 31 ++++++ .../Results/ApiResponse.cs | 105 ++++++++++++++++++ .../ApplicationBuilderExtensions.cs | 22 ++++ .../Extensions/ServiceCollectionExtensions.cs | 48 ++++++++ .../Filters/ValidateModelAttribute.cs | 29 +++++ .../Middleware/CorrelationIdMiddleware.cs | 85 ++++++++++++++ .../Middleware/ExceptionHandlingMiddleware.cs | 86 ++++++++++++++ .../Middleware/RequestLoggingMiddleware.cs | 43 +++++++ .../Middleware/SecurityHeadersMiddleware.cs | 27 +++++ .../Swagger/ConfigureSwaggerOptions.cs | 60 ++++++++++ .../Swagger/SwaggerDocumentSettings.cs | 22 ++++ .../Swagger/SwaggerExtensions.cs | 65 +++++++++++ .../TakeoutSaaS.Shared.Web.csproj | 23 ++++ .../TakeoutSaaS.Module.Tenancy.csproj | 13 +++ 22 files changed, 1058 insertions(+) create mode 100644 0_Document/10_TODO.md create mode 100644 src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs create mode 100644 src/Api/TakeoutSaaS.AdminApi/Program.cs create mode 100644 src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs create mode 100644 src/Api/TakeoutSaaS.MiniApi/Program.cs create mode 100644 src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs create mode 100644 src/Api/TakeoutSaaS.UserApi/Program.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Extensions/ApplicationBuilderExtensions.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerDocumentSettings.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj create mode 100644 src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj diff --git a/0_Document/10_TODO.md b/0_Document/10_TODO.md new file mode 100644 index 0000000..7157dbf --- /dev/null +++ b/0_Document/10_TODO.md @@ -0,0 +1,62 @@ +# TODO Roadmap + +说明:本清单覆盖当前阶段的骨架搭建与核心基础能力(不含部署与CI/CD,留到项目跑通后再做)。 + +## A. 基础骨架与规范 +- [x] 统一返回结果/异常处理中间件(Shared.Web) +- [x] 模型验证、验证失败统一输出(Shared.Web) +- [x] 统一日志(Serilog)与请求日志/TraceId(Shared.Web) +- [x] API 版本化与分组(AdminApi、MiniApi、UserApi) +- [x] Swagger 定制(鉴权按钮、分组说明、示例) +- [x] 安全中间件:Security Headers、CORS 策略(按端区分) + +## B. 认证与权限 +- [ ] JWT 颁发与刷新(AdminApi、MiniApi) +- [ ] RBAC 权限模型(角色/权限/策略)与特性授权(AdminApi) +- [ ] 小程序登录(微信 code2Session)并绑定用户账户(MiniApi) +- [ ] 登录防刷限流(MiniApi) + +## C. 多租户与参数字典 +- [ ] 多租户中间件:从 Header/域名解析租户(Shared.Web + Tenancy) +- [ ] EF Core 全局查询过滤(tenant_id) +- [ ] 参数字典模块(系统参数/业务参数)CRUD 与缓存(Dictionary 模块) + +## D. 数据访问与多数据源 +- [ ] EF Core 10 基础上下文、实体基类、审计字段 +- [ ] 读写分离/多数据源配置(主写、从读;或按租户切库预留) +- [ ] Dapper 基础设施封装(统计/报表类查询) + +## E. 文件与存储 +- [ ] 存储模块抽象(本地/MinIO/云厂商适配) +- [ ] 上传接口(AdminApi、MiniApi)与签名直传预留 +- [ ] 图片/文件访问安全策略(防盗链、过期签名) + +## F. 短信与消息队列 +- [ ] 短信模块(阿里云/腾讯云 适配占位)与验证码发送 +- [ ] MQ 模块(RabbitMQ)Publisher/Subscriber 抽象 +- [ ] 业务事件定义(订单创建/支付成功等)与事件发布入口 + +## G. 调度与定时任务 +- [ ] 调度模块(Quartz/Hangfire 二选一,默认 Hangfire) +- [ ] 基础任务:订单超时取消、优惠券过期处理、日志清理 +- [ ] 调度面板(后续 AdminUI 对接) + +## H. 第三方配送对接(仅第三方) +- [ ] 配送适配抽象(达达/闪送/顺丰同城等) +- [ ] 统一下单/取消/查询接口与回调验签 +- [ ] AdminApi 后台运力单查询与补单 + +## I. 网关与横切能力 +- [ ] YARP 路由拆分(/api/admin、/api/mini、/api/user) +- [ ] 网关级限流与请求日志 +- [ ] 透传鉴权/租户标识与统一错误页 + +## J. 测试与质量 +- [ ] 单元测试工程骨架(xUnit + FluentAssertions) +- [ ] 集成测试基座(WebApplicationFactory、测试容器) +- [ ] 静态分析与风格规范(.editorconfig) + +## K. 文档与规范落地 +- [ ] 在文档中补充:仅第三方配送的接口与回调规范 +- [ ] MiniApi 认证流程图(微信登录)与错误码 +- [ ] 模块间调用关系图与依赖边界 diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs new file mode 100644 index 0000000..68ca72d --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 管理后台 - 健康检查。 +/// +[ApiVersion("1.0")] +[Route("api/admin/v{version:apiVersion}/[controller]")] +public class HealthController : BaseApiController +{ + /// + /// 获取服务健康状态。 + /// + /// 健康状态 + [HttpGet] + [AllowAnonymous] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public IActionResult Get() + { + var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow }; + return Ok(ApiResponse.Ok(payload)); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/Program.cs b/src/Api/TakeoutSaaS.AdminApi/Program.cs new file mode 100644 index 0000000..7e650e0 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Program.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using TakeoutSaaS.Module.Tenancy; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Web.Extensions; +using TakeoutSaaS.Shared.Web.Swagger; + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseSerilog((context, _, configuration) => +{ + configuration + .Enrich.FromLogContext() + .Enrich.WithProperty("Service", "AdminApi") + .WriteTo.Console(); +}); + +builder.Services.AddSharedWebCore(); +builder.Services.AddSharedSwagger(options => +{ + options.Title = "外卖SaaS - 管理后台"; + options.Description = "管理后台 API 文档"; + options.EnableAuthorization = true; +}); + +var adminOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Admin"); +builder.Services.AddCors(options => +{ + options.AddPolicy("AdminApiCors", policy => + { + ConfigureCorsPolicy(policy, adminOrigins); + }); +}); + +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.UseCors("AdminApiCors"); +app.UseSharedWebCore(); +app.UseSharedSwagger(); + +app.MapControllers(); +app.Run(); + +static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) +{ + var origins = configuration.GetSection(sectionKey).Get(); + return origins? + .Where(origin => !string.IsNullOrWhiteSpace(origin)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() ?? Array.Empty(); +} + +static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) +{ + if (origins.Length == 0) + { + policy.AllowAnyOrigin(); + } + else + { + policy.WithOrigins(origins) + .AllowCredentials(); + } + + policy + .AllowAnyHeader() + .AllowAnyMethod(); +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs new file mode 100644 index 0000000..e915adf --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.MiniApi.Controllers; + +/// +/// 小程序端 - 健康检查。 +/// +[ApiVersion("1.0")] +[AllowAnonymous] +[Route("api/mini/v{version:apiVersion}/[controller]")] +public class HealthController : BaseApiController +{ + /// + /// 获取服务健康状态。 + /// + /// 健康状态 + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public IActionResult Get() + { + var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow }; + return Ok(ApiResponse.Ok(payload)); + } +} diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs new file mode 100644 index 0000000..d6621d5 --- /dev/null +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using TakeoutSaaS.Module.Tenancy; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Web.Extensions; +using TakeoutSaaS.Shared.Web.Swagger; + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseSerilog((context, _, configuration) => +{ + configuration + .Enrich.FromLogContext() + .Enrich.WithProperty("Service", "MiniApi") + .WriteTo.Console(); +}); + +builder.Services.AddSharedWebCore(); +builder.Services.AddSharedSwagger(options => +{ + options.Title = "外卖SaaS - 小程序端"; + options.Description = "小程序 API 文档"; + options.EnableAuthorization = true; +}); + +var miniOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:Mini"); +builder.Services.AddCors(options => +{ + options.AddPolicy("MiniApiCors", policy => + { + ConfigureCorsPolicy(policy, miniOrigins); + }); +}); + +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.UseCors("MiniApiCors"); +app.UseSharedWebCore(); +app.UseSharedSwagger(); + +app.MapControllers(); +app.Run(); + +static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) +{ + var origins = configuration.GetSection(sectionKey).Get(); + return origins? + .Where(origin => !string.IsNullOrWhiteSpace(origin)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() ?? Array.Empty(); +} + +static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) +{ + if (origins.Length == 0) + { + policy.AllowAnyOrigin(); + } + else + { + policy.WithOrigins(origins) + .AllowCredentials(); + } + + policy + .AllowAnyHeader() + .AllowAnyMethod(); +} diff --git a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs new file mode 100644 index 0000000..1648b83 --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.UserApi.Controllers; + +/// +/// 用户端 - 健康检查。 +/// +[ApiVersion("1.0")] +[AllowAnonymous] +[Route("api/user/v{version:apiVersion}/[controller]")] +public class HealthController : BaseApiController +{ + /// + /// 获取服务健康状态。 + /// + /// 健康状态 + [HttpGet] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public IActionResult Get() + { + var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow }; + return Ok(ApiResponse.Ok(payload)); + } +} diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs new file mode 100644 index 0000000..fc30e4f --- /dev/null +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Serilog; +using TakeoutSaaS.Module.Tenancy; +using TakeoutSaaS.Shared.Abstractions.Tenancy; +using TakeoutSaaS.Shared.Web.Extensions; +using TakeoutSaaS.Shared.Web.Swagger; + +var builder = WebApplication.CreateBuilder(args); + +builder.Host.UseSerilog((context, _, configuration) => +{ + configuration + .Enrich.FromLogContext() + .Enrich.WithProperty("Service", "UserApi") + .WriteTo.Console(); +}); + +builder.Services.AddSharedWebCore(); +builder.Services.AddSharedSwagger(options => +{ + options.Title = "外卖SaaS - 用户端"; + options.Description = "C 端用户 API 文档"; + options.EnableAuthorization = true; +}); + +var userOrigins = ResolveCorsOrigins(builder.Configuration, "Cors:User"); +builder.Services.AddCors(options => +{ + options.AddPolicy("UserApiCors", policy => + { + ConfigureCorsPolicy(policy, userOrigins); + }); +}); + +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.UseCors("UserApiCors"); +app.UseSharedWebCore(); +app.UseSharedSwagger(); + +app.MapControllers(); +app.Run(); + +static string[] ResolveCorsOrigins(IConfiguration configuration, string sectionKey) +{ + var origins = configuration.GetSection(sectionKey).Get(); + return origins? + .Where(origin => !string.IsNullOrWhiteSpace(origin)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray() ?? Array.Empty(); +} + +static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins) +{ + if (origins.Length == 0) + { + policy.AllowAnyOrigin(); + } + else + { + policy.WithOrigins(origins) + .AllowCredentials(); + } + + policy + .AllowAnyHeader() + .AllowAnyMethod(); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs new file mode 100644 index 0000000..0715cf1 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs @@ -0,0 +1,25 @@ +using System.Threading; + +namespace TakeoutSaaS.Shared.Abstractions.Diagnostics; + +/// +/// 轻量级 TraceId 上下文,便于跨层访问当前请求的追踪标识。 +/// +public static class TraceContext +{ + private static readonly AsyncLocal TraceIdHolder = new(); + + /// + /// 当前请求的 TraceId。 + /// + public static string? TraceId + { + get => TraceIdHolder.Value; + set => TraceIdHolder.Value = value; + } + + /// + /// 清理 TraceId,避免 AsyncLocal 污染其它请求。 + /// + public static void Clear() => TraceIdHolder.Value = null; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs new file mode 100644 index 0000000..d3e8d6d --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs @@ -0,0 +1,31 @@ +namespace TakeoutSaaS.Shared.Abstractions.Results; + +/// +/// 非泛型便捷封装。 +/// +public static class ApiResponse +{ + /// + /// 仅返回成功消息(无数据)。 + /// + public static ApiResponse Success(string? message = "操作成功") + => ApiResponse.Ok(message: message); + + /// + /// 成功且携带数据。 + /// + public static ApiResponse Ok(object? data, string? message = "操作成功") + => data is null ? ApiResponse.Ok(message: message) : ApiResponse.Ok(data, message); + + /// + /// 错误返回。 + /// + public static ApiResponse Failure(int code, string message) + => ApiResponse.Error(code, message); + + /// + /// 错误返回(附带详情)。 + /// + public static ApiResponse Error(int code, string message, object? errors = null) + => ApiResponse.Error(code, message, errors); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs new file mode 100644 index 0000000..6669468 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs @@ -0,0 +1,105 @@ +using System; +using System.Diagnostics; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; + +namespace TakeoutSaaS.Shared.Abstractions.Results; + +/// +/// 统一的 API 返回结果包装。 +/// +/// 数据载荷类型 +public sealed record class ApiResponse +{ + /// + /// 是否成功。 + /// + public bool Success { get; init; } + + /// + /// 状态/错误码(默认 200)。 + /// + public int Code { get; init; } = 200; + + /// + /// 提示信息。 + /// + public string? Message { get; init; } + + /// + /// 业务数据。 + /// + public T? Data { get; init; } + + /// + /// 错误详情(如字段验证错误)。 + /// + public object? Errors { get; init; } + + /// + /// TraceId,便于链路追踪。 + /// + public string TraceId { get; init; } = string.Empty; + + /// + /// 时间戳(UTC)。 + /// + public DateTime Timestamp { get; init; } = DateTime.UtcNow; + + /// + /// 成功返回。 + /// + public static ApiResponse Ok(T data, string? message = "操作成功") + => Create(true, 200, message, data); + + /// + /// 无数据的成功返回。 + /// + public static ApiResponse Ok(string? message = "操作成功") + => Create(true, 200, message, default); + + /// + /// 兼容旧名称:成功结果。 + /// + public static ApiResponse SuccessResult(T data, string? message = "操作成功") + => Ok(data, message); + + /// + /// 错误返回。 + /// + public static ApiResponse Error(int code, string message, object? errors = null) + => Create(false, code, message, default, errors); + + /// + /// 兼容旧名称:失败结果。 + /// + public static ApiResponse Failure(int code, string message) + => Error(code, message); + + /// + /// 附加错误详情。 + /// + public ApiResponse WithErrors(object? errors) + => this with { Errors = errors }; + + private static ApiResponse Create(bool success, int code, string? message, T? data, object? errors = null) + => new() + { + Success = success, + Code = code, + Message = message, + Data = data, + Errors = errors, + TraceId = ResolveTraceId(), + Timestamp = DateTime.UtcNow + }; + + private static string ResolveTraceId() + { + if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) + { + return TraceContext.TraceId!; + } + + return Activity.Current?.Id ?? Guid.NewGuid().ToString("N"); + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ApplicationBuilderExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..2c25aaa --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Builder; +using TakeoutSaaS.Shared.Web.Middleware; + +namespace TakeoutSaaS.Shared.Web.Extensions; + +/// +/// Web 应用中间件扩展。 +/// +public static class ApplicationBuilderExtensions +{ + /// + /// 按规范启用 TraceId、请求日志、异常映射与安全响应头。 + /// + public static IApplicationBuilder UseSharedWebCore(this IApplicationBuilder app) + { + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + return app; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..781364d --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Shared.Web.Filters; + +namespace TakeoutSaaS.Shared.Web.Extensions; + +/// +/// Shared.Web 服务注册扩展。 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册控制器、模型验证、API 版本化等基础能力。 + /// + public static IServiceCollection AddSharedWebCore(this IServiceCollection services) + { + services.AddHttpContextAccessor(); + services.AddEndpointsApiExplorer(); + + services + .AddControllers(options => + { + options.Filters.Add(); + }) + .AddNewtonsoftJson(); + + services.Configure(options => + { + options.SuppressModelStateInvalidFilter = true; + }); + + services.AddApiVersioning(options => + { + options.AssumeDefaultVersionWhenUnspecified = true; + options.DefaultApiVersion = new ApiVersion(1, 0); + options.ReportApiVersions = true; + }); + + services.AddVersionedApiExplorer(setup => + { + setup.GroupNameFormat = "'v'VVV"; + setup.SubstituteApiVersionInUrl = true; + }); + + return services; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs new file mode 100644 index 0000000..4c802e0 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs @@ -0,0 +1,29 @@ +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Shared.Web.Filters; + +/// +/// 模型验证过滤器:将模型验证错误统一为ApiResponse输出 +/// +public sealed class ValidateModelAttribute : ActionFilterAttribute +{ + public override void OnActionExecuting(ActionExecutingContext context) + { + if (!context.ModelState.IsValid) + { + var errors = context.ModelState + .Where(kv => kv.Value?.Errors.Count > 0) + .ToDictionary( + kv => kv.Key, + kv => kv.Value!.Errors.Select(e => string.IsNullOrWhiteSpace(e.ErrorMessage) ? "Invalid" : e.ErrorMessage).ToArray() + ); + + var response = ApiResponse.Error(ErrorCodes.ValidationFailed, "一个或多个验证错误", errors); + context.Result = new UnprocessableEntityObjectResult(response); + } + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..b3cf763 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 统一 TraceId/CorrelationId,贯穿日志与响应。 +/// +public sealed class CorrelationIdMiddleware +{ + private const string TraceHeader = "X-Trace-Id"; + private const string RequestHeader = "X-Request-Id"; + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public CorrelationIdMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var traceId = ResolveTraceId(context); + context.TraceIdentifier = traceId; + TraceContext.TraceId = traceId; + + context.Response.OnStarting(() => + { + context.Response.Headers[TraceHeader] = traceId; + return Task.CompletedTask; + }); + + using (_logger.BeginScope(new Dictionary + { + ["TraceId"] = traceId + })) + { + try + { + await _next(context); + } + finally + { + TraceContext.Clear(); + } + } + } + + private static string ResolveTraceId(HttpContext context) + { + if (TryGetHeader(context, TraceHeader, out var traceId)) + { + return traceId; + } + + if (TryGetHeader(context, RequestHeader, out var requestId)) + { + return requestId; + } + + return Guid.NewGuid().ToString("N"); + } + + private static bool TryGetHeader(HttpContext context, string headerName, out string value) + { + if (context.Request.Headers.TryGetValue(headerName, out var values)) + { + var headerValue = values.ToString(); + if (!string.IsNullOrWhiteSpace(headerValue)) + { + value = headerValue; + return true; + } + } + + value = string.Empty; + return false; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs new file mode 100644 index 0000000..da85777 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,86 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Results; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 全局异常处理中间件,将异常统一映射为 ApiResponse。 +/// +public sealed class ExceptionHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IHostEnvironment _environment; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment environment) + { + _next = next; + _logger = logger; + _environment = environment; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "未处理异常:{Message}", ex.Message); + await HandleExceptionAsync(context, ex); + } + } + + private Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var (statusCode, response) = BuildErrorResponse(exception); + + if (_environment.IsDevelopment()) + { + response = response with + { + Message = exception.Message, + Errors = new + { + response.Errors, + detail = exception.ToString() + } + }; + } + + context.Response.StatusCode = statusCode; + context.Response.ContentType = "application/json"; + return context.Response.WriteAsJsonAsync(response, SerializerOptions); + } + + private static (int StatusCode, ApiResponse Response) BuildErrorResponse(Exception exception) + { + return exception switch + { + ValidationException validationException => ( + StatusCodes.Status422UnprocessableEntity, + ApiResponse.Error(ErrorCodes.ValidationFailed, "请求参数验证失败", validationException.Errors)), + BusinessException businessException => ( + StatusCodes.Status422UnprocessableEntity, + ApiResponse.Error(businessException.ErrorCode, businessException.Message)), + _ => ( + StatusCodes.Status500InternalServerError, + ApiResponse.Error(ErrorCodes.InternalServerError, "服务器开小差啦,请稍后再试")) + }; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs new file mode 100644 index 0000000..8121499 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs @@ -0,0 +1,43 @@ +using System.Diagnostics; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 基础请求日志(方法、路径、耗时、状态码、TraceId)。 +/// +public sealed class RequestLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public RequestLoggingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var stopwatch = Stopwatch.StartNew(); + try + { + await _next(context); + } + finally + { + stopwatch.Stop(); + var traceId = TraceContext.TraceId ?? context.TraceIdentifier; + _logger.LogInformation( + "HTTP {Method} {Path} => {StatusCode} ({Elapsed} ms) TraceId:{TraceId}", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + stopwatch.Elapsed.TotalMilliseconds, + traceId); + } + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs new file mode 100644 index 0000000..7bb1b0c --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Http; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 安全响应头中间件 +/// +public sealed class SecurityHeadersMiddleware +{ + private readonly RequestDelegate _next; + + public SecurityHeadersMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var headers = context.Response.Headers; + headers["X-Content-Type-Options"] = "nosniff"; + headers["X-Frame-Options"] = "DENY"; + headers["X-XSS-Protection"] = "1; mode=block"; + headers["Referrer-Policy"] = "no-referrer"; + await _next(context); + } +} + diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs new file mode 100644 index 0000000..e620d67 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs @@ -0,0 +1,60 @@ +using System; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace TakeoutSaaS.Shared.Web.Swagger; + +/// +/// 根据 API 版本动态注册 Swagger 文档。 +/// +internal sealed class ConfigureSwaggerOptions : IConfigureOptions +{ + private readonly IApiVersionDescriptionProvider _provider; + private readonly SwaggerDocumentSettings _settings; + + public ConfigureSwaggerOptions( + IApiVersionDescriptionProvider provider, + IOptions settings) + { + _provider = provider; + _settings = settings.Value; + } + + public void Configure(SwaggerGenOptions options) + { + foreach (var description in _provider.ApiVersionDescriptions) + { + var info = new OpenApiInfo + { + Title = $"{_settings.Title} {description.ApiVersion}", + Version = description.ApiVersion.ToString(), + Description = description.IsDeprecated + ? $"{_settings.Description}(该版本已弃用)" + : _settings.Description + }; + + options.SwaggerGeneratorOptions.SwaggerDocs[description.GroupName] = info; + } + + if (_settings.EnableAuthorization) + { + var scheme = new OpenApiSecurityScheme + { + Name = "Authorization", + Description = "在下方输入 Bearer Token,格式:Bearer {token}", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT" + }; + + options.SwaggerGeneratorOptions.SecuritySchemes["Bearer"] = scheme; + options.SwaggerGeneratorOptions.SecurityRequirements.Add(new OpenApiSecurityRequirement + { + { scheme, Array.Empty() } + }); + } + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerDocumentSettings.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerDocumentSettings.cs new file mode 100644 index 0000000..f0410e0 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerDocumentSettings.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Shared.Web.Swagger; + +/// +/// Swagger 文档配置。 +/// +public class SwaggerDocumentSettings +{ + /// + /// 文档标题。 + /// + public string Title { get; set; } = "TakeoutSaaS API"; + + /// + /// 描述信息。 + /// + public string? Description { get; set; } + + /// + /// 是否启用 JWT Authorize 按钮。 + /// + public bool EnableAuthorization { get; set; } = true; +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs new file mode 100644 index 0000000..c192adb --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs @@ -0,0 +1,65 @@ +using System; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace TakeoutSaaS.Shared.Web.Swagger; + +/// +/// Swagger 注册/启用扩展。 +/// +public static class SwaggerExtensions +{ + /// + /// 注入统一的 Swagger 服务。 + /// + public static IServiceCollection AddSharedSwagger(this IServiceCollection services, Action? configure = null) + { + services.AddSwaggerGen(); + services.AddSingleton(provider => + { + var settings = new SwaggerDocumentSettings(); + configure?.Invoke(settings); + return settings; + }); + services.AddSingleton>(provider => + new ConfigureSwaggerOptions( + provider.GetRequiredService(), + Options.Create(provider.GetRequiredService()))); + + return services; + } + + /// + /// 开发环境启用 Swagger UI(自动注册所有版本)。 + /// + public static IApplicationBuilder UseSharedSwagger(this IApplicationBuilder app) + { + var env = app.ApplicationServices.GetRequiredService(); + if (!env.IsDevelopment()) + { + return app; + } + + var provider = app.ApplicationServices.GetRequiredService(); + var settings = app.ApplicationServices.GetRequiredService(); + + app.UseSwagger(); + app.UseSwaggerUI(options => + { + foreach (var description in provider.ApiVersionDescriptions) + { + options.SwaggerEndpoint( + $"/swagger/{description.GroupName}/swagger.json", + $"{settings.Title} {description.ApiVersion}"); + } + + options.DisplayRequestDuration(); + }); + + return app; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj new file mode 100644 index 0000000..94b4aeb --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj b/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj new file mode 100644 index 0000000..2617cb0 --- /dev/null +++ b/src/Modules/TakeoutSaaS.Module.Tenancy/TakeoutSaaS.Module.Tenancy.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + + + + + + + +