feat(shared-web): add shared swagger and tracing utilities

This commit is contained in:
2025-11-22 21:20:49 +08:00
parent b9d4a06f61
commit b587e8c1e1
22 changed files with 1058 additions and 0 deletions

62
0_Document/10_TODO.md Normal file
View File

@@ -0,0 +1,62 @@
# TODO Roadmap
说明本清单覆盖当前阶段的骨架搭建与核心基础能力不含部署与CI/CD留到项目跑通后再做
## A. 基础骨架与规范
- [x] 统一返回结果/异常处理中间件Shared.Web
- [x] 模型验证、验证失败统一输出Shared.Web
- [x] 统一日志Serilog与请求日志/TraceIdShared.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 模块RabbitMQPublisher/Subscriber 抽象
- [ ] 业务事件定义(订单创建/支付成功等)与事件发布入口
## G. 调度与定时任务
- [ ] 调度模块Quartz/Hangfire 二选一,默认 Hangfire
- [ ] 基础任务:订单超时取消、优惠券过期处理、日志清理
- [ ] 调度面板(后续 AdminUI 对接)
## H. 第三方配送对接(仅第三方)
- [ ] 配送适配抽象(达达/闪送/顺丰同城等)
- [ ] 统一下单/取消/查询接口与回调验签
- [ ] AdminApi 后台运力单查询与补单
## I. 网关与横切能力
- [ ] YARP 路由拆分(/api/admin、/api/mini、/api/user
- [ ] 网关级限流与请求日志
- [ ] 透传鉴权/租户标识与统一错误页
## J. 测试与质量
- [ ] 单元测试工程骨架xUnit + FluentAssertions
- [ ] 集成测试基座WebApplicationFactory、测试容器
- [ ] 静态分析与风格规范(.editorconfig
## K. 文档与规范落地
- [ ] 在文档中补充:仅第三方配送的接口与回调规范
- [ ] MiniApi 认证流程图(微信登录)与错误码
- [ ] 模块间调用关系图与依赖边界

View File

@@ -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;
/// <summary>
/// 管理后台 - 健康检查。
/// </summary>
[ApiVersion("1.0")]
[Route("api/admin/v{version:apiVersion}/[controller]")]
public class HealthController : BaseApiController
{
/// <summary>
/// 获取服务健康状态。
/// </summary>
/// <returns>健康状态</returns>
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public IActionResult Get()
{
var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow };
return Ok(ApiResponse<object>.Ok(payload));
}
}

View File

@@ -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<ITenantProvider, TenantProvider>();
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<string[]>();
return origins?
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>();
}
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
{
if (origins.Length == 0)
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(origins)
.AllowCredentials();
}
policy
.AllowAnyHeader()
.AllowAnyMethod();
}

View File

@@ -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;
/// <summary>
/// 小程序端 - 健康检查。
/// </summary>
[ApiVersion("1.0")]
[AllowAnonymous]
[Route("api/mini/v{version:apiVersion}/[controller]")]
public class HealthController : BaseApiController
{
/// <summary>
/// 获取服务健康状态。
/// </summary>
/// <returns>健康状态</returns>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public IActionResult Get()
{
var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow };
return Ok(ApiResponse<object>.Ok(payload));
}
}

View File

@@ -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<ITenantProvider, TenantProvider>();
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<string[]>();
return origins?
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>();
}
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
{
if (origins.Length == 0)
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(origins)
.AllowCredentials();
}
policy
.AllowAnyHeader()
.AllowAnyMethod();
}

View File

@@ -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;
/// <summary>
/// 用户端 - 健康检查。
/// </summary>
[ApiVersion("1.0")]
[AllowAnonymous]
[Route("api/user/v{version:apiVersion}/[controller]")]
public class HealthController : BaseApiController
{
/// <summary>
/// 获取服务健康状态。
/// </summary>
/// <returns>健康状态</returns>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public IActionResult Get()
{
var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow };
return Ok(ApiResponse<object>.Ok(payload));
}
}

View File

@@ -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<ITenantProvider, TenantProvider>();
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<string[]>();
return origins?
.Where(origin => !string.IsNullOrWhiteSpace(origin))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? Array.Empty<string>();
}
static void ConfigureCorsPolicy(CorsPolicyBuilder policy, string[] origins)
{
if (origins.Length == 0)
{
policy.AllowAnyOrigin();
}
else
{
policy.WithOrigins(origins)
.AllowCredentials();
}
policy
.AllowAnyHeader()
.AllowAnyMethod();
}

View File

@@ -0,0 +1,25 @@
using System.Threading;
namespace TakeoutSaaS.Shared.Abstractions.Diagnostics;
/// <summary>
/// 轻量级 TraceId 上下文,便于跨层访问当前请求的追踪标识。
/// </summary>
public static class TraceContext
{
private static readonly AsyncLocal<string?> TraceIdHolder = new();
/// <summary>
/// 当前请求的 TraceId。
/// </summary>
public static string? TraceId
{
get => TraceIdHolder.Value;
set => TraceIdHolder.Value = value;
}
/// <summary>
/// 清理 TraceId避免 AsyncLocal 污染其它请求。
/// </summary>
public static void Clear() => TraceIdHolder.Value = null;
}

View File

@@ -0,0 +1,31 @@
namespace TakeoutSaaS.Shared.Abstractions.Results;
/// <summary>
/// 非泛型便捷封装。
/// </summary>
public static class ApiResponse
{
/// <summary>
/// 仅返回成功消息(无数据)。
/// </summary>
public static ApiResponse<object> Success(string? message = "操作成功")
=> ApiResponse<object>.Ok(message: message);
/// <summary>
/// 成功且携带数据。
/// </summary>
public static ApiResponse<object> Ok(object? data, string? message = "操作成功")
=> data is null ? ApiResponse<object>.Ok(message: message) : ApiResponse<object>.Ok(data, message);
/// <summary>
/// 错误返回。
/// </summary>
public static ApiResponse<object> Failure(int code, string message)
=> ApiResponse<object>.Error(code, message);
/// <summary>
/// 错误返回(附带详情)。
/// </summary>
public static ApiResponse<object> Error(int code, string message, object? errors = null)
=> ApiResponse<object>.Error(code, message, errors);
}

View File

@@ -0,0 +1,105 @@
using System;
using System.Diagnostics;
using TakeoutSaaS.Shared.Abstractions.Diagnostics;
namespace TakeoutSaaS.Shared.Abstractions.Results;
/// <summary>
/// 统一的 API 返回结果包装。
/// </summary>
/// <typeparam name="T">数据载荷类型</typeparam>
public sealed record class ApiResponse<T>
{
/// <summary>
/// 是否成功。
/// </summary>
public bool Success { get; init; }
/// <summary>
/// 状态/错误码(默认 200
/// </summary>
public int Code { get; init; } = 200;
/// <summary>
/// 提示信息。
/// </summary>
public string? Message { get; init; }
/// <summary>
/// 业务数据。
/// </summary>
public T? Data { get; init; }
/// <summary>
/// 错误详情(如字段验证错误)。
/// </summary>
public object? Errors { get; init; }
/// <summary>
/// TraceId便于链路追踪。
/// </summary>
public string TraceId { get; init; } = string.Empty;
/// <summary>
/// 时间戳UTC
/// </summary>
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
/// <summary>
/// 成功返回。
/// </summary>
public static ApiResponse<T> Ok(T data, string? message = "操作成功")
=> Create(true, 200, message, data);
/// <summary>
/// 无数据的成功返回。
/// </summary>
public static ApiResponse<T> Ok(string? message = "操作成功")
=> Create(true, 200, message, default);
/// <summary>
/// 兼容旧名称:成功结果。
/// </summary>
public static ApiResponse<T> SuccessResult(T data, string? message = "操作成功")
=> Ok(data, message);
/// <summary>
/// 错误返回。
/// </summary>
public static ApiResponse<T> Error(int code, string message, object? errors = null)
=> Create(false, code, message, default, errors);
/// <summary>
/// 兼容旧名称:失败结果。
/// </summary>
public static ApiResponse<T> Failure(int code, string message)
=> Error(code, message);
/// <summary>
/// 附加错误详情。
/// </summary>
public ApiResponse<T> WithErrors(object? errors)
=> this with { Errors = errors };
private static ApiResponse<T> 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");
}
}

View File

@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Builder;
using TakeoutSaaS.Shared.Web.Middleware;
namespace TakeoutSaaS.Shared.Web.Extensions;
/// <summary>
/// Web 应用中间件扩展。
/// </summary>
public static class ApplicationBuilderExtensions
{
/// <summary>
/// 按规范启用 TraceId、请求日志、异常映射与安全响应头。
/// </summary>
public static IApplicationBuilder UseSharedWebCore(this IApplicationBuilder app)
{
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseMiddleware<SecurityHeadersMiddleware>();
return app;
}
}

View File

@@ -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;
/// <summary>
/// Shared.Web 服务注册扩展。
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册控制器、模型验证、API 版本化等基础能力。
/// </summary>
public static IServiceCollection AddSharedWebCore(this IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddEndpointsApiExplorer();
services
.AddControllers(options =>
{
options.Filters.Add<ValidateModelAttribute>();
})
.AddNewtonsoftJson();
services.Configure<ApiBehaviorOptions>(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;
}
}

View File

@@ -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;
/// <summary>
/// 模型验证过滤器将模型验证错误统一为ApiResponse输出
/// </summary>
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<object>.Error(ErrorCodes.ValidationFailed, "一个或多个验证错误", errors);
context.Result = new UnprocessableEntityObjectResult(response);
}
}
}

View File

@@ -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;
/// <summary>
/// 统一 TraceId/CorrelationId贯穿日志与响应。
/// </summary>
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<CorrelationIdMiddleware> _logger;
public CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> 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<string, object>
{
["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;
}
}

View File

@@ -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;
/// <summary>
/// 全局异常处理中间件,将异常统一映射为 ApiResponse。
/// </summary>
public sealed class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
private readonly IHostEnvironment _environment;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> 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<object> Response) BuildErrorResponse(Exception exception)
{
return exception switch
{
ValidationException validationException => (
StatusCodes.Status422UnprocessableEntity,
ApiResponse<object>.Error(ErrorCodes.ValidationFailed, "请求参数验证失败", validationException.Errors)),
BusinessException businessException => (
StatusCodes.Status422UnprocessableEntity,
ApiResponse<object>.Error(businessException.ErrorCode, businessException.Message)),
_ => (
StatusCodes.Status500InternalServerError,
ApiResponse<object>.Error(ErrorCodes.InternalServerError, "服务器开小差啦,请稍后再试"))
};
}
}

View File

@@ -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;
/// <summary>
/// 基础请求日志方法、路径、耗时、状态码、TraceId
/// </summary>
public sealed class RequestLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestLoggingMiddleware> _logger;
public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> 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);
}
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Shared.Web.Middleware;
/// <summary>
/// 安全响应头中间件
/// </summary>
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);
}
}

View File

@@ -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;
/// <summary>
/// 根据 API 版本动态注册 Swagger 文档。
/// </summary>
internal sealed class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider _provider;
private readonly SwaggerDocumentSettings _settings;
public ConfigureSwaggerOptions(
IApiVersionDescriptionProvider provider,
IOptions<SwaggerDocumentSettings> 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<string>() }
});
}
}
}

View File

@@ -0,0 +1,22 @@
namespace TakeoutSaaS.Shared.Web.Swagger;
/// <summary>
/// Swagger 文档配置。
/// </summary>
public class SwaggerDocumentSettings
{
/// <summary>
/// 文档标题。
/// </summary>
public string Title { get; set; } = "TakeoutSaaS API";
/// <summary>
/// 描述信息。
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 是否启用 JWT Authorize 按钮。
/// </summary>
public bool EnableAuthorization { get; set; } = true;
}

View File

@@ -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;
/// <summary>
/// Swagger 注册/启用扩展。
/// </summary>
public static class SwaggerExtensions
{
/// <summary>
/// 注入统一的 Swagger 服务。
/// </summary>
public static IServiceCollection AddSharedSwagger(this IServiceCollection services, Action<SwaggerDocumentSettings>? configure = null)
{
services.AddSwaggerGen();
services.AddSingleton(provider =>
{
var settings = new SwaggerDocumentSettings();
configure?.Invoke(settings);
return settings;
});
services.AddSingleton<IConfigureOptions<SwaggerGenOptions>>(provider =>
new ConfigureSwaggerOptions(
provider.GetRequiredService<IApiVersionDescriptionProvider>(),
Options.Create(provider.GetRequiredService<SwaggerDocumentSettings>())));
return services;
}
/// <summary>
/// 开发环境启用 Swagger UI自动注册所有版本
/// </summary>
public static IApplicationBuilder UseSharedSwagger(this IApplicationBuilder app)
{
var env = app.ApplicationServices.GetRequiredService<IHostEnvironment>();
if (!env.IsDevelopment())
{
return app;
}
var provider = app.ApplicationServices.GetRequiredService<IApiVersionDescriptionProvider>();
var settings = app.ApplicationServices.GetRequiredService<SwaggerDocumentSettings>();
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;
}
}

View File

@@ -0,0 +1,23 @@
<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>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="6.6.2" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>