using Microsoft.AspNetCore.Http; using System.Net; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; using TakeoutSaaS.Shared.Abstractions.Tenancy; namespace TakeoutSaaS.Application.Identity.Services; /// /// 小程序认证服务实现。 /// public sealed class MiniAuthService( IWeChatAuthService weChatAuthService, IMiniUserRepository miniUserRepository, IJwtTokenService jwtTokenService, IRefreshTokenStore refreshTokenStore, ILoginRateLimiter rateLimiter, IHttpContextAccessor httpContextAccessor, ITenantProvider tenantProvider) : IMiniAuthService { /// /// 微信小程序登录:通过微信 code 获取用户信息并生成令牌。 /// /// 微信登录请求 /// 取消令牌 /// 令牌响应 /// 获取微信用户信息失败、缺少租户标识时抛出 public async Task LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default) { // 1. 限流检查(基于 IP 地址) var throttleKey = BuildThrottleKey(); await rateLimiter.EnsureAllowedAsync(throttleKey, 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, "获取微信用户信息失败"); } // 3. 获取当前租户 ID(多租户支持) var tenantId = tenantProvider.GetCurrentTenantId(); if (tenantId == 0) { throw new BusinessException(ErrorCodes.BadRequest, "缺少租户标识"); } // 4. 获取或创建小程序用户(如果 OpenId 已存在则返回现有用户,否则创建新用户) var (user, isNew) = await GetOrBindMiniUserAsync(session.OpenId, session.UnionId, request.Nickname, request.Avatar, tenantId, cancellationToken); // 5. 登录成功后重置限流计数 await rateLimiter.ResetAsync(throttleKey, cancellationToken); // 6. 构建用户档案并生成令牌 var profile = BuildProfile(user); return await jwtTokenService.CreateTokensAsync(profile, isNew, cancellationToken); } /// /// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。 /// /// 刷新令牌请求 /// 取消令牌 /// 新的令牌响应 /// 刷新令牌无效、已过期或用户不存在时抛出 public async Task RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default) { // 1. 验证刷新令牌(检查是否存在、是否过期、是否已撤销) var descriptor = await refreshTokenStore.GetAsync(request.RefreshToken, cancellationToken); if (descriptor == null || descriptor.ExpiresAt <= DateTime.UtcNow || descriptor.Revoked) { throw new BusinessException(ErrorCodes.Unauthorized, "RefreshToken 无效或已过期"); } // 2. 根据用户 ID 查找用户 var user = await miniUserRepository.FindByIdAsync(descriptor.UserId, cancellationToken) ?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在"); // 3. 撤销旧刷新令牌(防止重复使用) await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken); // 4. 生成新的令牌对 var profile = BuildProfile(user); return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); } /// /// 获取用户档案。 /// /// 用户 ID /// 取消令牌 /// 用户档案 /// 用户不存在时抛出 public async Task GetProfileAsync(long userId, CancellationToken cancellationToken = default) { 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, long tenantId, CancellationToken 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); return (created, true); } private static CurrentUserProfile BuildProfile(MiniUser user) => new() { UserId = user.Id, Account = user.OpenId, DisplayName = user.Nickname, TenantId = user.TenantId, MerchantId = null, Roles = Array.Empty(), Permissions = Array.Empty(), Avatar = user.Avatar }; private string BuildThrottleKey() { var ip = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback; return $"mini-login:{ip}"; } }