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}";
}
}