feat(shared-web): add shared swagger and tracing utilities
This commit is contained in:
28
src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs
Normal file
28
src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
76
src/Api/TakeoutSaaS.AdminApi/Program.cs
Normal file
76
src/Api/TakeoutSaaS.AdminApi/Program.cs
Normal 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();
|
||||
}
|
||||
28
src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs
Normal file
28
src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
76
src/Api/TakeoutSaaS.MiniApi/Program.cs
Normal file
76
src/Api/TakeoutSaaS.MiniApi/Program.cs
Normal 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();
|
||||
}
|
||||
28
src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs
Normal file
28
src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
76
src/Api/TakeoutSaaS.UserApi/Program.cs
Normal file
76
src/Api/TakeoutSaaS.UserApi/Program.cs
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
105
src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs
Normal file
105
src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, "服务器开小差啦,请稍后再试"))
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>() }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
65
src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs
Normal file
65
src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user