using Microsoft.AspNetCore.Identity; using TakeoutSaaS.Application.Identity.Abstractions; using TakeoutSaaS.Application.Identity.Contracts; using TakeoutSaaS.Domain.Identity.Entities; using TakeoutSaaS.Domain.Identity.Enums; using TakeoutSaaS.Domain.Identity.Repositories; using TakeoutSaaS.Shared.Abstractions.Constants; using TakeoutSaaS.Shared.Abstractions.Exceptions; namespace TakeoutSaaS.Application.Identity.Services; /// /// 管理后台认证服务实现。 /// public sealed class AdminAuthService( IIdentityUserRepository userRepository, IUserRoleRepository userRoleRepository, IRoleRepository roleRepository, IPermissionRepository permissionRepository, IRolePermissionRepository rolePermissionRepository, IMenuRepository menuRepository, IPasswordHasher passwordHasher, IJwtTokenService jwtTokenService, IRefreshTokenStore refreshTokenStore) : IAdminAuthService { /// /// 管理后台登录:验证账号密码并生成令牌。 /// /// 登录请求 /// 取消令牌 /// 令牌响应 /// 账号或密码错误时抛出 public async Task LoginAsync(AdminLoginRequest request, CancellationToken cancellationToken = default) { // 1. 标准化输入 var accountName = request.AccountName?.Trim() ?? string.Empty; var phone = request.Phone?.Trim() ?? string.Empty; var password = request.Password; // 2. (空行后) 参数校验 if (string.IsNullOrWhiteSpace(accountName) || string.IsNullOrWhiteSpace(phone) || string.IsNullOrWhiteSpace(password)) { throw new BusinessException(ErrorCodes.BadRequest, "账号名、手机号与密码不能为空"); } // 3. (空行后) 根据账号查找用户 var user = await userRepository.FindByAccountAsync(PortalType.Admin, null, accountName, cancellationToken) ?? throw new BusinessException(ErrorCodes.Unauthorized, "账号名、手机号或密码错误"); // 4. (空行后) 校验手机号匹配 if (!string.Equals(user.Phone?.Trim(), phone, StringComparison.Ordinal)) { throw new BusinessException(ErrorCodes.Unauthorized, "账号名、手机号或密码错误"); } // 5. (空行后) 校验账号状态 var now = DateTime.UtcNow; if (user.Status == IdentityUserStatus.Disabled) { throw new BusinessException(ErrorCodes.Forbidden, "账号已被禁用,请联系管理员"); } if (user.Status == IdentityUserStatus.Locked) { if (user.LockedUntil.HasValue && user.LockedUntil.Value <= now) { await ResetLockedUserAsync(user.Id, cancellationToken); } else { throw new BusinessException(ErrorCodes.Forbidden, "账号已被锁定,请稍后再试"); } } if (user.MustChangePassword) { throw new BusinessException(ErrorCodes.Forbidden, "账号需要重置密码,请通过重置链接设置新密码"); } // 6. (空行后) 验证密码(使用 ASP.NET Core Identity 的密码哈希器) var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password); if (result == PasswordVerificationResult.Failed) { await IncreaseFailedLoginAsync(user.Id, now, cancellationToken); throw new BusinessException(ErrorCodes.Unauthorized, "账号名、手机号或密码错误"); } // 7. (空行后) 更新登录成功状态 await UpdateLoginSuccessAsync(user.Id, now, cancellationToken); // 8. (空行后) 构建用户档案并生成令牌 var profile = await BuildProfileAsync(user, cancellationToken); return await jwtTokenService.CreateTokensAsync(profile, false, 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 userRepository.FindByIdAsync(descriptor.UserId, cancellationToken) ?? throw new BusinessException(ErrorCodes.Unauthorized, "用户不存在"); // 3. 撤销旧刷新令牌(防止重复使用) await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken); // 4. 生成新的令牌对 var profile = await BuildProfileAsync(user, cancellationToken); return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken); } /// /// 获取用户档案。 /// /// 用户 ID /// 取消令牌 /// 用户档案 /// 用户不存在时抛出 public async Task GetProfileAsync(long userId, CancellationToken cancellationToken = default) { var user = await userRepository.FindByIdAsync(userId, cancellationToken) ?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在"); // 1. 返回档案 return await BuildProfileAsync(user, cancellationToken); } /// /// 获取当前用户可见菜单树。 /// /// 用户 ID。 /// 取消令牌。 /// 菜单树。 public async Task> GetMenuTreeAsync(long userId, CancellationToken cancellationToken = default) { // 1. 读取档案以获取权限 var profile = await GetProfileAsync(userId, cancellationToken); // 2. 读取菜单定义 var definitions = await menuRepository.GetByPortalAsync(profile.Portal, cancellationToken); // 3. 生成菜单树 var menu = BuildMenuTree(definitions, profile.Permissions); return menu; } private async Task BuildProfileAsync(IdentityUser user, CancellationToken cancellationToken) { // 1. 解析角色集合 var roles = await ResolveUserRolesAsync(user.Portal, user.TenantId, user.Id, cancellationToken); // 2. (空行后) 解析权限集合 var permissions = await ResolveUserPermissionsAsync(user.Portal, user.TenantId, user.Id, cancellationToken); // 3. (空行后) 组装档案 return new CurrentUserProfile { Portal = user.Portal, UserId = user.Id, Account = user.Account, DisplayName = user.DisplayName, TenantId = user.TenantId, MerchantId = user.MerchantId, Roles = roles, Permissions = permissions, Avatar = user.Avatar }; } private async Task ResetLockedUserAsync(long userId, CancellationToken cancellationToken) { // 1. 获取可更新实体 var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken); if (tracked == null) { return; } // 2. 解除锁定并清空失败次数 tracked.Status = IdentityUserStatus.Active; tracked.LockedUntil = null; tracked.FailedLoginCount = 0; // 3. 保存变更 await userRepository.SaveChangesAsync(cancellationToken); } private async Task IncreaseFailedLoginAsync(long userId, DateTime now, CancellationToken cancellationToken) { // 1. 获取可更新实体 var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken); if (tracked == null) { return; } // 2. 累计失败次数并判断是否需要锁定 tracked.FailedLoginCount += 1; if (tracked.FailedLoginCount >= 5) { tracked.Status = IdentityUserStatus.Locked; tracked.LockedUntil = now.AddMinutes(15); } // 3. 保存变更 await userRepository.SaveChangesAsync(cancellationToken); } private async Task UpdateLoginSuccessAsync(long userId, DateTime now, CancellationToken cancellationToken) { // 1. 获取可更新实体 var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken); if (tracked == null) { return; } // 2. 重置失败次数并刷新登录时间 tracked.FailedLoginCount = 0; tracked.LockedUntil = null; tracked.LastLoginAt = now; if (tracked.Status == IdentityUserStatus.Locked) { tracked.Status = IdentityUserStatus.Active; } // 3. 保存变更 await userRepository.SaveChangesAsync(cancellationToken); } private static bool IsLikelyPhone(string value) { // 1. 空值快速返回 if (string.IsNullOrWhiteSpace(value)) { return false; } // 2. 标准化前缀 var span = value.AsSpan(); if (span[0] == '+') { span = span[1..]; } // 3. 长度校验 if (span.Length < 6 || span.Length > 32) { return false; } // 4. 逐字符校验 foreach (var ch in span) { if (!char.IsDigit(ch)) { return false; } } // 5. 返回校验结果 return true; } private static IReadOnlyList BuildMenuTree( IReadOnlyList definitions, IReadOnlyList permissions) { // 1. 权限集合 var permissionSet = new HashSet(permissions, StringComparer.OrdinalIgnoreCase); // 2. 父子分组 var parentLookup = definitions .GroupBy(x => x.ParentId) .ToDictionary(g => g.Key, g => g.OrderBy(d => d.SortOrder).ToList()); // 3. 递归构造并过滤 IReadOnlyList BuildChildren(long parentId) { if (!parentLookup.TryGetValue(parentId, out var children)) { return []; } // 1. 收集可见节点 var result = new List(); foreach (var def in children) { // 1.1 构造节点 var node = MapMenuNode(def); // 1.2 递归子节点 var sub = BuildChildren(def.Id); // 1.3 可见性(严格使用 RequiredPermissions,不做兜底) var requiredPermissions = node.RequiredPermissions; // 1.4 (空行后) 判断可见性 var isVisible = requiredPermissions.Length == 0 || requiredPermissions.Any(permissionSet.Contains); // 1.5 (空行后) 收集 if (isVisible || sub.Count > 0) { result.Add(node with { Children = sub }); } } // 2. 返回本层节点 return result; } // 4. 返回根节点 return BuildChildren(0); } private static MenuNodeDto MapMenuNode(Domain.Identity.Entities.MenuDefinition definition) { // 1. 解析权限 var requiredPermissions = SplitCodes(definition.RequiredPermissions); var metaPermissions = SplitCodes(definition.MetaPermissions); var metaRoles = SplitCodes(definition.MetaRoles); // 2. 解析按钮权限 var authList = string.IsNullOrWhiteSpace(definition.AuthListJson) ? [] : System.Text.Json.JsonSerializer.Deserialize>(definition.AuthListJson) ?? []; // 3. 构造节点 return new MenuNodeDto { Name = definition.Name, Path = definition.Path, Component = definition.Component, Meta = new MenuMetaDto { Title = definition.Title, Icon = definition.Icon, KeepAlive = definition.KeepAlive, IsIframe = definition.IsIframe, Link = definition.Link, Roles = metaRoles, Permissions = metaPermissions, AuthList = authList }, Children = [], RequiredPermissions = requiredPermissions }; } private static string[] SplitCodes(string? value) { if (string.IsNullOrWhiteSpace(value)) { return Array.Empty(); } // 1. 拆分去重 return value .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); } private async Task ResolveUserRolesAsync(PortalType portal, long? tenantId, long userId, CancellationToken cancellationToken) { var relations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, userId, cancellationToken); var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); if (roleIds.Length == 0) { return Array.Empty(); } // 2. (空行后) 查询角色定义并返回角色码 var roles = await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken); return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } private async Task ResolveUserPermissionsAsync(PortalType portal, long? tenantId, long userId, CancellationToken cancellationToken) { var relations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, userId, cancellationToken); var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); if (roleIds.Length == 0) { return Array.Empty(); } // 2. (空行后) 查询角色权限关联 var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken); var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray(); if (permissionIds.Length == 0) { return Array.Empty(); } // 3. (空行后) 查询权限定义并返回权限码 var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken); return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); } }