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();
}
}