feat: 初始化 BuildingBlocks 仓库

This commit is contained in:
2026-01-29 01:57:39 +00:00
commit 4cf39b4d42
44 changed files with 1799 additions and 0 deletions

7
README.md Normal file
View File

@@ -0,0 +1,7 @@
# TakeoutSaaS.BuildingBlocks
该仓库用于承载 TakeoutSaaS 的可复用基础组件Building Blocks
## 约定
- 该仓库通常以 git submodule 的形式被 `TakeoutSaaS.AdminApi` / `TakeoutSaaS.TenantApi` 引用。
- 共享代码不应包含租户过滤等“租户侧专属”能力。

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Shared.Abstractions.Constants;
/// <summary>
/// 数据源名称常量,统一配置键与使用说明。
/// </summary>
public static class DatabaseConstants
{
/// <summary>
/// 默认业务库AppDatabase.
/// </summary>
public const string AppDataSource = "AppDatabase";
/// <summary>
/// 身份认证库IdentityDatabase
/// </summary>
public const string IdentityDataSource = "IdentityDatabase";
/// <summary>
/// 字典库DictionaryDatabase
/// </summary>
public const string DictionaryDataSource = "DictionaryDatabase";
/// <summary>
/// 日志库LogsDatabase
/// </summary>
public const string LogsDataSource = "LogsDatabase";
}

View File

@@ -0,0 +1,47 @@
namespace TakeoutSaaS.Shared.Abstractions.Constants;
/// <summary>
/// 统一错误码常量。
/// </summary>
public static class ErrorCodes
{
/// <summary>
/// 请求参数错误。
/// </summary>
public const int BadRequest = 400;
/// <summary>
/// 未授权访问。
/// </summary>
public const int Unauthorized = 401;
/// <summary>
/// 权限不足。
/// </summary>
public const int Forbidden = 403;
/// <summary>
/// 资源未找到。
/// </summary>
public const int NotFound = 404;
/// <summary>
/// 资源冲突。
/// </summary>
public const int Conflict = 409;
/// <summary>
/// 校验失败。
/// </summary>
public const int ValidationFailed = 422;
/// <summary>
/// 服务器内部错误。
/// </summary>
public const int InternalServerError = 500;
/// <summary>
/// 业务自定义错误10000+)。
/// </summary>
public const int BusinessError = 10001;
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Shared.Abstractions.Data;
/// <summary>
/// 数据库连接角色,用于区分主写与从读连接。
/// </summary>
public enum DatabaseConnectionRole
{
/// <summary>
/// 主写连接,用于写入或强一致读。
/// </summary>
Write = 1,
/// <summary>
/// 从读连接,用于只读查询或报表。
/// </summary>
Read = 2
}

View File

@@ -0,0 +1,48 @@
using System.Data;
namespace TakeoutSaaS.Shared.Abstractions.Data;
/// <summary>
/// Dapper 查询/命令执行器抽象,封装连接获取与读写路由。
/// </summary>
public interface IDapperExecutor
{
/// <summary>
/// 使用指定数据源与读写角色执行异步查询,并返回结果。
/// </summary>
/// <typeparam name="TResult">查询结果类型。</typeparam>
/// <param name="dataSourceName">逻辑数据源名称。</param>
/// <param name="role">连接角色(读/写)。</param>
/// <param name="query">查询委托,提供已打开的连接和取消标记。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>查询结果。</returns>
Task<TResult> QueryAsync<TResult>(
string dataSourceName,
DatabaseConnectionRole role,
Func<IDbConnection, CancellationToken, Task<TResult>> query,
CancellationToken cancellationToken = default);
/// <summary>
/// 使用指定数据源与读写角色执行异步命令。
/// </summary>
/// <param name="dataSourceName">逻辑数据源名称。</param>
/// <param name="role">连接角色(读/写)。</param>
/// <param name="command">命令委托,提供已打开的连接和取消标记。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>异步执行任务。</returns>
Task ExecuteAsync(
string dataSourceName,
DatabaseConnectionRole role,
Func<IDbConnection, CancellationToken, Task> command,
CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定数据源及角色的默认命令超时时间(秒)。
/// </summary>
/// <param name="dataSourceName">逻辑数据源名称。</param>
/// <param name="role">连接角色,默认读取从库。</param>
/// <returns>命令超时时间(秒)。</returns>
int GetDefaultCommandTimeoutSeconds(
string dataSourceName,
DatabaseConnectionRole role = DatabaseConnectionRole.Read);
}

View File

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

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Shared.Abstractions.Entities;
/// <summary>
/// 审计实体基类:提供创建、更新时间以及软删除时间。
/// </summary>
public abstract class AuditableEntityBase : EntityBase, IAuditableEntity
{
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// 最近一次更新时间UTC从未更新时为 null。
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// 软删除时间UTC未删除时为 null。
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// 创建人用户标识,匿名或系统操作时为 null。
/// </summary>
public long? CreatedBy { get; set; }
/// <summary>
/// 最后更新人用户标识,匿名或系统操作时为 null。
/// </summary>
public long? UpdatedBy { get; set; }
/// <summary>
/// 删除人用户标识(软删除),未删除时为 null。
/// </summary>
public long? DeletedBy { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Shared.Abstractions.Entities;
/// <summary>
/// 实体基类,统一提供主键标识。
/// </summary>
public abstract class EntityBase
{
/// <summary>
/// 实体唯一标识。
/// </summary>
public long Id { get; set; }
}

View File

@@ -0,0 +1,37 @@
namespace TakeoutSaaS.Shared.Abstractions.Entities;
/// <summary>
/// 审计字段接口:提供创建、更新、删除时间与操作者标识。
/// </summary>
public interface IAuditableEntity : ISoftDeleteEntity
{
/// <summary>
/// 创建时间UTC
/// </summary>
DateTime CreatedAt { get; set; }
/// <summary>
/// 更新时间UTC未更新时为 null。
/// </summary>
DateTime? UpdatedAt { get; set; }
/// <summary>
/// 删除时间UTC未删除时为 null。
/// </summary>
new DateTime? DeletedAt { get; set; }
/// <summary>
/// 创建人用户标识,匿名或系统操作时为 null。
/// </summary>
long? CreatedBy { get; set; }
/// <summary>
/// 最后更新人用户标识,匿名或系统操作时为 null。
/// </summary>
long? UpdatedBy { get; set; }
/// <summary>
/// 删除人用户标识(软删除),未删除时为 null。
/// </summary>
long? DeletedBy { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Shared.Abstractions.Entities;
/// <summary>
/// 多租户实体约定:所有持久化实体须包含租户标识字段。
/// </summary>
public interface IMultiTenantEntity
{
/// <summary>
/// 所属租户 ID。
/// </summary>
long TenantId { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Shared.Abstractions.Entities;
/// <summary>
/// 软删除实体约定:提供可空的删除时间戳以支持全局过滤。
/// </summary>
public interface ISoftDeleteEntity
{
/// <summary>
/// 删除时间UTC未删除时为 null。
/// </summary>
DateTime? DeletedAt { get; set; }
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Shared.Abstractions.Entities;
/// <summary>
/// 多租户审计实体基类:提供租户标识、审计字段与软删除标记。
/// </summary>
public abstract class MultiTenantEntityBase : AuditableEntityBase, IMultiTenantEntity
{
/// <summary>
/// 所属租户 ID。
/// </summary>
public long TenantId { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace TakeoutSaaS.Shared.Abstractions.Exceptions;
/// <summary>
/// 业务异常(用于可预期的业务校验错误)。
/// </summary>
public class BusinessException(int errorCode, string message) : Exception(message)
{
/// <summary>
/// 业务错误码。
/// </summary>
public int ErrorCode { get; } = errorCode;
}

View File

@@ -0,0 +1,13 @@
namespace TakeoutSaaS.Shared.Abstractions.Exceptions;
/// <summary>
/// 验证异常(用于聚合验证错误信息)。
/// </summary>
public class ValidationException(IDictionary<string, string[]> errors) : Exception("一个或多个验证错误")
{
/// <summary>
/// 字段/属性的错误集合。
/// </summary>
public IDictionary<string, string[]> Errors { get; } = errors;
}

View File

@@ -0,0 +1,13 @@
namespace TakeoutSaaS.Shared.Abstractions.Ids;
/// <summary>
/// 雪花 ID 生成器接口。
/// </summary>
public interface IIdGenerator
{
/// <summary>
/// 生成下一个唯一长整型 ID。
/// </summary>
/// <returns>雪花 ID。</returns>
long NextId();
}

View File

@@ -0,0 +1,26 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Shared.Abstractions.Ids;
/// <summary>
/// 雪花 ID 生成器配置。
/// </summary>
public sealed class IdGeneratorOptions
{
/// <summary>
/// 配置节名称。
/// </summary>
public const string SectionName = "IdGenerator";
/// <summary>
/// 工作节点标识0-31。
/// </summary>
[Range(0, 31)]
public int WorkerId { get; set; }
/// <summary>
/// 机房标识0-31。
/// </summary>
[Range(0, 31)]
public int DatacenterId { get; set; }
}

View File

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

View File

@@ -0,0 +1,203 @@
using System.Diagnostics;
using TakeoutSaaS.Shared.Abstractions.Diagnostics;
namespace TakeoutSaaS.Shared.Abstractions.Results;
/// <summary>
/// 统一的 API 返回结果包装。
/// </summary>
/// <typeparam name="T">数据载荷类型。</typeparam>
public sealed record 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>
/// <param name="data">业务数据。</param>
/// <param name="message">提示信息。</param>
/// <returns>封装后的成功响应。</returns>
public static ApiResponse<T> Ok(T data, string? message = "操作成功")
=> Create(true, 200, message, data);
/// <summary>
/// 无数据的成功返回。
/// </summary>
/// <param name="message">提示信息。</param>
/// <returns>封装后的成功响应。</returns>
public static ApiResponse<T> Ok(string? message = "操作成功")
=> Create(true, 200, message, default);
/// <summary>
/// 兼容旧名称:成功结果。
/// </summary>
/// <param name="data">业务数据。</param>
/// <param name="message">提示信息。</param>
/// <returns>封装后的成功响应。</returns>
public static ApiResponse<T> SuccessResult(T data, string? message = "操作成功")
=> Ok(data, message);
/// <summary>
/// 错误返回。
/// </summary>
/// <param name="code">错误码。</param>
/// <param name="message">错误提示。</param>
/// <param name="errors">错误详情。</param>
/// <returns>封装后的失败响应。</returns>
public static ApiResponse<T> Error(int code, string message, object? errors = null)
=> Create(false, code, message, default, errors);
/// <summary>
/// 兼容旧名称:失败结果。
/// </summary>
/// <param name="code">错误码。</param>
/// <param name="message">错误提示。</param>
/// <returns>封装后的失败响应。</returns>
public static ApiResponse<T> Failure(int code, string message)
=> Error(code, message);
/// <summary>
/// 附加错误详情。
/// </summary>
/// <param name="errors">错误详情。</param>
/// <returns>包含错误详情的新响应。</returns>
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
};
/// <summary>
/// 解析当前 TraceId。
/// </summary>
/// <returns>当前有效的 TraceId。</returns>
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();
}
}
/// <summary>
/// 作为 TraceId 缺失时的本地雪花 ID 备用生成器。
/// </summary>
internal sealed class IdFallbackGenerator
{
/// <summary>
/// 延迟初始化的单例实例承载。
/// </summary>
private static readonly Lazy<IdFallbackGenerator> Lazy = new(() => new IdFallbackGenerator());
/// <summary>
/// 获取备用雪花生成器单例。
/// </summary>
public static IdFallbackGenerator Instance => Lazy.Value;
private readonly object _sync = new();
private long _lastTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private long _sequence;
private IdFallbackGenerator()
{
}
/// <summary>
/// 生成雪花风格的本地备用 ID。
/// </summary>
/// <returns>本地生成的雪花 ID。</returns>
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;
}
}
/// <summary>
/// 等待到下一个毫秒以避免序列冲突。
/// </summary>
/// <param name="lastTimestamp">上一毫秒的时间戳。</param>
/// <returns>下一个时间戳(毫秒)。</returns>
private static long WaitNextMillis(long lastTimestamp)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
while (timestamp <= lastTimestamp)
{
Thread.SpinWait(100);
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
return timestamp;
}
}

View File

@@ -0,0 +1,36 @@
namespace TakeoutSaaS.Shared.Abstractions.Results;
/// <summary>
/// 分页结果包装,携带列表与总条数等元数据。
/// </summary>
/// <typeparam name="T">数据类型。</typeparam>
/// <remarks>
/// 初始化分页结果。
/// </remarks>
public sealed class PagedResult<T>(IReadOnlyList<T> items, int page, int pageSize, int totalCount)
{
/// <summary>
/// 数据列表。
/// </summary>
public IReadOnlyList<T> Items { get; } = items;
/// <summary>
/// 当前页码,从 1 开始。
/// </summary>
public int Page { get; } = page;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; } = pageSize;
/// <summary>
/// 总条数。
/// </summary>
public int TotalCount { get; } = totalCount;
/// <summary>
/// 总页数。
/// </summary>
public int TotalPages { get; } = pageSize == 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize);
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Shared.Abstractions.Security;
/// <summary>
/// 当前用户访问器:提供与当前请求相关的用户标识信息。
/// </summary>
public interface ICurrentUserAccessor
{
/// <summary>
/// 当前用户 ID未登录时为 Guid.Empty。
/// </summary>
long UserId { get; }
/// <summary>
/// 是否已登录。
/// </summary>
bool IsAuthenticated { get; }
}

View File

@@ -0,0 +1,52 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace TakeoutSaaS.Shared.Abstractions.Serialization;
/// <summary>
/// 将 long 类型的雪花 ID 以字符串形式序列化/反序列化,避免前端精度丢失。
/// </summary>
public sealed class SnowflakeIdJsonConverter : JsonConverter<long>
{
/// <inheritdoc />
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")
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options)
{
writer.WriteStringValue(value == 0 ? "0" : value.ToString());
}
}
/// <summary>
/// 可空雪花 ID 转换器。
/// </summary>
public sealed class NullableSnowflakeIdJsonConverter : JsonConverter<long?>
{
/// <inheritdoc />
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")
};
}
/// <inheritdoc />
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.HasValue ? value.Value.ToString() : null);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Update="System.IO.Packaging" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
/// <summary>
/// 租户上下文访问器:用于在请求生命周期内读写当前租户上下文。
/// </summary>
public interface ITenantContextAccessor
{
/// <summary>
/// 获取或设置当前租户上下文。
/// </summary>
TenantContext? Current { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
/// <summary>
/// 租户提供者:用于在各层读取当前请求绑定的租户 ID。
/// </summary>
public interface ITenantProvider
{
/// <summary>
/// 获取当前租户 ID未解析时返回 0。
/// </summary>
/// <returns>当前请求绑定的租户 ID未解析时为 0。</returns>
long GetCurrentTenantId();
}

View File

@@ -0,0 +1,12 @@
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
/// <summary>
/// 多租户相关通用常量。
/// </summary>
public static class TenantConstants
{
/// <summary>
/// HttpContext.Items 中租户上下文的键名。
/// </summary>
public const string HttpContextItemKey = "__tenant_context";
}

View File

@@ -0,0 +1,38 @@
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
/// <summary>
/// 租户上下文:封装当前请求解析得到的租户标识、编号及解析来源。
/// </summary>
/// <remarks>
/// 初始化租户上下文。
/// </remarks>
/// <param name="tenantId">租户 ID</param>
/// <param name="tenantCode">租户编码(可选)</param>
/// <param name="source">解析来源</param>
public sealed class TenantContext(long tenantId, string? tenantCode, string source)
{
/// <summary>
/// 未解析到租户时的默认上下文。
/// </summary>
public static TenantContext Empty { get; } = new(0, null, "unresolved");
/// <summary>
/// 当前租户 ID未解析时为 Guid.Empty。
/// </summary>
public long TenantId { get; } = tenantId;
/// <summary>
/// 当前租户编码(例如子域名或业务编码),可为空。
/// </summary>
public string? TenantCode { get; } = tenantCode;
/// <summary>
/// 租户解析来源Header、Host、Token 等)。
/// </summary>
public string Source { get; } = source;
/// <summary>
/// 是否已成功解析到租户。
/// </summary>
public bool IsResolved => TenantId != 0;
}

View File

@@ -0,0 +1,104 @@
using System.Diagnostics;
using System.Security.Cryptography;
using TakeoutSaaS.Shared.Abstractions.Ids;
namespace TakeoutSaaS.Shared.Kernel.Ids;
/// <summary>
/// 基于雪花算法的长整型 ID 生成器。
/// </summary>
/// <remarks>
/// 初始化生成器。
/// </remarks>
/// <param name="workerId">工作节点 ID。</param>
/// <param name="datacenterId">机房 ID。</param>
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();
/// <inheritdoc />
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;
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="System.IO.Packaging" Version="10.0.1" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc;
namespace TakeoutSaaS.Shared.Web.Api;
/// <summary>
/// API 基类控制器:
/// - 统一应用 [ApiController] 和默认响应类型
/// - 作为所有 API 控制器的基类,便于复用过滤器/中间件特性
/// </summary>
[ApiController]
[Produces("application/json")]
public abstract class BaseApiController : ControllerBase
{
}

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,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;
/// <summary>
/// Shared.Web 服务注册扩展。
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// 注册控制器、模型验证、API 版本化等基础能力。
/// </summary>
public static IServiceCollection AddSharedWebCore(this IServiceCollection services)
{
// 1. 注册基础上下文与当前用户访问器
services.AddHttpContextAccessor();
services.AddEndpointsApiExplorer();
services.AddScoped<ICurrentUserAccessor, HttpContextCurrentUserAccessor>();
// 2. 注册控制器与全局过滤器
services
.AddControllers(options =>
{
options.Filters.Add<ValidateModelAttribute>();
options.Filters.Add<ApiResponseResultFilter>();
})
.AddNewtonsoftJson();
// 3. 配置模型验证行为
services.Configure<ApiBehaviorOptions>(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;
}
}

View File

@@ -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;
/// <summary>
/// ApiResponse 结果过滤器:自动将 ApiResponse 转换为对应的 HTTP 状态码。
/// 使用此过滤器后,控制器可以直接返回 ApiResponse&lt;T&gt;,无需再包一层 Ok() 或 Unauthorized()。
/// </summary>
public sealed class ApiResponseResultFilter : IAsyncResultFilter
{
/// <summary>
/// 执行结果过滤,将 ApiResponse 映射为对应 HTTP 状态码。
/// </summary>
/// <param name="context">结果执行上下文。</param>
/// <param name="next">后续委托。</param>
/// <returns>异步任务。</returns>
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<T>
// 检查是否是 ApiResponse<T> 类型
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<T> 类型
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
};
}
}

View File

@@ -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;
/// <summary>
/// 模型验证过滤器将模型验证错误统一为ApiResponse输出
/// </summary>
public sealed class ValidateModelAttribute : ActionFilterAttribute
{
/// <summary>
/// 在 Action 执行前拦截模型验证错误。
/// </summary>
/// <param name="context">执行上下文。</param>
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<object>.Error(ErrorCodes.ValidationFailed, "一个或多个验证错误", errors);
context.Result = new UnprocessableEntityObjectResult(response);
}
}
}

View File

@@ -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;
/// <summary>
/// 统一 TraceId/CorrelationId贯穿日志与响应。
/// </summary>
public sealed class CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger, IIdGenerator idGenerator)
{
private const string TraceHeader = "X-Trace-Id";
private const string SpanHeader = "X-Span-Id";
private const string RequestHeader = "X-Request-Id";
/// <summary>
/// 管道入口,确保 TraceId/SpanId 贯穿请求。
/// </summary>
/// <param name="context">HTTP 上下文。</param>
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<string, object>
{
["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;
}
}

View File

@@ -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;
/// <summary>
/// 全局异常处理中间件,将异常统一映射为 ApiResponse。
/// </summary>
public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger, IHostEnvironment environment)
{
private static readonly HashSet<int> 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
};
/// <summary>
/// 中间件入口,捕获并统一处理异常。
/// </summary>
/// <param name="context">HTTP 上下文。</param>
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<object> Response) BuildErrorResponse(Exception exception)
{
return exception switch
{
DbUpdateConcurrencyException => (
StatusCodes.Status409Conflict,
ApiResponse<object>.Error(
ErrorCodes.Conflict,
"数据已被他人修改,请刷新后重试",
new Dictionary<string, string[]>
{
["RowVersion"] = ["数据已被他人修改,请刷新后重试"]
})),
UnauthorizedAccessException => (
StatusCodes.Status403Forbidden,
ApiResponse<object>.Error(ErrorCodes.Forbidden, "无权访问该资源")),
SharedValidationException validationException => (
StatusCodes.Status422UnprocessableEntity,
ApiResponse<object>.Error(ErrorCodes.ValidationFailed, "请求参数验证失败", validationException.Errors)),
FluentValidationException fluentValidationException => (
StatusCodes.Status422UnprocessableEntity,
ApiResponse<object>.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<object>.Error(businessException.ErrorCode, businessException.Message)),
_ => (
StatusCodes.Status500InternalServerError,
ApiResponse<object>.Error(ErrorCodes.InternalServerError, "服务器开小差啦,请稍后再试"))
};
}
private static IDictionary<string, string[]> NormalizeValidationErrors(IEnumerable<ValidationFailure> failures)
{
var result = new Dictionary<string, List<string>>(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<string>();
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);
}
}

View File

@@ -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;
/// <summary>
/// 基础请求日志方法、路径、耗时、状态码、TraceId
/// </summary>
public sealed class RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
{
/// <summary>
/// 记录请求日志并调用后续中间件。
/// </summary>
/// <param name="context">HTTP 上下文。</param>
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);
}
}
}

View File

@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Http;
namespace TakeoutSaaS.Shared.Web.Middleware;
/// <summary>
/// 安全响应头中间件
/// </summary>
public sealed class SecurityHeadersMiddleware(RequestDelegate next)
{
/// <summary>
/// 设置基础安全响应头。
/// </summary>
/// <param name="context">HTTP 上下文。</param>
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);
}
}

View File

@@ -0,0 +1,27 @@
using System.Security.Claims;
namespace TakeoutSaaS.Shared.Web.Security;
/// <summary>
/// ClaimsPrincipal 便捷扩展
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>
/// 获取当前用户 Id不存在时返回 0
/// </summary>
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;
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Shared.Web.Security;
/// <summary>
/// 基于 HttpContext 的当前用户访问器。
/// </summary>
/// <remarks>
/// 初始化访问器。
/// </remarks>
public sealed class HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUserAccessor
{
/// <inheritdoc />
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;
}
}
/// <inheritdoc />
public bool IsAuthenticated => UserId != 0;
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Shared.Web.Security;
/// <summary>
/// HttpContext 租户扩展方法。
/// </summary>
public static class TenantHttpContextExtensions
{
/// <summary>
/// 获取 HttpContext.Items 中缓存的租户上下文。
/// </summary>
/// <param name="context">当前 HttpContext</param>
/// <returns>租户上下文,若不存在则返回 null</returns>
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;
}
}

View File

@@ -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;
/// <summary>
/// 根据 API 版本动态注册 Swagger 文档。
/// </summary>
internal sealed class ConfigureSwaggerOptions(
IApiVersionDescriptionProvider provider,
IOptions<SwaggerDocumentSettings> settings) : IConfigureOptions<SwaggerGenOptions>
{
private readonly SwaggerDocumentSettings _settings = settings.Value;
/// <summary>
/// 根据 API 版本生成 Swagger 文档配置。
/// </summary>
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<string>() }
};
return requirement;
});
}
}
}

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

View File

@@ -0,0 +1,30 @@
<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="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.0" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TakeoutSaaS.Shared.Abstractions\TakeoutSaaS.Shared.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="System.IO.Packaging" Version="10.0.1" />
</ItemGroup>
</Project>