chore: 优化代码注释

This commit is contained in:
2025-11-23 09:52:54 +08:00
parent 1169e1f220
commit ccadacaa9d
33 changed files with 457 additions and 221 deletions

View File

@@ -39,10 +39,10 @@ public sealed class AuthController : BaseApiController
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<ApiResponse<TokenResponse>>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
public async Task<ApiResponse<TokenResponse>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken)
{
var response = await _authService.LoginAsync(request, cancellationToken);
return Ok(ApiResponse<TokenResponse>.Ok(response));
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
@@ -51,10 +51,10 @@ public sealed class AuthController : BaseApiController
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<ApiResponse<TokenResponse>>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
var response = await _authService.RefreshTokenAsync(request, cancellationToken);
return Ok(ApiResponse<TokenResponse>.Ok(response));
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
@@ -63,15 +63,16 @@ public sealed class AuthController : BaseApiController
[HttpGet("profile")]
[PermissionAuthorize("identity:profile:read")]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
public async Task<ActionResult<ApiResponse<CurrentUserProfile>>> GetProfile(CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<CurrentUserProfile>> GetProfile(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (userId == Guid.Empty)
{
return Unauthorized(ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"));
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
}
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
return Ok(ApiResponse<CurrentUserProfile>.Ok(profile));
return ApiResponse<CurrentUserProfile>.Ok(profile);
}
}

View File

@@ -1,3 +1,4 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -20,9 +21,9 @@ public class HealthController : BaseApiController
[HttpGet]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public IActionResult Get()
public ApiResponse<object> Get()
{
var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow };
return Ok(ApiResponse<object>.Ok(payload));
return ApiResponse<object>.Ok(payload);
}
}

View File

@@ -1,7 +1,4 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
@@ -20,6 +17,10 @@ public sealed class AuthController : BaseApiController
{
private readonly IMiniAuthService _authService;
/// <summary>
/// 小程序登录认证
/// </summary>
/// <param name="authService"></param>
public AuthController(IMiniAuthService authService)
{
_authService = authService;
@@ -31,10 +32,10 @@ public sealed class AuthController : BaseApiController
[HttpPost("wechat/login")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<ApiResponse<TokenResponse>>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
public async Task<ApiResponse<TokenResponse>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken)
{
var response = await _authService.LoginWithWeChatAsync(request, cancellationToken);
return Ok(ApiResponse<TokenResponse>.Ok(response));
return ApiResponse<TokenResponse>.Ok(response);
}
/// <summary>
@@ -43,9 +44,9 @@ public sealed class AuthController : BaseApiController
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult<ApiResponse<TokenResponse>>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
public async Task<ApiResponse<TokenResponse>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
var response = await _authService.RefreshTokenAsync(request, cancellationToken);
return Ok(ApiResponse<TokenResponse>.Ok(response));
return ApiResponse<TokenResponse>.Ok(response);
}
}

View File

@@ -1,3 +1,4 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -20,9 +21,9 @@ public class HealthController : BaseApiController
/// <returns>健康状态</returns>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public IActionResult Get()
public ApiResponse<object> Get()
{
var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow };
return Ok(ApiResponse<object>.Ok(payload));
return ApiResponse<object>.Ok(payload);
}
}

View File

@@ -37,15 +37,16 @@ public sealed class MeController : BaseApiController
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status200OK)]
public async Task<ActionResult<ApiResponse<CurrentUserProfile>>> Get(CancellationToken cancellationToken)
[ProducesResponseType(typeof(ApiResponse<CurrentUserProfile>), StatusCodes.Status401Unauthorized)]
public async Task<ApiResponse<CurrentUserProfile>> Get(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
if (userId == Guid.Empty)
{
return Unauthorized(ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"));
return ApiResponse<CurrentUserProfile>.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识");
}
var profile = await _authService.GetProfileAsync(userId, cancellationToken);
return Ok(ApiResponse<CurrentUserProfile>.Ok(profile));
return ApiResponse<CurrentUserProfile>.Ok(profile);
}
}

View File

@@ -1,13 +1,5 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using TakeoutSaaS.Application.Identity.Extensions;
using TakeoutSaaS.Infrastructure.Identity.Extensions;
using TakeoutSaaS.Module.Tenancy;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Web.Extensions;
@@ -15,7 +7,7 @@ using TakeoutSaaS.Shared.Web.Swagger;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, _, configuration) =>
builder.Host.UseSerilog((_, _, configuration) =>
{
configuration
.Enrich.FromLogContext()

View File

@@ -1,3 +1,4 @@
using System;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -20,9 +21,9 @@ public class HealthController : BaseApiController
/// <returns>健康状态</returns>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
public IActionResult Get()
public ApiResponse<object> Get()
{
var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow };
return Ok(ApiResponse<object>.Ok(payload));
return ApiResponse<object>.Ok(payload);
}
}

View File

@@ -1,10 +1,4 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using TakeoutSaaS.Module.Tenancy;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
@@ -13,7 +7,7 @@ using TakeoutSaaS.Shared.Web.Swagger;
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, _, configuration) =>
builder.Host.UseSerilog((_, _, configuration) =>
{
configuration
.Enrich.FromLogContext()

View File

@@ -1,5 +1,3 @@
using System;
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
@@ -7,12 +5,43 @@ namespace TakeoutSaaS.Application.Identity.Contracts;
/// </summary>
public sealed class CurrentUserProfile
{
/// <summary>
/// 用户 ID。
/// </summary>
public Guid UserId { get; init; }
/// <summary>
/// 登录账号。
/// </summary>
public string Account { get; init; } = string.Empty;
/// <summary>
/// 展示名称。
/// </summary>
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// 所属租户 ID。
/// </summary>
public Guid TenantId { get; init; }
/// <summary>
/// 所属商户 ID平台管理员为空
/// </summary>
public Guid? MerchantId { get; init; }
/// <summary>
/// 角色集合。
/// </summary>
public string[] Roles { get; init; } = Array.Empty<string>();
/// <summary>
/// 权限集合。
/// </summary>
public string[] Permissions { get; init; } = Array.Empty<string>();
/// <summary>
/// 头像地址(可选)。
/// </summary>
public string? Avatar { get; init; }
}

View File

@@ -1,5 +1,3 @@
using System;
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
@@ -7,10 +5,33 @@ namespace TakeoutSaaS.Application.Identity.Contracts;
/// </summary>
public class TokenResponse
{
/// <summary>
/// 访问令牌JWT
/// </summary>
public string AccessToken { get; init; } = string.Empty;
/// <summary>
/// 访问令牌过期时间UTC
/// </summary>
public DateTime AccessTokenExpiresAt { get; init; }
/// <summary>
/// 刷新令牌。
/// </summary>
public string RefreshToken { get; init; } = string.Empty;
/// <summary>
/// 刷新令牌过期时间UTC
/// </summary>
public DateTime RefreshTokenExpiresAt { get; init; }
/// <summary>
/// 当前用户档案(可选,首次登录时可能为空)。
/// </summary>
public CurrentUserProfile? User { get; init; }
/// <summary>
/// 是否为新用户(首次登录)。
/// </summary>
public bool IsNewUser { get; init; }
}

View File

@@ -1,11 +1,13 @@
using System;
namespace TakeoutSaaS.Application.Identity.Models;
/// <summary>
/// 刷新令牌描述。
/// 刷新令牌描述:存储刷新令牌的元数据信息
/// </summary>
public sealed record class RefreshTokenDescriptor(
/// <param name="Token">刷新令牌值</param>
/// <param name="UserId">关联的用户 ID</param>
/// <param name="ExpiresAt">过期时间UTC</param>
/// <param name="Revoked">是否已撤销</param>
public sealed record RefreshTokenDescriptor(
string Token,
Guid UserId,
DateTime ExpiresAt,

View File

@@ -1,6 +1,3 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
@@ -14,59 +11,75 @@ namespace TakeoutSaaS.Application.Identity.Services;
/// <summary>
/// 管理后台认证服务实现。
/// </summary>
public sealed class AdminAuthService : IAdminAuthService
public sealed class AdminAuthService(
IIdentityUserRepository userRepository,
IPasswordHasher<IdentityUser> passwordHasher,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore) : IAdminAuthService
{
private readonly IIdentityUserRepository _userRepository;
private readonly IPasswordHasher<IdentityUser> _passwordHasher;
private readonly IJwtTokenService _jwtTokenService;
private readonly IRefreshTokenStore _refreshTokenStore;
public AdminAuthService(
IIdentityUserRepository userRepository,
IPasswordHasher<IdentityUser> passwordHasher,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore)
{
_userRepository = userRepository;
_passwordHasher = passwordHasher;
_jwtTokenService = jwtTokenService;
_refreshTokenStore = refreshTokenStore;
}
/// <summary>
/// 管理后台登录:验证账号密码并生成令牌。
/// </summary>
/// <param name="request">登录请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>令牌响应</returns>
/// <exception cref="BusinessException">账号或密码错误时抛出</exception>
public async Task<TokenResponse> LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
{
var user = await _userRepository.FindByAccountAsync(request.Account, cancellationToken)
// 1. 根据账号查找用户
var user = await userRepository.FindByAccountAsync(request.Account, cancellationToken)
?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
var result = _passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
// 2. 验证密码(使用 ASP.NET Core Identity 的密码哈希器)
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (result == PasswordVerificationResult.Failed)
{
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
}
// 3. 构建用户档案并生成令牌
var profile = BuildProfile(user);
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
/// <summary>
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
/// </summary>
/// <param name="request">刷新令牌请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>新的令牌响应</returns>
/// <exception cref="BusinessException">刷新令牌无效、已过期或用户不存在时抛出</exception>
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
{
var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
// 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销)
var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
{
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
}
var user = await _userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
// 2. 根据用户 ID 查找用户
var user = await userRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
// 3. 撤销旧刷新令牌(防止重复使用)
await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
// 4. 生成新的令牌对
var profile = BuildProfile(user);
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
/// <summary>
/// 获取用户档案。
/// </summary>
/// <param name="userId">用户 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>用户档案</returns>
/// <exception cref="BusinessException">用户不存在时抛出</exception>
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
{
var user = await _userRepository.FindByIdAsync(userId, cancellationToken)
var user = await userRepository.FindByIdAsync(userId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
return BuildProfile(user);

View File

@@ -1,7 +1,4 @@
using System;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
@@ -16,90 +13,117 @@ namespace TakeoutSaaS.Application.Identity.Services;
/// <summary>
/// 小程序认证服务实现。
/// </summary>
public sealed class MiniAuthService : IMiniAuthService
public sealed class MiniAuthService(
IWeChatAuthService weChatAuthService,
IMiniUserRepository miniUserRepository,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
ILoginRateLimiter rateLimiter,
IHttpContextAccessor httpContextAccessor,
ITenantProvider tenantProvider) : IMiniAuthService
{
private readonly IWeChatAuthService _weChatAuthService;
private readonly IMiniUserRepository _miniUserRepository;
private readonly IJwtTokenService _jwtTokenService;
private readonly IRefreshTokenStore _refreshTokenStore;
private readonly ILoginRateLimiter _rateLimiter;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ITenantProvider _tenantProvider;
public MiniAuthService(
IWeChatAuthService weChatAuthService,
IMiniUserRepository miniUserRepository,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
ILoginRateLimiter rateLimiter,
IHttpContextAccessor httpContextAccessor,
ITenantProvider tenantProvider)
{
_weChatAuthService = weChatAuthService;
_miniUserRepository = miniUserRepository;
_jwtTokenService = jwtTokenService;
_refreshTokenStore = refreshTokenStore;
_rateLimiter = rateLimiter;
_httpContextAccessor = httpContextAccessor;
_tenantProvider = tenantProvider;
}
/// <summary>
/// 微信小程序登录:通过微信 code 获取用户信息并生成令牌。
/// </summary>
/// <param name="request">微信登录请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>令牌响应</returns>
/// <exception cref="BusinessException">获取微信用户信息失败、缺少租户标识时抛出</exception>
public async Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default)
{
// 1. 限流检查(基于 IP 地址)
var throttleKey = BuildThrottleKey();
await _rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
await rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
var session = await _weChatAuthService.Code2SessionAsync(request.Code, cancellationToken);
// 2. 通过微信 code 获取 sessionOpenId、UnionId、SessionKey
var session = await weChatAuthService.Code2SessionAsync(request.Code, cancellationToken);
if (string.IsNullOrWhiteSpace(session.OpenId))
{
throw new BusinessException(ErrorCodes.Unauthorized, "获取微信用户信息失败");
}
var tenantId = _tenantProvider.GetCurrentTenantId();
// 3. 获取当前租户 ID多租户支持
var tenantId = tenantProvider.GetCurrentTenantId();
if (tenantId == Guid.Empty)
{
throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识");
}
// 4. 获取或创建小程序用户(如果 OpenId 已存在则返回现有用户,否则创建新用户)
var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken);
await _rateLimiter.ResetAsync(throttleKey, cancellationToken);
// 5. 登录成功后重置限流计数
await rateLimiter.ResetAsync(throttleKey, cancellationToken);
// 6. 构建用户档案并生成令牌
var profile = BuildProfile(user);
return await _jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken);
}
/// <summary>
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
/// </summary>
/// <param name="request">刷新令牌请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>新的令牌响应</returns>
/// <exception cref="BusinessException">刷新令牌无效、已过期或用户不存在时抛出</exception>
public async Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default)
{
var descriptor = await _refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
// 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销)
var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken);
if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked)
{
throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期");
}
var user = await _miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
// 2. 根据用户 ID 查找用户
var user = await miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken)
?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在");
await _refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
// 3. 撤销旧刷新令牌(防止重复使用)
await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
// 4. 生成新的令牌对
var profile = BuildProfile(user);
return await _jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
/// <summary>
/// 获取用户档案。
/// </summary>
/// <param name="userId">用户 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>用户档案</returns>
/// <exception cref="BusinessException">用户不存在时抛出</exception>
public async Task<CurrentUserProfile> GetProfileAsync(Guid userId, CancellationToken cancellationToken = default)
{
var user = await _miniUserRepository.FindByIdAsync(userId, cancellationToken)
var user = await miniUserRepository.FindByIdAsync(userId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
return BuildProfile(user);
}
/// <summary>
/// 获取或绑定小程序用户:如果 OpenId 已存在则返回现有用户,否则创建新用户。
/// </summary>
/// <param name="openId">微信 OpenId</param>
/// <param name="unionId">微信 UnionId可选</param>
/// <param name="nickname">昵称</param>
/// <param name="avatar">头像地址(可选)</param>
/// <param name="tenantId">租户 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>用户实体和是否为新用户的元组</returns>
private async Task<(MiniUser user, bool isNew)> GetOrBindMiniUserAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken)
{
var existing = await _miniUserRepository.FindByOpenIdAsync(openId, cancellationToken);
// 检查用户是否已存在
var existing = await miniUserRepository.FindByOpenIdAsync(openId, cancellationToken);
if (existing != null)
{
return (existing, false);
}
var created = await _miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken);
// 创建新用户
var created = await miniUserRepository.CreateOrUpdateAsync(openId, unionId, nickname, avatar, tenantId, cancellationToken);
return (created, true);
}
@@ -118,7 +142,7 @@ public sealed class MiniAuthService : IMiniAuthService
private string BuildThrottleKey()
{
var ip = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
var ip = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
return $"mini-login:{ip}";
}
}

View File

@@ -1,11 +1,18 @@
namespace TakeoutSaaS.Shared.Abstractions.Entities;
/// <summary>
/// 审计字段接口
/// 审计字段接口:提供创建时间和更新时间字段。
/// </summary>
public interface IAuditableEntity
{
/// <summary>
/// 创建时间UTC
/// </summary>
DateTime CreatedAt { get; set; }
/// <summary>
/// 更新时间UTC未更新时为 null。
/// </summary>
DateTime? UpdatedAt { get; set; }
}

View File

@@ -1,5 +1,3 @@
using System;
namespace TakeoutSaaS.Shared.Abstractions.Exceptions;
/// <summary>

View File

@@ -1,4 +1,3 @@
using System;
using System.Diagnostics;
using TakeoutSaaS.Shared.Abstractions.Diagnostics;
@@ -8,7 +7,7 @@ namespace TakeoutSaaS.Shared.Abstractions.Results;
/// 统一的 API 返回结果包装。
/// </summary>
/// <typeparam name="T">数据载荷类型</typeparam>
public sealed record class ApiResponse<T>
public sealed record ApiResponse<T>
{
/// <summary>
/// 是否成功。
@@ -97,7 +96,7 @@ public sealed record class ApiResponse<T>
{
if (!string.IsNullOrWhiteSpace(TraceContext.TraceId))
{
return TraceContext.TraceId!;
return TraceContext.TraceId;
}
return Activity.Current?.Id ?? Guid.NewGuid().ToString("N");

View File

@@ -1,7 +1,14 @@
namespace TakeoutSaaS.Shared.Abstractions.Tenancy;
/// <summary>
/// 租户提供者接口:用于获取当前请求的租户标识。
/// </summary>
public interface ITenantProvider
{
/// <summary>
/// 获取当前请求的租户 ID。
/// </summary>
/// <returns>租户 ID如果未设置则返回 Guid.Empty</returns>
Guid GetCurrentTenantId();
}

View File

@@ -22,6 +22,7 @@ public static class ServiceCollectionExtensions
.AddControllers(options =>
{
options.Filters.Add<ValidateModelAttribute>();
options.Filters.Add<ApiResponseResultFilter>();
})
.AddNewtonsoftJson();

View File

@@ -0,0 +1,104 @@
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
{
public Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
// 只处理 ObjectResult 类型的结果
if (context.Result is not ObjectResult objectResult)
{
return next();
}
var value = objectResult.Value;
if (value == null)
{
return next();
}
// 检查是否是 ApiResponse<T> 类型
var valueType = value.GetType();
if (!IsApiResponseType(valueType))
{
return next();
}
// 使用反射获取 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);
// 根据 Success 和 Code 设置 HTTP 状态码
var statusCode = success ? MapSuccessCode(code) : MapErrorCode(code);
// 更新 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

@@ -5,15 +5,8 @@ namespace TakeoutSaaS.Shared.Web.Middleware;
/// <summary>
/// 安全响应头中间件
/// </summary>
public sealed class SecurityHeadersMiddleware
public sealed class SecurityHeadersMiddleware(RequestDelegate next)
{
private readonly RequestDelegate _next;
public SecurityHeadersMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var headers = context.Response.Headers;
@@ -21,7 +14,7 @@ public sealed class SecurityHeadersMiddleware
headers["X-Frame-Options"] = "DENY";
headers["X-XSS-Protection"] = "1; mode=block";
headers["Referrer-Policy"] = "no-referrer";
await _next(context);
await next(context);
}
}

View File

@@ -1,4 +1,3 @@
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.DependencyInjection;
@@ -19,7 +18,7 @@ public static class SwaggerExtensions
public static IServiceCollection AddSharedSwagger(this IServiceCollection services, Action<SwaggerDocumentSettings>? configure = null)
{
services.AddSwaggerGen();
services.AddSingleton(provider =>
services.AddSingleton(_ =>
{
var settings = new SwaggerDocumentSettings();
configure?.Invoke(settings);

View File

@@ -1,6 +1,3 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Identity.Entities;
namespace TakeoutSaaS.Domain.Identity.Repositories;
@@ -10,9 +7,31 @@ namespace TakeoutSaaS.Domain.Identity.Repositories;
/// </summary>
public interface IMiniUserRepository
{
/// <summary>
/// 根据微信 OpenId 查找小程序用户。
/// </summary>
/// <param name="openId">微信 OpenId</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>小程序用户,如果不存在则返回 null</returns>
Task<MiniUser?> FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default);
/// <summary>
/// 根据用户 ID 查找小程序用户。
/// </summary>
/// <param name="id">用户 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>小程序用户,如果不存在则返回 null</returns>
Task<MiniUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。
/// </summary>
/// <param name="openId">微信 OpenId</param>
/// <param name="unionId">微信 UnionId可选</param>
/// <param name="nickname">昵称</param>
/// <param name="avatar">头像地址(可选)</param>
/// <param name="tenantId">租户 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>创建或更新后的小程序用户</returns>
Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default);
}

View File

@@ -1,5 +1,3 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.Identity.Options;
@@ -9,22 +7,52 @@ namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// </summary>
public sealed class AdminSeedOptions
{
/// <summary>
/// 初始用户列表。
/// </summary>
public List<SeedUserOptions> Users { get; set; } = new();
}
/// <summary>
/// 种子用户配置:用于初始化管理后台账号。
/// </summary>
public sealed class SeedUserOptions
{
/// <summary>
/// 登录账号。
/// </summary>
[Required]
public string Account { get; set; } = string.Empty;
/// <summary>
/// 登录密码(明文,将在初始化时进行哈希处理)。
/// </summary>
[Required]
public string Password { get; set; } = string.Empty;
/// <summary>
/// 展示名称。
/// </summary>
[Required]
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// 所属租户 ID。
/// </summary>
public Guid TenantId { get; set; }
/// <summary>
/// 所属商户 ID平台管理员为空
/// </summary>
public Guid? MerchantId { get; set; }
/// <summary>
/// 角色集合。
/// </summary>
public string[] Roles { get; set; } = Array.Empty<string>();
/// <summary>
/// 权限集合。
/// </summary>
public string[] Permissions { get; set; } = Array.Empty<string>();
}

View File

@@ -3,23 +3,38 @@ using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// JWT 配置。
/// JWT 配置选项
/// </summary>
public sealed class JwtOptions
{
/// <summary>
/// 令牌颁发者Issuer
/// </summary>
[Required]
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// 令牌受众Audience
/// </summary>
[Required]
public string Audience { get; set; } = string.Empty;
/// <summary>
/// JWT 签名密钥(至少 32 个字符)。
/// </summary>
[Required]
[MinLength(32)]
public string Secret { get; set; } = string.Empty;
/// <summary>
/// 访问令牌过期时间分钟范围5-1440。
/// </summary>
[Range(5, 1440)]
public int AccessTokenExpirationMinutes { get; set; } = 60;
/// <summary>
/// 刷新令牌过期时间分钟范围60-2016014天
/// </summary>
[Range(60, 1440 * 14)]
public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
}

View File

@@ -3,13 +3,19 @@ using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// 登录限流配置。
/// 登录限流配置选项
/// </summary>
public sealed class LoginRateLimitOptions
{
/// <summary>
/// 时间窗口范围1-3600。
/// </summary>
[Range(1, 3600)]
public int WindowSeconds { get; set; } = 60;
/// <summary>
/// 时间窗口内允许的最大尝试次数范围1-100。
/// </summary>
[Range(1, 100)]
public int MaxAttempts { get; set; } = 5;
}

View File

@@ -1,9 +1,12 @@
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// 刷新令牌存储配置。
/// 刷新令牌存储配置选项
/// </summary>
public sealed class RefreshTokenStoreOptions
{
/// <summary>
/// Redis 键前缀,用于存储刷新令牌。
/// </summary>
public string Prefix { get; set; } = "identity:refresh:";
}

View File

@@ -3,13 +3,19 @@ using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// 微信小程序配置。
/// 微信小程序配置选项
/// </summary>
public sealed class WeChatMiniOptions
{
/// <summary>
/// 微信小程序 AppId。
/// </summary>
[Required]
public string AppId { get; set; } = string.Empty;
/// <summary>
/// 微信小程序 AppSecret。
/// </summary>
[Required]
public string Secret { get; set; } = string.Empty;
}

View File

@@ -1,14 +1,9 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Infrastructure.Identity.Options;
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
@@ -17,29 +12,20 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// 后台账号初始化种子任务
/// </summary>
public sealed class IdentityDataSeeder : IHostedService
public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger<IdentityDataSeeder> logger) : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<IdentityDataSeeder> _logger;
public IdentityDataSeeder(IServiceProvider serviceProvider, ILogger<IdentityDataSeeder> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();
using var scope = serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
await context.Database.MigrateAsync(cancellationToken);
if (options.Users == null || options.Users.Count == 0)
if (options.Users is null or { Count: 0 })
{
_logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
return;
}
@@ -64,7 +50,7 @@ public sealed class IdentityDataSeeder : IHostedService
};
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
context.IdentityUsers.Add(user);
_logger.LogInformation("已创建后台账号 {Account}", user.Account);
logger.LogInformation("已创建后台账号 {Account}", user.Account);
}
else
{
@@ -74,7 +60,7 @@ public sealed class IdentityDataSeeder : IHostedService
user.Roles = roles;
user.Permissions = permissions;
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
_logger.LogInformation("已更新后台账号 {Account}", user.Account);
logger.LogInformation("已更新后台账号 {Account}", user.Account);
}
}
@@ -85,10 +71,9 @@ public sealed class IdentityDataSeeder : IHostedService
private static string[] NormalizeValues(string[]? values)
=> values == null
? Array.Empty<string>()
: values
? []
: [.. values
.Where(v => !string.IsNullOrWhiteSpace(v))
.Select(v => v.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
.Distinct(StringComparer.OrdinalIgnoreCase)];
}

View File

@@ -1,4 +1,3 @@
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
@@ -11,12 +10,8 @@ namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// 身份认证 DbContext。
/// </summary>
public sealed class IdentityDbContext : DbContext
public sealed class IdentityDbContext(DbContextOptions<IdentityDbContext> options) : DbContext(options)
{
public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
: base(options)
{
}
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
@@ -37,11 +32,11 @@ public sealed class IdentityDbContext : DbContext
builder.Property(x => x.Avatar).HasMaxLength(256);
var converter = new ValueConverter<string[], string>(
v => string.Join(',', v ?? Array.Empty<string>()),
v => string.Join(',', v),
v => string.IsNullOrWhiteSpace(v) ? Array.Empty<string>() : v.Split(',', StringSplitOptions.RemoveEmptyEntries));
var comparer = new ValueComparer<string[]>(
(l, r) => l!.SequenceEqual(r!),
(l, r) => (l == null && r == null) || (l != null && r != null && Enumerable.SequenceEqual(l, r)),
v => v.Aggregate(0, (current, item) => HashCode.Combine(current, item.GetHashCode())),
v => v.ToArray());

View File

@@ -1,10 +1,6 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using TakeoutSaaS.Application.Identity.Abstractions;
@@ -16,29 +12,33 @@ namespace TakeoutSaaS.Infrastructure.Identity.Services;
/// <summary>
/// JWT 令牌生成器。
/// </summary>
public sealed class JwtTokenService : IJwtTokenService
public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions<JwtOptions> options) : IJwtTokenService
{
private readonly JwtSecurityTokenHandler _tokenHandler = new();
private readonly IRefreshTokenStore _refreshTokenStore;
private readonly JwtOptions _options;
public JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions<JwtOptions> options)
{
_refreshTokenStore = refreshTokenStore;
_options = options.Value;
}
private readonly JwtOptions _options = options.Value;
/// <summary>
/// 创建访问令牌和刷新令牌对。
/// </summary>
/// <param name="profile">用户档案</param>
/// <param name="isNewUser">是否为新用户(首次登录)</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>令牌响应</returns>
public async Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes);
var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes);
// 1. 构建 JWT Claims包含用户 ID、账号、租户 ID、商户 ID、角色、权限等
var claims = BuildClaims(profile);
// 2. 创建签名凭据(使用 HMAC SHA256 算法)
var signingCredentials = new SigningCredentials(
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)),
SecurityAlgorithms.HmacSha256);
// 3. 创建 JWT 安全令牌
var jwt = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
@@ -47,8 +47,11 @@ public sealed class JwtTokenService : IJwtTokenService
expires: accessExpires,
signingCredentials: signingCredentials);
// 4. 序列化 JWT 为字符串
var accessToken = _tokenHandler.WriteToken(jwt);
var refreshDescriptor = await _refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
// 5. 生成刷新令牌并存储到 Redis
var refreshDescriptor = await refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
return new TokenResponse
{
@@ -61,6 +64,11 @@ public sealed class JwtTokenService : IJwtTokenService
};
}
/// <summary>
/// 构建 JWT Claims将用户档案转换为 Claims 集合。
/// </summary>
/// <param name="profile">用户档案</param>
/// <returns>Claims 集合</returns>
private static IEnumerable<Claim> BuildClaims(CurrentUserProfile profile)
{
var userId = profile.UserId.ToString();

View File

@@ -1,6 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Authorization;
using TakeoutSaaS.Module.Authorization.Policies;
@@ -9,7 +6,7 @@ namespace TakeoutSaaS.Module.Authorization.Attributes;
/// <summary>
/// 权限校验特性
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true, Inherited = true)]
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class PermissionAuthorizeAttribute : AuthorizeAttribute
{
public PermissionAuthorizeAttribute(params string[] permissions)

View File

@@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
@@ -10,16 +6,10 @@ namespace TakeoutSaaS.Module.Authorization.Policies;
/// <summary>
/// 权限策略提供者(按需动态构建策略)
/// </summary>
public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider
public sealed class PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options) : DefaultAuthorizationPolicyProvider(options)
{
public const string PolicyPrefix = "PERMISSION:";
private readonly AuthorizationOptions _options;
public PermissionAuthorizationPolicyProvider(IOptions<AuthorizationOptions> options)
: base(options)
{
_options = options.Value;
}
private readonly AuthorizationOptions _options = options.Value;
public override Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
@@ -28,7 +18,7 @@ public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorization
var existingPolicy = _options.GetPolicy(policyName);
if (existingPolicy != null)
{
return Task.FromResult(existingPolicy);
return Task.FromResult<AuthorizationPolicy?>(existingPolicy);
}
var permissions = ParsePermissions(policyName);
@@ -61,9 +51,8 @@ public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorization
}
private static string[] NormalizePermissions(IEnumerable<string> permissions)
=> permissions
=> [.. permissions
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
.Distinct(StringComparer.OrdinalIgnoreCase)];
}

View File

@@ -1,18 +1,14 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authorization;
namespace TakeoutSaaS.Module.Authorization.Policies;
/// <summary>
/// 权限要求
/// 权限要求:用于授权策略中定义所需的权限集合。
/// </summary>
public sealed class PermissionRequirement : IAuthorizationRequirement
public sealed class PermissionRequirement(IReadOnlyCollection<string> permissions) : IAuthorizationRequirement
{
public PermissionRequirement(IReadOnlyCollection<string> permissions)
{
Permissions = permissions ?? throw new ArgumentNullException(nameof(permissions));
}
public IReadOnlyCollection<string> Permissions { get; }
/// <summary>
/// 所需的权限集合。
/// </summary>
public IReadOnlyCollection<string> Permissions { get; } = permissions ?? throw new ArgumentNullException(nameof(permissions));
}