diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs index 04bedfc..164d0c3 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/AuthController.cs @@ -39,10 +39,10 @@ public sealed class AuthController : BaseApiController [HttpPost("login")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task>> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken) + public async Task> Login([FromBody] AdminLoginRequest request, CancellationToken cancellationToken) { var response = await _authService.LoginAsync(request, cancellationToken); - return Ok(ApiResponse.Ok(response)); + return ApiResponse.Ok(response); } /// @@ -51,10 +51,10 @@ public sealed class AuthController : BaseApiController [HttpPost("refresh")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) + public async Task> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) { var response = await _authService.RefreshTokenAsync(request, cancellationToken); - return Ok(ApiResponse.Ok(response)); + return ApiResponse.Ok(response); } /// @@ -63,15 +63,16 @@ public sealed class AuthController : BaseApiController [HttpGet("profile")] [PermissionAuthorize("identity:profile:read")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task>> GetProfile(CancellationToken cancellationToken) + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task> GetProfile(CancellationToken cancellationToken) { var userId = User.GetUserId(); if (userId == Guid.Empty) { - return Unauthorized(ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识")); + return ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); } var profile = await _authService.GetProfileAsync(userId, cancellationToken); - return Ok(ApiResponse.Ok(profile)); + return ApiResponse.Ok(profile); } } diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs index 68ca72d..4db5f17 100644 --- a/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/HealthController.cs @@ -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), StatusCodes.Status200OK)] - public IActionResult Get() + public ApiResponse Get() { var payload = new { status = "OK", service = "AdminApi", time = DateTime.UtcNow }; - return Ok(ApiResponse.Ok(payload)); + return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs index 2c80505..07961a3 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/AuthController.cs @@ -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; + /// + /// 小程序登录认证 + /// + /// public AuthController(IMiniAuthService authService) { _authService = authService; @@ -31,10 +32,10 @@ public sealed class AuthController : BaseApiController [HttpPost("wechat/login")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task>> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken) + public async Task> LoginWithWeChat([FromBody] WeChatLoginRequest request, CancellationToken cancellationToken) { var response = await _authService.LoginWithWeChatAsync(request, cancellationToken); - return Ok(ApiResponse.Ok(response)); + return ApiResponse.Ok(response); } /// @@ -43,9 +44,9 @@ public sealed class AuthController : BaseApiController [HttpPost("refresh")] [AllowAnonymous] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task>> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) + public async Task> RefreshToken([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken) { var response = await _authService.RefreshTokenAsync(request, cancellationToken); - return Ok(ApiResponse.Ok(response)); + return ApiResponse.Ok(response); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs index e915adf..c2673f8 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/HealthController.cs @@ -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] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public IActionResult Get() + public ApiResponse Get() { var payload = new { status = "OK", service = "MiniApi", time = DateTime.UtcNow }; - return Ok(ApiResponse.Ok(payload)); + return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs index 8ccdc14..795116f 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Controllers/MeController.cs @@ -37,15 +37,16 @@ public sealed class MeController : BaseApiController /// [HttpGet] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public async Task>> Get(CancellationToken cancellationToken) + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)] + public async Task> Get(CancellationToken cancellationToken) { var userId = User.GetUserId(); if (userId == Guid.Empty) { - return Unauthorized(ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识")); + return ApiResponse.Error(ErrorCodes.Unauthorized, "Token 缺少有效的用户标识"); } var profile = await _authService.GetProfileAsync(userId, cancellationToken); - return Ok(ApiResponse.Ok(profile)); + return ApiResponse.Ok(profile); } } diff --git a/src/Api/TakeoutSaaS.MiniApi/Program.cs b/src/Api/TakeoutSaaS.MiniApi/Program.cs index ccdb0fd..fac60c8 100644 --- a/src/Api/TakeoutSaaS.MiniApi/Program.cs +++ b/src/Api/TakeoutSaaS.MiniApi/Program.cs @@ -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() diff --git a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs index 1648b83..e1fa6d5 100644 --- a/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs +++ b/src/Api/TakeoutSaaS.UserApi/Controllers/HealthController.cs @@ -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] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] - public IActionResult Get() + public ApiResponse Get() { var payload = new { status = "OK", service = "UserApi", time = DateTime.UtcNow }; - return Ok(ApiResponse.Ok(payload)); + return ApiResponse.Ok(payload); } } diff --git a/src/Api/TakeoutSaaS.UserApi/Program.cs b/src/Api/TakeoutSaaS.UserApi/Program.cs index fc30e4f..d43a488 100644 --- a/src/Api/TakeoutSaaS.UserApi/Program.cs +++ b/src/Api/TakeoutSaaS.UserApi/Program.cs @@ -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() diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs index be4eb44..e93e2bc 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/CurrentUserProfile.cs @@ -1,5 +1,3 @@ -using System; - namespace TakeoutSaaS.Application.Identity.Contracts; /// @@ -7,12 +5,43 @@ namespace TakeoutSaaS.Application.Identity.Contracts; /// public sealed class CurrentUserProfile { + /// + /// 用户 ID。 + /// public Guid UserId { get; init; } + + /// + /// 登录账号。 + /// public string Account { get; init; } = string.Empty; + + /// + /// 展示名称。 + /// public string DisplayName { get; init; } = string.Empty; + + /// + /// 所属租户 ID。 + /// public Guid TenantId { get; init; } + + /// + /// 所属商户 ID(平台管理员为空)。 + /// public Guid? MerchantId { get; init; } + + /// + /// 角色集合。 + /// public string[] Roles { get; init; } = Array.Empty(); + + /// + /// 权限集合。 + /// public string[] Permissions { get; init; } = Array.Empty(); + + /// + /// 头像地址(可选)。 + /// public string? Avatar { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs index 57b3ded..716364e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/TokenResponse.cs @@ -1,5 +1,3 @@ -using System; - namespace TakeoutSaaS.Application.Identity.Contracts; /// @@ -7,10 +5,33 @@ namespace TakeoutSaaS.Application.Identity.Contracts; /// public class TokenResponse { + /// + /// 访问令牌(JWT)。 + /// public string AccessToken { get; init; } = string.Empty; + + /// + /// 访问令牌过期时间(UTC)。 + /// public DateTime AccessTokenExpiresAt { get; init; } + + /// + /// 刷新令牌。 + /// public string RefreshToken { get; init; } = string.Empty; + + /// + /// 刷新令牌过期时间(UTC)。 + /// public DateTime RefreshTokenExpiresAt { get; init; } + + /// + /// 当前用户档案(可选,首次登录时可能为空)。 + /// public CurrentUserProfile? User { get; init; } + + /// + /// 是否为新用户(首次登录)。 + /// public bool IsNewUser { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs b/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs index f508c3e..68e07e6 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Models/RefreshTokenDescriptor.cs @@ -1,11 +1,13 @@ -using System; - namespace TakeoutSaaS.Application.Identity.Models; /// -/// 刷新令牌描述。 +/// 刷新令牌描述:存储刷新令牌的元数据信息。 /// -public sealed record class RefreshTokenDescriptor( +/// 刷新令牌值 +/// 关联的用户 ID +/// 过期时间(UTC) +/// 是否已撤销 +public sealed record RefreshTokenDescriptor( string Token, Guid UserId, DateTime ExpiresAt, diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs index 477e53f..ee0fbeb 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -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; /// /// 管理后台认证服务实现。 /// -public sealed class AdminAuthService : IAdminAuthService +public sealed class AdminAuthService( + IIdentityUserRepository userRepository, + IPasswordHasher passwordHasher, + IJwtTokenService jwtTokenService, + IRefreshTokenStore refreshTokenStore) : IAdminAuthService { - private readonly IIdentityUserRepository _userRepository; - private readonly IPasswordHasher _passwordHasher; - private readonly IJwtTokenService _jwtTokenService; - private readonly IRefreshTokenStore _refreshTokenStore; - - public AdminAuthService( - IIdentityUserRepository userRepository, - IPasswordHasher passwordHasher, - IJwtTokenService jwtTokenService, - IRefreshTokenStore refreshTokenStore) - { - _userRepository = userRepository; - _passwordHasher = passwordHasher; - _jwtTokenService = jwtTokenService; - _refreshTokenStore = refreshTokenStore; - } - + /// + /// 管理后台登录:验证账号密码并生成令牌。 + /// + /// 登录请求 + /// 取消令牌 + /// 令牌响应 + /// 账号或密码错误时抛出 public async Task 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); } + /// + /// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。 + /// + /// 刷新令牌请求 + /// 取消令牌 + /// 新的令牌响应 + /// 刷新令牌无效、已过期或用户不存在时抛出 public async Task 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); } + /// + /// 获取用户档案。 + /// + /// 用户 ID + /// 取消令牌 + /// 用户档案 + /// 用户不存在时抛出 public async Task 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); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs index 0d27c61..25efbf9 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/MiniAuthService.cs @@ -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; /// /// 小程序认证服务实现。 /// -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; - } - + /// + /// 微信小程序登录:通过微信 code 获取用户信息并生成令牌。 + /// + /// 微信登录请求 + /// 取消令牌 + /// 令牌响应 + /// 获取微信用户信息失败、缺少租户标识时抛出 public async Task 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 获取 session(OpenId、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); } + /// + /// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。 + /// + /// 刷新令牌请求 + /// 取消令牌 + /// 新的令牌响应 + /// 刷新令牌无效、已过期或用户不存在时抛出 public async Task 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); } + /// + /// 获取用户档案。 + /// + /// 用户 ID + /// 取消令牌 + /// 用户档案 + /// 用户不存在时抛出 public async Task 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); } + /// + /// 获取或绑定小程序用户:如果 OpenId 已存在则返回现有用户,否则创建新用户。 + /// + /// 微信 OpenId + /// 微信 UnionId(可选) + /// 昵称 + /// 头像地址(可选) + /// 租户 ID + /// 取消令牌 + /// 用户实体和是否为新用户的元组 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}"; } } diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs index fe612a2..aa6a7cd 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Entities/IAuditableEntity.cs @@ -1,11 +1,18 @@ namespace TakeoutSaaS.Shared.Abstractions.Entities; /// -/// 审计字段接口 +/// 审计字段接口:提供创建时间和更新时间字段。 /// public interface IAuditableEntity { + /// + /// 创建时间(UTC)。 + /// DateTime CreatedAt { get; set; } + + /// + /// 更新时间(UTC),未更新时为 null。 + /// DateTime? UpdatedAt { get; set; } } diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs index 60d793a..c270130 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Exceptions/BusinessException.cs @@ -1,5 +1,3 @@ -using System; - namespace TakeoutSaaS.Shared.Abstractions.Exceptions; /// diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs index 6669468..50401a7 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Results/ApiResponse.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics; using TakeoutSaaS.Shared.Abstractions.Diagnostics; @@ -8,7 +7,7 @@ namespace TakeoutSaaS.Shared.Abstractions.Results; /// 统一的 API 返回结果包装。 /// /// 数据载荷类型 -public sealed record class ApiResponse +public sealed record ApiResponse { /// /// 是否成功。 @@ -97,7 +96,7 @@ public sealed record class ApiResponse { if (!string.IsNullOrWhiteSpace(TraceContext.TraceId)) { - return TraceContext.TraceId!; + return TraceContext.TraceId; } return Activity.Current?.Id ?? Guid.NewGuid().ToString("N"); diff --git a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs index 4af8592..8782625 100644 --- a/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs +++ b/src/Core/TakeoutSaaS.Shared.Abstractions/Tenancy/ITenantProvider.cs @@ -1,7 +1,14 @@ namespace TakeoutSaaS.Shared.Abstractions.Tenancy; +/// +/// 租户提供者接口:用于获取当前请求的租户标识。 +/// public interface ITenantProvider { + /// + /// 获取当前请求的租户 ID。 + /// + /// 租户 ID,如果未设置则返回 Guid.Empty Guid GetCurrentTenantId(); } diff --git a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs index 781364d..edc70d8 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Extensions/ServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ public static class ServiceCollectionExtensions .AddControllers(options => { options.Filters.Add(); + options.Filters.Add(); }) .AddNewtonsoftJson(); 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..4877e1e --- /dev/null +++ b/src/Core/TakeoutSaaS.Shared.Web/Filters/ApiResponseResultFilter.cs @@ -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; + +/// +/// ApiResponse 结果过滤器:自动将 ApiResponse 转换为对应的 HTTP 状态码。 +/// 使用此过滤器后,控制器可以直接返回 ApiResponse<T>,无需再包一层 Ok() 或 Unauthorized()。 +/// +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 类型 + 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 类型 + 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/Middleware/SecurityHeadersMiddleware.cs b/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs index 7bb1b0c..febf3dc 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Middleware/SecurityHeadersMiddleware.cs @@ -5,15 +5,8 @@ namespace TakeoutSaaS.Shared.Web.Middleware; /// /// 安全响应头中间件 /// -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); } } diff --git a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs index c192adb..8b1fb25 100644 --- a/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs +++ b/src/Core/TakeoutSaaS.Shared.Web/Swagger/SwaggerExtensions.cs @@ -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? configure = null) { services.AddSwaggerGen(); - services.AddSingleton(provider => + services.AddSingleton(_ => { var settings = new SwaggerDocumentSettings(); configure?.Invoke(settings); diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs index db3e810..4688ea6 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IMiniUserRepository.cs @@ -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; /// public interface IMiniUserRepository { + /// + /// 根据微信 OpenId 查找小程序用户。 + /// + /// 微信 OpenId + /// 取消令牌 + /// 小程序用户,如果不存在则返回 null Task FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default); + /// + /// 根据用户 ID 查找小程序用户。 + /// + /// 用户 ID + /// 取消令牌 + /// 小程序用户,如果不存在则返回 null Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + /// + /// 创建或更新小程序用户(如果 OpenId 已存在则更新,否则创建)。 + /// + /// 微信 OpenId + /// 微信 UnionId(可选) + /// 昵称 + /// 头像地址(可选) + /// 租户 ID + /// 取消令牌 + /// 创建或更新后的小程序用户 Task CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, Guid tenantId, CancellationToken cancellationToken = default); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs index d1dd243..575f1b0 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/AdminSeedOptions.cs @@ -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; /// public sealed class AdminSeedOptions { + /// + /// 初始用户列表。 + /// public List Users { get; set; } = new(); } +/// +/// 种子用户配置:用于初始化管理后台账号。 +/// public sealed class SeedUserOptions { + /// + /// 登录账号。 + /// [Required] public string Account { get; set; } = string.Empty; + /// + /// 登录密码(明文,将在初始化时进行哈希处理)。 + /// [Required] public string Password { get; set; } = string.Empty; + /// + /// 展示名称。 + /// [Required] public string DisplayName { get; set; } = string.Empty; + /// + /// 所属租户 ID。 + /// public Guid TenantId { get; set; } + + /// + /// 所属商户 ID(平台管理员为空)。 + /// public Guid? MerchantId { get; set; } + + /// + /// 角色集合。 + /// public string[] Roles { get; set; } = Array.Empty(); + + /// + /// 权限集合。 + /// public string[] Permissions { get; set; } = Array.Empty(); } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs index 28e1052..18aed1d 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/JwtOptions.cs @@ -3,23 +3,38 @@ using System.ComponentModel.DataAnnotations; namespace TakeoutSaaS.Infrastructure.Identity.Options; /// -/// JWT 配置。 +/// JWT 配置选项。 /// public sealed class JwtOptions { + /// + /// 令牌颁发者(Issuer)。 + /// [Required] public string Issuer { get; set; } = string.Empty; + /// + /// 令牌受众(Audience)。 + /// [Required] public string Audience { get; set; } = string.Empty; + /// + /// JWT 签名密钥(至少 32 个字符)。 + /// [Required] [MinLength(32)] public string Secret { get; set; } = string.Empty; + /// + /// 访问令牌过期时间(分钟),范围:5-1440。 + /// [Range(5, 1440)] public int AccessTokenExpirationMinutes { get; set; } = 60; + /// + /// 刷新令牌过期时间(分钟),范围:60-20160(14天)。 + /// [Range(60, 1440 * 14)] public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7; } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs index a5fb4f1..d9f7a42 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/LoginRateLimitOptions.cs @@ -3,13 +3,19 @@ using System.ComponentModel.DataAnnotations; namespace TakeoutSaaS.Infrastructure.Identity.Options; /// -/// 登录限流配置。 +/// 登录限流配置选项。 /// public sealed class LoginRateLimitOptions { + /// + /// 时间窗口(秒),范围:1-3600。 + /// [Range(1, 3600)] public int WindowSeconds { get; set; } = 60; + /// + /// 时间窗口内允许的最大尝试次数,范围:1-100。 + /// [Range(1, 100)] public int MaxAttempts { get; set; } = 5; } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs index ab69c3d..fcbf6e8 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/RefreshTokenStoreOptions.cs @@ -1,9 +1,12 @@ namespace TakeoutSaaS.Infrastructure.Identity.Options; /// -/// 刷新令牌存储配置。 +/// 刷新令牌存储配置选项。 /// public sealed class RefreshTokenStoreOptions { + /// + /// Redis 键前缀,用于存储刷新令牌。 + /// public string Prefix { get; set; } = "identity:refresh:"; } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs index e30d274..0dee3be 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Options/WeChatMiniOptions.cs @@ -3,13 +3,19 @@ using System.ComponentModel.DataAnnotations; namespace TakeoutSaaS.Infrastructure.Identity.Options; /// -/// 微信小程序配置。 +/// 微信小程序配置选项。 /// public sealed class WeChatMiniOptions { + /// + /// 微信小程序 AppId。 + /// [Required] public string AppId { get; set; } = string.Empty; + /// + /// 微信小程序 AppSecret。 + /// [Required] public string Secret { get; set; } = string.Empty; } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs index 80327ac..57d88b6 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDataSeeder.cs @@ -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; /// /// 后台账号初始化种子任务 /// -public sealed class IdentityDataSeeder : IHostedService +public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger logger) : IHostedService { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public IdentityDataSeeder(IServiceProvider serviceProvider, ILogger 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(); var options = scope.ServiceProvider.GetRequiredService>().Value; var passwordHasher = scope.ServiceProvider.GetRequiredService>(); 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() - : values + ? [] + : [.. values .Where(v => !string.IsNullOrWhiteSpace(v)) .Select(v => v.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + .Distinct(StringComparer.OrdinalIgnoreCase)]; } diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index 6168f7e..9887201 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -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; /// /// 身份认证 DbContext。 /// -public sealed class IdentityDbContext : DbContext +public sealed class IdentityDbContext(DbContextOptions options) : DbContext(options) { - public IdentityDbContext(DbContextOptions options) - : base(options) - { - } public DbSet IdentityUsers => Set(); public DbSet MiniUsers => Set(); @@ -37,11 +32,11 @@ public sealed class IdentityDbContext : DbContext builder.Property(x => x.Avatar).HasMaxLength(256); var converter = new ValueConverter( - v => string.Join(',', v ?? Array.Empty()), + v => string.Join(',', v), v => string.IsNullOrWhiteSpace(v) ? Array.Empty() : v.Split(',', StringSplitOptions.RemoveEmptyEntries)); var comparer = new ValueComparer( - (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()); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs index fa728ac..cf9c8e9 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Services/JwtTokenService.cs @@ -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; /// /// JWT 令牌生成器。 /// -public sealed class JwtTokenService : IJwtTokenService +public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions options) : IJwtTokenService { private readonly JwtSecurityTokenHandler _tokenHandler = new(); - private readonly IRefreshTokenStore _refreshTokenStore; - private readonly JwtOptions _options; - - public JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions options) - { - _refreshTokenStore = refreshTokenStore; - _options = options.Value; - } + private readonly JwtOptions _options = options.Value; + /// + /// 创建访问令牌和刷新令牌对。 + /// + /// 用户档案 + /// 是否为新用户(首次登录) + /// 取消令牌 + /// 令牌响应 public async Task 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 }; } + /// + /// 构建 JWT Claims:将用户档案转换为 Claims 集合。 + /// + /// 用户档案 + /// Claims 集合 private static IEnumerable BuildClaims(CurrentUserProfile profile) { var userId = profile.UserId.ToString(); diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs index b55f602..78a16e9 100644 --- a/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Attributes/PermissionAuthorizeAttribute.cs @@ -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; /// /// 权限校验特性 /// -[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) diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs index daaa3f0..4f2e610 100644 --- a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionAuthorizationPolicyProvider.cs @@ -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; /// /// 权限策略提供者(按需动态构建策略) /// -public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorizationPolicyProvider +public sealed class PermissionAuthorizationPolicyProvider(IOptions options) : DefaultAuthorizationPolicyProvider(options) { public const string PolicyPrefix = "PERMISSION:"; - private readonly AuthorizationOptions _options; - - public PermissionAuthorizationPolicyProvider(IOptions options) - : base(options) - { - _options = options.Value; - } + private readonly AuthorizationOptions _options = options.Value; public override Task 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(existingPolicy); } var permissions = ParsePermissions(policyName); @@ -61,9 +51,8 @@ public sealed class PermissionAuthorizationPolicyProvider : DefaultAuthorization } private static string[] NormalizePermissions(IEnumerable permissions) - => permissions + => [.. permissions .Where(p => !string.IsNullOrWhiteSpace(p)) .Select(p => p.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); + .Distinct(StringComparer.OrdinalIgnoreCase)]; } diff --git a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs index 2ed0421..b188ec0 100644 --- a/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs +++ b/src/Modules/TakeoutSaaS.Module.Authorization/Policies/PermissionRequirement.cs @@ -1,18 +1,14 @@ -using System; -using System.Collections.Generic; using Microsoft.AspNetCore.Authorization; namespace TakeoutSaaS.Module.Authorization.Policies; /// -/// 权限要求 +/// 权限要求:用于授权策略中定义所需的权限集合。 /// -public sealed class PermissionRequirement : IAuthorizationRequirement +public sealed class PermissionRequirement(IReadOnlyCollection permissions) : IAuthorizationRequirement { - public PermissionRequirement(IReadOnlyCollection permissions) - { - Permissions = permissions ?? throw new ArgumentNullException(nameof(permissions)); - } - - public IReadOnlyCollection Permissions { get; } + /// + /// 所需的权限集合。 + /// + public IReadOnlyCollection Permissions { get; } = permissions ?? throw new ArgumentNullException(nameof(permissions)); }