From 4cf39b4d42ef822d5ff4ccf72ccb64bbd961b9ad Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Thu, 29 Jan 2026 01:57:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E5=A7=8B=E5=8C=96=20BuildingB?= =?UTF-8?q?locks=20=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 + .../Constants/DatabaseConstants.cs | 27 +++ .../Constants/ErrorCodes.cs | 47 ++++ .../Data/DatabaseConnectionRole.cs | 17 ++ .../Data/IDapperExecutor.cs | 48 +++++ .../Diagnostics/TraceContext.cs | 37 ++++ .../Entities/AuditableEntityBase.cs | 37 ++++ .../Entities/EntityBase.cs | 12 ++ .../Entities/IAuditableEntity.cs | 37 ++++ .../Entities/IMultiTenantEntity.cs | 12 ++ .../Entities/ISoftDeleteEntity.cs | 12 ++ .../Entities/MultiTenantEntityBase.cs | 12 ++ .../Exceptions/BusinessException.cs | 13 ++ .../Exceptions/ValidationException.cs | 13 ++ .../Ids/IIdGenerator.cs | 13 ++ .../Ids/IdGeneratorOptions.cs | 26 +++ .../Results/ApiResponse.NonGeneric.cs | 43 ++++ .../Results/ApiResponse.cs | 203 ++++++++++++++++++ .../Results/PagedResult.cs | 36 ++++ .../Security/ICurrentUserAccessor.cs | 17 ++ .../Serialization/SnowflakeIdJsonConverter.cs | 52 +++++ .../TakeoutSaaS.Shared.Abstractions.csproj | 13 ++ .../Tenancy/ITenantContextAccessor.cs | 12 ++ .../Tenancy/ITenantProvider.cs | 13 ++ .../Tenancy/TenantConstants.cs | 12 ++ .../Tenancy/TenantContext.cs | 38 ++++ .../Ids/SnowflakeIdGenerator.cs | 104 +++++++++ .../TakeoutSaaS.Shared.Kernel.csproj | 14 ++ .../Api/BaseApiController.cs | 15 ++ .../ApplicationBuilderExtensions.cs | 22 ++ .../Extensions/ServiceCollectionExtensions.cs | 53 +++++ .../Filters/ApiResponseResultFilter.cs | 115 ++++++++++ .../Filters/ValidateModelAttribute.cs | 33 +++ .../Middleware/CorrelationIdMiddleware.cs | 108 ++++++++++ .../Middleware/ExceptionHandlingMiddleware.cs | 139 ++++++++++++ .../Middleware/RequestLoggingMiddleware.cs | 41 ++++ .../Middleware/SecurityHeadersMiddleware.cs | 25 +++ .../Security/ClaimsPrincipalExtensions.cs | 27 +++ .../HttpContextCurrentUserAccessor.cs | 35 +++ .../Security/TenantHttpContextExtensions.cs | 31 +++ .../Swagger/ConfigureSwaggerOptions.cs | 60 ++++++ .../Swagger/SwaggerDocumentSettings.cs | 22 ++ .../Swagger/SwaggerExtensions.cs | 116 ++++++++++ .../TakeoutSaaS.Shared.Web.csproj | 30 +++ 44 files changed, 1799 insertions(+) create mode 100644 README.md create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IIdGenerator.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IdGeneratorOptions.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.Abstractions/Results/PagedResult.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Serialization/SnowflakeIdJsonConverter.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/TakeoutSaaS.Shared.Abstractions.csproj create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Kernel/TakeoutSaaS.Shared.Kernel.csproj create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Api/BaseApiController.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/ApiResponseResultFilter.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/Security/ClaimsPrincipalExtensions.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs create mode 100644 src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..65a7544 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# TakeoutSaaS.BuildingBlocks + +该仓库用于承载 TakeoutSaaS 的可复用基础组件(Building Blocks)。 + +## 约定 +- 该仓库通常以 git submodule 的形式被 `TakeoutSaaS.AdminApi` / `TakeoutSaaS.TenantApi` 引用。 +- 共享代码不应包含租户过滤等“租户侧专属”能力。 diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs new file mode 100644 index 0000000..4e9e458 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/DatabaseConstants.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Shared.Abstractions.Constants; + +/// +/// 数据源名称常量,统一配置键与使用说明。 +/// +public static class DatabaseConstants +{ + /// + /// 默认业务库(AppDatabase). + /// + public const string AppDataSource = "AppDatabase"; + + /// + /// 身份认证库(IdentityDatabase)。 + /// + public const string IdentityDataSource = "IdentityDatabase"; + + /// + /// 字典库(DictionaryDatabase)。 + /// + public const string DictionaryDataSource = "DictionaryDatabase"; + + /// + /// 日志库(LogsDatabase)。 + /// + public const string LogsDataSource = "LogsDatabase"; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs new file mode 100644 index 0000000..3214155 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Constants/ErrorCodes.cs @@ -0,0 +1,47 @@ +namespace TakeoutSaaS.Shared.Abstractions.Constants; + +/// +/// 统一错误码常量。 +/// +public static class ErrorCodes +{ + /// + /// 请求参数错误。 + /// + public const int BadRequest = 400; + + /// + /// 未授权访问。 + /// + public const int Unauthorized = 401; + + /// + /// 权限不足。 + /// + public const int Forbidden = 403; + + /// + /// 资源未找到。 + /// + public const int NotFound = 404; + + /// + /// 资源冲突。 + /// + public const int Conflict = 409; + + /// + /// 校验失败。 + /// + public const int ValidationFailed = 422; + + /// + /// 服务器内部错误。 + /// + public const int InternalServerError = 500; + + /// + /// 业务自定义错误(10000+)。 + /// + public const int BusinessError = 10001; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs new file mode 100644 index 0000000..175f9f6 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/DatabaseConnectionRole.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Shared.Abstractions.Data; + +/// +/// 数据库连接角色,用于区分主写与从读连接。 +/// +public enum DatabaseConnectionRole +{ + /// + /// 主写连接,用于写入或强一致读。 + /// + Write = 1, + + /// + /// 从读连接,用于只读查询或报表。 + /// + Read = 2 +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs new file mode 100644 index 0000000..0423468 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Data/IDapperExecutor.cs @@ -0,0 +1,48 @@ +using System.Data; + +namespace TakeoutSaaS.Shared.Abstractions.Data; + +/// +/// Dapper 查询/命令执行器抽象,封装连接获取与读写路由。 +/// +public interface IDapperExecutor +{ + /// + /// 使用指定数据源与读写角色执行异步查询,并返回结果。 + /// + /// 查询结果类型。 + /// 逻辑数据源名称。 + /// 连接角色(读/写)。 + /// 查询委托,提供已打开的连接和取消标记。 + /// 取消标记。 + /// 查询结果。 + Task QueryAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func> query, + CancellationToken cancellationToken = default); + + /// + /// 使用指定数据源与读写角色执行异步命令。 + /// + /// 逻辑数据源名称。 + /// 连接角色(读/写)。 + /// 命令委托,提供已打开的连接和取消标记。 + /// 取消标记。 + /// 异步执行任务。 + Task ExecuteAsync( + string dataSourceName, + DatabaseConnectionRole role, + Func command, + CancellationToken cancellationToken = default); + + /// + /// 获取指定数据源及角色的默认命令超时时间(秒)。 + /// + /// 逻辑数据源名称。 + /// 连接角色,默认读取从库。 + /// 命令超时时间(秒)。 + int GetDefaultCommandTimeoutSeconds( + string dataSourceName, + DatabaseConnectionRole role = DatabaseConnectionRole.Read); +} 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..ae8284b --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Diagnostics/TraceContext.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Shared.Abstractions.Diagnostics; + +/// +/// 轻量级 TraceId/SpanId 上下文,便于跨层访问当前请求的追踪标识。 +/// +public static class TraceContext +{ + private static readonly AsyncLocal TraceIdHolder = new(); + private static readonly AsyncLocal SpanIdHolder = new(); + + /// + /// 当前请求的 TraceId。 + /// + public static string? TraceId + { + get => TraceIdHolder.Value; + set => TraceIdHolder.Value = value; + } + + /// + /// 当前请求的 SpanId。 + /// + public static string? SpanId + { + get => SpanIdHolder.Value; + set => SpanIdHolder.Value = value; + } + + /// + /// 清理 TraceId,避免 AsyncLocal 污染其它请求。 + /// + public static void Clear() + { + TraceIdHolder.Value = null; + SpanIdHolder.Value = null; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs new file mode 100644 index 0000000..6dbcf9c --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/AuditableEntityBase.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 审计实体基类:提供创建、更新时间以及软删除时间。 +/// +public abstract class AuditableEntityBase : EntityBase, IAuditableEntity +{ + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; set; } + + /// + /// 最近一次更新时间(UTC),从未更新时为 null。 + /// + public DateTime? UpdatedAt { get; set; } + + /// + /// 软删除时间(UTC),未删除时为 null。 + /// + public DateTime? DeletedAt { get; set; } + + /// + /// 创建人用户标识,匿名或系统操作时为 null。 + /// + public long? CreatedBy { get; set; } + + /// + /// 最后更新人用户标识,匿名或系统操作时为 null。 + /// + public long? UpdatedBy { get; set; } + + /// + /// 删除人用户标识(软删除),未删除时为 null。 + /// + public long? DeletedBy { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs new file mode 100644 index 0000000..e1e2aa4 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/EntityBase.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 实体基类,统一提供主键标识。 +/// +public abstract class EntityBase +{ + /// + /// 实体唯一标识。 + /// + public long Id { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs new file mode 100644 index 0000000..844ad54 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs @@ -0,0 +1,37 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 审计字段接口:提供创建、更新、删除时间与操作者标识。 +/// +public interface IAuditableEntity : ISoftDeleteEntity +{ + /// + /// 创建时间(UTC)。 + /// + DateTime CreatedAt { get; set; } + + /// + /// 更新时间(UTC),未更新时为 null。 + /// + DateTime? UpdatedAt { get; set; } + + /// + /// 删除时间(UTC),未删除时为 null。 + /// + new DateTime? DeletedAt { get; set; } + + /// + /// 创建人用户标识,匿名或系统操作时为 null。 + /// + long? CreatedBy { get; set; } + + /// + /// 最后更新人用户标识,匿名或系统操作时为 null。 + /// + long? UpdatedBy { get; set; } + + /// + /// 删除人用户标识(软删除),未删除时为 null。 + /// + long? DeletedBy { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs new file mode 100644 index 0000000..1a0fecd --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IMultiTenantEntity.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 多租户实体约定:所有持久化实体须包含租户标识字段。 +/// +public interface IMultiTenantEntity +{ + /// + /// 所属租户 ID。 + /// + long TenantId { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs new file mode 100644 index 0000000..4bc3fba --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/ISoftDeleteEntity.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 软删除实体约定:提供可空的删除时间戳以支持全局过滤。 +/// +public interface ISoftDeleteEntity +{ + /// + /// 删除时间(UTC),未删除时为 null。 + /// + DateTime? DeletedAt { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs new file mode 100644 index 0000000..df6417e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/MultiTenantEntityBase.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Entities; + +/// +/// 多租户审计实体基类:提供租户标识、审计字段与软删除标记。 +/// +public abstract class MultiTenantEntityBase : AuditableEntityBase, IMultiTenantEntity +{ + /// + /// 所属租户 ID。 + /// + public long TenantId { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs new file mode 100644 index 0000000..b14dc4a --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs @@ -0,0 +1,13 @@ +namespace TakeoutSaaS.Shared.Abstractions.Exceptions; + +/// +/// 业务异常(用于可预期的业务校验错误)。 +/// +public class BusinessException(int errorCode, string message) : Exception(message) +{ + /// + /// 业务错误码。 + /// + public int ErrorCode { get; } = errorCode; +} + diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs new file mode 100644 index 0000000..f95bf0b --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/ValidationException.cs @@ -0,0 +1,13 @@ +namespace TakeoutSaaS.Shared.Abstractions.Exceptions; + +/// +/// 验证异常(用于聚合验证错误信息)。 +/// +public class ValidationException(IDictionary errors) : Exception("一个或多个验证错误") +{ + /// + /// 字段/属性的错误集合。 + /// + public IDictionary Errors { get; } = errors; +} + diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IIdGenerator.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IIdGenerator.cs new file mode 100644 index 0000000..ce8dff4 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IIdGenerator.cs @@ -0,0 +1,13 @@ +namespace TakeoutSaaS.Shared.Abstractions.Ids; + +/// +/// 雪花 ID 生成器接口。 +/// +public interface IIdGenerator +{ + /// + /// 生成下一个唯一长整型 ID。 + /// + /// 雪花 ID。 + long NextId(); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IdGeneratorOptions.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IdGeneratorOptions.cs new file mode 100644 index 0000000..6d9b40f --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Ids/IdGeneratorOptions.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace TakeoutSaaS.Shared.Abstractions.Ids; + +/// +/// 雪花 ID 生成器配置。 +/// +public sealed class IdGeneratorOptions +{ + /// + /// 配置节名称。 + /// + public const string SectionName = "IdGenerator"; + + /// + /// 工作节点标识,0-31。 + /// + [Range(0, 31)] + public int WorkerId { get; set; } + + /// + /// 机房标识,0-31。 + /// + [Range(0, 31)] + public int DatacenterId { get; set; } +} 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..04f400c --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.NonGeneric.cs @@ -0,0 +1,43 @@ +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..f89a1b7 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs @@ -0,0 +1,203 @@ +using System.Diagnostics; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; + +namespace TakeoutSaaS.Shared.Abstractions.Results; + +/// +/// 统一的 API 返回结果包装。 +/// +/// 数据载荷类型。 +public sealed record 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 + }; + + /// + /// 解析当前 TraceId。 + /// + /// 当前有效的 TraceId。 + private static string ResolveTraceId() + { + if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) + { + return TraceContext.TraceId; + } + + if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) + { + return TraceContext.TraceId; + } + + if (Activity.Current?.Id is { } id && !string.IsNullOrWhiteSpace(id)) + { + return id; + } + + return IdFallbackGenerator.Instance.NextId().ToString(); + } +} + +/// +/// 作为 TraceId 缺失时的本地雪花 ID 备用生成器。 +/// +internal sealed class IdFallbackGenerator +{ + /// + /// 延迟初始化的单例实例承载。 + /// + private static readonly Lazy Lazy = new(() => new IdFallbackGenerator()); + + /// + /// 获取备用雪花生成器单例。 + /// + public static IdFallbackGenerator Instance => Lazy.Value; + + private readonly object _sync = new(); + private long _lastTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + private long _sequence; + + private IdFallbackGenerator() + { + } + + /// + /// 生成雪花风格的本地备用 ID。 + /// + /// 本地生成的雪花 ID。 + public long NextId() + { + lock (_sync) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (timestamp == _lastTimestamp) + { + _sequence = (_sequence + 1) & 4095; + if (_sequence == 0) + { + timestamp = WaitNextMillis(_lastTimestamp); + } + } + else + { + _sequence = 0; + } + + _lastTimestamp = timestamp; + return ((timestamp - 1577836800000L) << 22) | _sequence; + } + } + + /// + /// 等待到下一个毫秒以避免序列冲突。 + /// + /// 上一毫秒的时间戳。 + /// 下一个时间戳(毫秒)。 + private static long WaitNextMillis(long lastTimestamp) + { + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + while (timestamp <= lastTimestamp) + { + Thread.SpinWait(100); + timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + } + + return timestamp; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs new file mode 100644 index 0000000..4bc4c7e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/PagedResult.cs @@ -0,0 +1,36 @@ +namespace TakeoutSaaS.Shared.Abstractions.Results; + +/// +/// 分页结果包装,携带列表与总条数等元数据。 +/// +/// 数据类型。 +/// +/// 初始化分页结果。 +/// +public sealed class PagedResult(IReadOnlyList items, int page, int pageSize, int totalCount) +{ + /// + /// 数据列表。 + /// + public IReadOnlyList Items { get; } = items; + + /// + /// 当前页码,从 1 开始。 + /// + public int Page { get; } = page; + + /// + /// 每页条数。 + /// + public int PageSize { get; } = pageSize; + + /// + /// 总条数。 + /// + public int TotalCount { get; } = totalCount; + + /// + /// 总页数。 + /// + public int TotalPages { get; } = pageSize == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs new file mode 100644 index 0000000..ba0d7e2 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Security/ICurrentUserAccessor.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Shared.Abstractions.Security; + +/// +/// 当前用户访问器:提供与当前请求相关的用户标识信息。 +/// +public interface ICurrentUserAccessor +{ + /// + /// 当前用户 ID,未登录时为 Guid.Empty。 + /// + long UserId { get; } + + /// + /// 是否已登录。 + /// + bool IsAuthenticated { get; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Serialization/SnowflakeIdJsonConverter.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Serialization/SnowflakeIdJsonConverter.cs new file mode 100644 index 0000000..b7f82a0 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Serialization/SnowflakeIdJsonConverter.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace TakeoutSaaS.Shared.Abstractions.Serialization; + +/// +/// 将 long 类型的雪花 ID 以字符串形式序列化/反序列化,避免前端精度丢失。 +/// +public sealed class SnowflakeIdJsonConverter : JsonConverter +{ + /// + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Number => reader.GetInt64(), + JsonTokenType.String when long.TryParse(reader.GetString(), out var value) => value, + JsonTokenType.Null => 0, + _ => throw new JsonException("无法解析雪花 ID") + }; + } + + /// + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + writer.WriteStringValue(value == 0 ? "0" : value.ToString()); + } +} + +/// +/// 可空雪花 ID 转换器。 +/// +public sealed class NullableSnowflakeIdJsonConverter : JsonConverter +{ + /// + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Number => reader.GetInt64(), + JsonTokenType.String when long.TryParse(reader.GetString(), out var value) => value, + JsonTokenType.Null => null, + _ => throw new JsonException("无法解析雪花 ID") + }; + } + + /// + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.HasValue ? value.Value.ToString() : null); + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/TakeoutSaaS.Shared.Abstractions.csproj b/src/Core/TakeoutSaaS.Shared.Abstractions/TakeoutSaaS.Shared.Abstractions.csproj new file mode 100644 index 0000000..43219c2 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/TakeoutSaaS.Shared.Abstractions.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + true + 1591 + + + + + + diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs new file mode 100644 index 0000000..c05f027 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantContextAccessor.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 租户上下文访问器:用于在请求生命周期内读写当前租户上下文。 +/// +public interface ITenantContextAccessor +{ + /// + /// 获取或设置当前租户上下文。 + /// + TenantContext? Current { get; set; } +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs new file mode 100644 index 0000000..76358db --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs @@ -0,0 +1,13 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 租户提供者:用于在各层读取当前请求绑定的租户 ID。 +/// +public interface ITenantProvider +{ + /// + /// 获取当前租户 ID,未解析时返回 0。 + /// + /// 当前请求绑定的租户 ID,未解析时为 0。 + long GetCurrentTenantId(); +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs new file mode 100644 index 0000000..d5638fa --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantConstants.cs @@ -0,0 +1,12 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 多租户相关通用常量。 +/// +public static class TenantConstants +{ + /// + /// HttpContext.Items 中租户上下文的键名。 + /// + public const string HttpContextItemKey = "__tenant_context"; +} diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs new file mode 100644 index 0000000..8ba5da6 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/TenantContext.cs @@ -0,0 +1,38 @@ +namespace TakeoutSaaS.Shared.Abstractions.Tenancy; + +/// +/// 租户上下文:封装当前请求解析得到的租户标识、编号及解析来源。 +/// +/// +/// 初始化租户上下文。 +/// +/// 租户 ID +/// 租户编码(可选) +/// 解析来源 +public sealed class TenantContext(long tenantId, string? tenantCode, string source) +{ + /// + /// 未解析到租户时的默认上下文。 + /// + public static TenantContext Empty { get; } = new(0, null, "unresolved"); + + /// + /// 当前租户 ID,未解析时为 Guid.Empty。 + /// + public long TenantId { get; } = tenantId; + + /// + /// 当前租户编码(例如子域名或业务编码),可为空。 + /// + public string? TenantCode { get; } = tenantCode; + + /// + /// 租户解析来源(Header、Host、Token 等)。 + /// + public string Source { get; } = source; + + /// + /// 是否已成功解析到租户。 + /// + public bool IsResolved => TenantId != 0; +} diff --git a/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs b/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs new file mode 100644 index 0000000..862edb9 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Kernel/Ids/SnowflakeIdGenerator.cs @@ -0,0 +1,104 @@ +using System.Diagnostics; +using System.Security.Cryptography; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Shared.Kernel.Ids; + +/// +/// 基于雪花算法的长整型 ID 生成器。 +/// +/// +/// 初始化生成器。 +/// +/// 工作节点 ID。 +/// 机房 ID。 +public sealed class SnowflakeIdGenerator(long workerId = 0, long datacenterId = 0) : IIdGenerator +{ + private const long Twepoch = 1577836800000L; // 2020-01-01 UTC + private const int WorkerIdBits = 5; + private const int DatacenterIdBits = 5; + private const int SequenceBits = 12; + + private const long MaxWorkerId = -1L ^ (-1L << WorkerIdBits); + private const long MaxDatacenterId = -1L ^ (-1L << DatacenterIdBits); + + private const int WorkerIdShift = SequenceBits; + private const int DatacenterIdShift = SequenceBits + WorkerIdBits; + private const int TimestampLeftShift = SequenceBits + WorkerIdBits + DatacenterIdBits; + private const long SequenceMask = -1L ^ (-1L << SequenceBits); + + private readonly long _workerId = Normalize(workerId, MaxWorkerId, nameof(workerId)); + private readonly long _datacenterId = Normalize(datacenterId, MaxDatacenterId, nameof(datacenterId)); + private long _lastTimestamp = -1L; + private long _sequence = RandomNumberGenerator.GetInt32(0, (int)SequenceMask); + private readonly object _syncRoot = new(); + + + /// + public long NextId() + { + lock (_syncRoot) + { + var timestamp = CurrentTimeMillis(); + + if (timestamp < _lastTimestamp) + { + // 时钟回拨时等待到下一毫秒。 + var wait = _lastTimestamp - timestamp; + Thread.Sleep(TimeSpan.FromMilliseconds(wait)); + timestamp = CurrentTimeMillis(); + if (timestamp < _lastTimestamp) + { + throw new InvalidOperationException($"系统时钟回拨 {_lastTimestamp - timestamp} 毫秒,无法生成 ID。"); + } + } + + if (_lastTimestamp == timestamp) + { + _sequence = (_sequence + 1) & SequenceMask; + if (_sequence == 0) + { + timestamp = WaitNextMillis(_lastTimestamp); + } + } + else + { + _sequence = 0; + } + + _lastTimestamp = timestamp; + + var id = ((timestamp - Twepoch) << TimestampLeftShift) + | (_datacenterId << DatacenterIdShift) + | (_workerId << WorkerIdShift) + | _sequence; + + Debug.Assert(id > 0); + return id; + } + } + + private static long WaitNextMillis(long lastTimestamp) + { + var timestamp = CurrentTimeMillis(); + while (timestamp <= lastTimestamp) + { + Thread.SpinWait(50); + timestamp = CurrentTimeMillis(); + } + + return timestamp; + } + + private static long CurrentTimeMillis() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static long Normalize(long value, long max, string name) + { + if (value < 0 || value > max) + { + throw new ArgumentOutOfRangeException(name, value, $"取值范围 0~{max}"); + } + + return value; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Kernel/TakeoutSaaS.Shared.Kernel.csproj b/src/Core/TakeoutSaaS.Shared.Kernel/TakeoutSaaS.Shared.Kernel.csproj new file mode 100644 index 0000000..db63687 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Kernel/TakeoutSaaS.Shared.Kernel.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/Core/TakeoutSaaS.Shared.Web/Api/BaseApiController.cs b/src/Core/TakeoutSaaS.Shared.Web/Api/BaseApiController.cs new file mode 100644 index 0000000..af1240e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Api/BaseApiController.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Mvc; + +namespace TakeoutSaaS.Shared.Web.Api; + +/// +/// API 基类控制器: +/// - 统一应用 [ApiController] 和默认响应类型 +/// - 作为所有 API 控制器的基类,便于复用过滤器/中间件特性 +/// +[ApiController] +[Produces("application/json")] +public abstract class BaseApiController : ControllerBase +{ +} + 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..842728d --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,53 @@ +using Asp.Versioning; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using TakeoutSaaS.Shared.Abstractions.Security; +using TakeoutSaaS.Shared.Web.Filters; +using TakeoutSaaS.Shared.Web.Security; +namespace TakeoutSaaS.Shared.Web.Extensions; + +/// +/// Shared.Web 服务注册扩展。 +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册控制器、模型验证、API 版本化等基础能力。 + /// + public static IServiceCollection AddSharedWebCore(this IServiceCollection services) + { + // 1. 注册基础上下文与当前用户访问器 + services.AddHttpContextAccessor(); + services.AddEndpointsApiExplorer(); + services.AddScoped(); + // 2. 注册控制器与全局过滤器 + services + .AddControllers(options => + { + options.Filters.Add(); + options.Filters.Add(); + }) + .AddNewtonsoftJson(); + // 3. 配置模型验证行为 + services.Configure(options => + { + options.SuppressModelStateInvalidFilter = true; + }); + // 4. 配置 API 版本化 + var apiVersioningBuilder = services.AddApiVersioning(options => + { + options.AssumeDefaultVersionWhenUnspecified = true; + options.DefaultApiVersion = new ApiVersion(1, 0); + options.ReportApiVersions = true; + }); + // 5. 注册版本化 Api Explorer + apiVersioningBuilder.AddApiExplorer(setup => + { + setup.GroupNameFormat = "'v'VVV"; + setup.SubstituteApiVersionInUrl = true; + }); + // 6. 返回服务集合 + return services; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Filters/ApiResponseResultFilter.cs b/src/Core/TakeoutSaaS.Shared.Web/Filters/ApiResponseResultFilter.cs new file mode 100644 index 0000000..30c6c7c --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Filters/ApiResponseResultFilter.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Http; +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 结果过滤器:自动将 ApiResponse 转换为对应的 HTTP 状态码。 +/// 使用此过滤器后,控制器可以直接返回 ApiResponse<T>,无需再包一层 Ok() 或 Unauthorized()。 +/// +public sealed class ApiResponseResultFilter : IAsyncResultFilter +{ + /// + /// 执行结果过滤,将 ApiResponse 映射为对应 HTTP 状态码。 + /// + /// 结果执行上下文。 + /// 后续委托。 + /// 异步任务。 + public Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + // 1. 仅处理 ObjectResult + // 只处理 ObjectResult 类型的结果 + if (context.Result is not ObjectResult objectResult) + { + return next(); + } + + // 2. 结果为空直接跳过 + var value = objectResult.Value; + if (value == null) + { + return next(); + } + + // 3. 确认类型为 ApiResponse + // 检查是否是 ApiResponse 类型 + var valueType = value.GetType(); + if (!IsApiResponseType(valueType)) + { + return next(); + } + + // 4. 读取 Success 与 Code + // 使用反射获取 Success 和 Code 属性 + // 注意:由于已通过 IsApiResponseType 检查,属性名是固定的 + const string successPropertyName = "Success"; + const string codePropertyName = "Code"; + var successProperty = valueType.GetProperty(successPropertyName); + var codeProperty = valueType.GetProperty(codePropertyName); + + if (successProperty == null || codeProperty == null) + { + return next(); + } + + var success = (bool)(successProperty.GetValue(value) ?? false); + var code = (int)(codeProperty.GetValue(value) ?? 200); + + // 5. 映射 HTTP 状态码 + // 根据 Success 和 Code 设置 HTTP 状态码 + var statusCode = success ? MapSuccessCode(code) : MapErrorCode(code); + + // 6. 回写状态码 + // 更新 ObjectResult 的状态码 + objectResult.StatusCode = statusCode; + + return next(); + } + + private static bool IsApiResponseType(Type type) + { + // 检查是否是 ApiResponse 类型 + if (type.IsGenericType) + { + var genericTypeDefinition = type.GetGenericTypeDefinition(); + return genericTypeDefinition == typeof(ApiResponse<>); + } + + return false; + } + + private static int MapSuccessCode(int code) + { + // 成功情况下,通常返回 200 + // 但也可以根据业务码返回其他成功状态码(如 201 Created) + return code switch + { + 200 => StatusCodes.Status200OK, + 201 => StatusCodes.Status201Created, + 204 => StatusCodes.Status204NoContent, + _ => StatusCodes.Status200OK + }; + } + + private static int MapErrorCode(int code) + { + // 根据业务错误码映射到 HTTP 状态码 + return code switch + { + ErrorCodes.BadRequest => StatusCodes.Status400BadRequest, + ErrorCodes.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorCodes.Forbidden => StatusCodes.Status403Forbidden, + ErrorCodes.NotFound => StatusCodes.Status404NotFound, + ErrorCodes.Conflict => StatusCodes.Status409Conflict, + ErrorCodes.ValidationFailed => StatusCodes.Status422UnprocessableEntity, + ErrorCodes.InternalServerError => StatusCodes.Status500InternalServerError, + // 业务错误码(10000+)统一返回 422 + >= 10000 => StatusCodes.Status422UnprocessableEntity, + // 默认返回 400 + _ => StatusCodes.Status400BadRequest + }; + } +} 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..f128bea --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Filters/ValidateModelAttribute.cs @@ -0,0 +1,33 @@ +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 +{ + /// + /// 在 Action 执行前拦截模型验证错误。 + /// + /// 执行上下文。 + public override void OnActionExecuting(ActionExecutingContext context) + { + // 1. 模型验证未通过则返回 422 + 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..3f545e3 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,108 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; +using TakeoutSaaS.Shared.Abstractions.Ids; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 统一 TraceId/CorrelationId,贯穿日志与响应。 +/// +public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger logger, IIdGenerator idGenerator) +{ + private const string TraceHeader = "X-Trace-Id"; + private const string SpanHeader = "X-Span-Id"; + private const string RequestHeader = "X-Request-Id"; + + /// + /// 管道入口,确保 TraceId/SpanId 贯穿请求。 + /// + /// HTTP 上下文。 + public async Task InvokeAsync(HttpContext context) + { + // 1. 确保活动存在并启动 + var ownsActivity = Activity.Current is null; + var activity = Activity.Current ?? new Activity("TakeoutSaaS.Request"); + + if (activity.Id is null) + { + activity.SetIdFormat(ActivityIdFormat.W3C); + activity.Start(); + } + + // 2. 生成/解析 TraceId、SpanId + var traceId = activity.TraceId.ToString(); + var spanId = activity.SpanId.ToString(); + + if (string.IsNullOrWhiteSpace(traceId)) + { + traceId = ResolveTraceId(context); + } + + // 3. 写入上下文与响应头 + context.TraceIdentifier = traceId; + TraceContext.TraceId = traceId; + TraceContext.SpanId = spanId; + + context.Response.OnStarting(() => + { + context.Response.Headers[TraceHeader] = traceId; + context.Response.Headers[SpanHeader] = spanId; + return Task.CompletedTask; + }); + + // 4. 带 Scope 调用后续中间件 + using (logger.BeginScope(new Dictionary + { + ["TraceId"] = traceId, + ["SpanId"] = spanId + })) + { + try + { + await next(context); + } + finally + { + // 5. 清理上下文与活动 + TraceContext.Clear(); + if (ownsActivity) + { + activity.Stop(); + } + } + } + } + + private string ResolveTraceId(HttpContext context) + { + if (TryGetHeader(context, TraceHeader, out var traceId)) + { + return traceId; + } + + if (TryGetHeader(context, RequestHeader, out var requestId)) + { + return requestId; + } + + return idGenerator.NextId().ToString(); + } + + 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..6af83c7 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/ExceptionHandlingMiddleware.cs @@ -0,0 +1,139 @@ +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Collections.Generic; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Exceptions; +using TakeoutSaaS.Shared.Abstractions.Results; +using FluentValidationException = FluentValidation.ValidationException; +using SharedValidationException = TakeoutSaaS.Shared.Abstractions.Exceptions.ValidationException; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 全局异常处理中间件,将异常统一映射为 ApiResponse。 +/// +public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger, IHostEnvironment environment) +{ + private static readonly HashSet AllowedHttpErrorCodes = new() + { + ErrorCodes.BadRequest, + ErrorCodes.Unauthorized, + ErrorCodes.Forbidden, + ErrorCodes.NotFound, + ErrorCodes.Conflict, + ErrorCodes.ValidationFailed + }; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// + /// 中间件入口,捕获并统一处理异常。 + /// + /// HTTP 上下文。 + public async Task InvokeAsync(HttpContext context) + { + try + { + await next(context); + } + catch (Exception ex) + { + // 1. 记录异常 + logger.LogError(ex, "未处理异常:{Message}", ex.Message); + // 2. 返回统一错误响应 + await HandleExceptionAsync(context, ex); + } + } + + private Task HandleExceptionAsync(HttpContext context, Exception exception) + { + // 1. 构建错误响应与状态码 + var (statusCode, response) = BuildErrorResponse(exception); + + if (environment.IsDevelopment()) + { + // 2. 开发环境附加细节 + response = response with + { + Message = exception.Message, + Errors = new + { + response.Errors, + detail = exception.ToString() + } + }; + } + + // 3. 写入响应 + 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 + { + DbUpdateConcurrencyException => ( + StatusCodes.Status409Conflict, + ApiResponse.Error( + ErrorCodes.Conflict, + "数据已被他人修改,请刷新后重试", + new Dictionary + { + ["RowVersion"] = ["数据已被他人修改,请刷新后重试"] + })), + UnauthorizedAccessException => ( + StatusCodes.Status403Forbidden, + ApiResponse.Error(ErrorCodes.Forbidden, "无权访问该资源")), + SharedValidationException validationException => ( + StatusCodes.Status422UnprocessableEntity, + ApiResponse.Error(ErrorCodes.ValidationFailed, "请求参数验证失败", validationException.Errors)), + FluentValidationException fluentValidationException => ( + StatusCodes.Status422UnprocessableEntity, + ApiResponse.Error( + ErrorCodes.ValidationFailed, + "请求参数验证失败", + NormalizeValidationErrors(fluentValidationException.Errors))), + BusinessException businessException => ( + // 1. 仅当业务错误码在白名单且位于 400-499 时透传,否则回退 400 + AllowedHttpErrorCodes.Contains(businessException.ErrorCode) && businessException.ErrorCode is >= 400 and < 500 + ? businessException.ErrorCode + : StatusCodes.Status400BadRequest, + ApiResponse.Error(businessException.ErrorCode, businessException.Message)), + _ => ( + StatusCodes.Status500InternalServerError, + ApiResponse.Error(ErrorCodes.InternalServerError, "服务器开小差啦,请稍后再试")) + }; + } + + private static IDictionary NormalizeValidationErrors(IEnumerable failures) + { + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var failure in failures) + { + var key = string.IsNullOrWhiteSpace(failure.PropertyName) ? "request" : failure.PropertyName; + if (!result.TryGetValue(key, out var list)) + { + list = new List(); + result[key] = list; + } + + if (!string.IsNullOrWhiteSpace(failure.ErrorMessage)) + { + list.Add(failure.ErrorMessage); + } + } + + return result.ToDictionary(pair => pair.Key, pair => pair.Value.Distinct().ToArray(), StringComparer.OrdinalIgnoreCase); + } +} 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..bd7a983 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/RequestLoggingMiddleware.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Diagnostics; +using TakeoutSaaS.Shared.Abstractions.Diagnostics; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 基础请求日志(方法、路径、耗时、状态码、TraceId)。 +/// +public sealed class RequestLoggingMiddleware(RequestDelegate next, ILogger logger) +{ + /// + /// 记录请求日志并调用后续中间件。 + /// + /// HTTP 上下文。 + public async Task InvokeAsync(HttpContext context) + { + // 1. 启动计时 + var stopwatch = Stopwatch.StartNew(); + try + { + await next(context); + } + finally + { + // 2. 结束计时并输出日志 + stopwatch.Stop(); + var traceId = TraceContext.TraceId ?? context.TraceIdentifier; + var spanId = TraceContext.SpanId ?? Activity.Current?.SpanId.ToString() ?? string.Empty; + logger.LogInformation( + "HTTP {Method} {Path} => {StatusCode} ({Elapsed} ms) TraceId:{TraceId} SpanId:{SpanId}", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + stopwatch.Elapsed.TotalMilliseconds, + traceId, + spanId); + } + } +} 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..dbc8773 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; + +namespace TakeoutSaaS.Shared.Web.Middleware; + +/// +/// 安全响应头中间件 +/// +public sealed class SecurityHeadersMiddleware(RequestDelegate next) +{ + /// + /// 设置基础安全响应头。 + /// + /// HTTP 上下文。 + public async Task InvokeAsync(HttpContext context) + { + // 1. 写入安全响应头 + 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"; + // 2. 继续后续管道 + await next(context); + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs new file mode 100644 index 0000000..630789b --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/ClaimsPrincipalExtensions.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// ClaimsPrincipal 便捷扩展 +/// +public static class ClaimsPrincipalExtensions +{ + /// + /// 获取当前用户 Id(不存在时返回 0)。 + /// + public static long GetUserId(this ClaimsPrincipal? principal) + { + if (principal == null) + { + return 0; + } + + var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub"); + + return long.TryParse(identifier, out var userId) + ? userId + : 0; + } +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs new file mode 100644 index 0000000..bc43ea8 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/HttpContextCurrentUserAccessor.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Http; +using System.Security.Claims; +using TakeoutSaaS.Shared.Abstractions.Security; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// 基于 HttpContext 的当前用户访问器。 +/// +/// +/// 初始化访问器。 +/// +public sealed class HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUserAccessor +{ + /// + public long UserId + { + get + { + var principal = httpContextAccessor.HttpContext?.User; + if (principal == null || !principal.Identity?.IsAuthenticated == true) + { + return 0; + } + + var identifier = principal.FindFirstValue(ClaimTypes.NameIdentifier) + ?? principal.FindFirstValue("sub"); + + return long.TryParse(identifier, out var id) ? id : 0; + } + } + + /// + public bool IsAuthenticated => UserId != 0; +} diff --git a/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs new file mode 100644 index 0000000..12832c9 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Security/TenantHttpContextExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Http; +using TakeoutSaaS.Shared.Abstractions.Tenancy; + +namespace TakeoutSaaS.Shared.Web.Security; + +/// +/// HttpContext 租户扩展方法。 +/// +public static class TenantHttpContextExtensions +{ + /// + /// 获取 HttpContext.Items 中缓存的租户上下文。 + /// + /// 当前 HttpContext + /// 租户上下文,若不存在则返回 null + public static TenantContext? GetTenantContext(this HttpContext? context) + { + if (context == null) + { + return null; + } + + if (context.Items.TryGetValue(TenantConstants.HttpContextItemKey, out var value) && + value is TenantContext tenantContext) + { + return tenantContext; + } + + return null; + } +} 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..cfa1753 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/ConfigureSwaggerOptions.cs @@ -0,0 +1,60 @@ +using Asp.Versioning.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.OpenApi; +using Swashbuckle.AspNetCore.SwaggerGen; +namespace TakeoutSaaS.Shared.Web.Swagger; + +/// +/// 根据 API 版本动态注册 Swagger 文档。 +/// +internal sealed class ConfigureSwaggerOptions( + IApiVersionDescriptionProvider provider, + IOptions settings) : IConfigureOptions +{ + private readonly SwaggerDocumentSettings _settings = settings.Value; + + /// + /// 根据 API 版本生成 Swagger 文档配置。 + /// + public void Configure(SwaggerGenOptions options) + { + // 1. 为每个 API 版本注册文档 + 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; + } + + // 2. 配置 JWT 授权信息 + if (_settings.EnableAuthorization) + { + const string bearerSchemeName = "Bearer"; + var scheme = new OpenApiSecurityScheme + { + Name = "Authorization", + Description = "在下方输入Bearer Token,格式:Bearer {token}", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT" + }; + options.AddSecurityDefinition(bearerSchemeName, scheme); + options.AddSecurityRequirement(document => + { + var requirement = new OpenApiSecurityRequirement + { + { new OpenApiSecuritySchemeReference(bearerSchemeName, document, null), new List() } + }; + return requirement; + }); + } + } +} 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..19a1606 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs @@ -0,0 +1,116 @@ +using System; +using System.IO; +using System.Linq; +using Asp.Versioning.ApiExplorer; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.Annotations; +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) + { + // 1. 注册 Swagger 并加载 XML 注释以展示中文文档 + services.AddSwaggerGen(options => + { + var basePath = AppContext.BaseDirectory; + var xmlFiles = Directory.GetFiles(basePath, "*.xml"); + foreach (var xml in xmlFiles) + { + options.IncludeXmlComments(xml, true); + } + options.EnableAnnotations(); + + // Avoid schemaId collisions when different types share the same Name in different namespaces. + options.CustomSchemaIds(CreateSchemaId); + }); + services.AddSingleton(_ => + { + var settings = new SwaggerDocumentSettings(); + configure?.Invoke(settings); + return settings; + }); + services.AddSingleton>(provider => + new ConfigureSwaggerOptions( + provider.GetRequiredService(), + Options.Create(provider.GetRequiredService()))); + return services; + } + + private static string CreateSchemaId(Type type) + { + if (type.IsArray) + { + var elementType = type.GetElementType(); + return $"{CreateSchemaId(elementType ?? typeof(object))}Array"; + } + + if (!type.IsGenericType) + { + return (type.FullName ?? type.Name).Replace("+", "."); + } + + var genericDef = type.GetGenericTypeDefinition(); + var genericName = (genericDef.FullName ?? genericDef.Name).Replace("+", "."); + var tickIndex = genericName.IndexOf('`'); + if (tickIndex >= 0) + { + genericName = genericName[..tickIndex]; + } + + var argNames = string.Join("And", type.GetGenericArguments().Select(CreateSchemaId)); + return $"{genericName}Of{argNames}"; + } + + /// + /// 开发环境启用 Swagger UI(自动注册所有版本)。 + /// + public static IApplicationBuilder UseSharedSwagger(this IApplicationBuilder app) + { + var provider = app.ApplicationServices.GetRequiredService(); + var settings = app.ApplicationServices.GetRequiredService(); + const string routePrefix = "api/docs"; + const string legacyRoutePrefix = "swagger"; + // 1. 注册 Swagger 中间件(新旧入口同时支持) + app.UseSwagger(options => { options.RouteTemplate = $"{routePrefix}/{{documentName}}/swagger.json"; }); + app.UseSwagger(options => { options.RouteTemplate = $"{legacyRoutePrefix}/{{documentName}}/swagger.json"; }); + app.UseSwaggerUI(options => + { + options.RoutePrefix = routePrefix; + foreach (var description in provider.ApiVersionDescriptions) + { + // 3. 使用相对路径适配反向代理/网关前缀 + options.SwaggerEndpoint( + $"./{description.GroupName}/swagger.json", + $"{settings.Title} {description.ApiVersion}"); + } + // 2. 显示请求耗时 + options.DisplayRequestDuration(); + }); + app.UseSwaggerUI(options => + { + options.RoutePrefix = legacyRoutePrefix; + foreach (var description in provider.ApiVersionDescriptions) + { + // 3. 使用相对路径适配反向代理/网关前缀 + options.SwaggerEndpoint( + $"./{description.GroupName}/swagger.json", + $"{settings.Title} {description.ApiVersion}"); + } + // 2. 显示请求耗时 + 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..ddb24c5 --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/TakeoutSaaS.Shared.Web.csproj @@ -0,0 +1,30 @@ + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + + + +