All checks were successful
Build and Deploy / deploy (push) Successful in 4m34s
Co-authored-by: Cursor <cursoragent@cursor.com>
954 lines
34 KiB
C#
954 lines
34 KiB
C#
using System.IdentityModel.Tokens.Jwt;
|
||
using System.Security.Claims;
|
||
using Fake.Application;
|
||
using Fake.Domain.Exceptions;
|
||
using Fake.Rbac.Application.Dtos.Auth;
|
||
using Fake.Rbac.Application.Dtos.Menu;
|
||
using Fake.Rbac.Application.Dtos.Role;
|
||
using Fake.Rbac.Application.Dtos.User;
|
||
using Fake.Rbac.Application.Jwt;
|
||
using Fake.Rbac.Domain.Managers;
|
||
using Fake.Rbac.Domain.UserAggregate;
|
||
using Fake.Rbac.Infrastructure;
|
||
using Fake.Security.Claims;
|
||
using Fake.Users;
|
||
using Fake.EntityFrameworkCore;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Caching.Memory;
|
||
using Fake.ObjectStorage;
|
||
using Oms.Application;
|
||
using Oms.Application.Shared;
|
||
using Oms.Application.Storage;
|
||
using Oms.Domain.CustomersAggregate.Entities;
|
||
using Oms.Domain.CustomersAggregate.Enums;
|
||
using Oms.Domain.CustomersAggregate.Repositories;
|
||
using SimpleAdmin.Api.WeChat.Dtos;
|
||
using SimpleAdmin.Api.WeChat.Services;
|
||
using SixLabors.ImageSharp;
|
||
using SixLabors.ImageSharp.Processing;
|
||
|
||
namespace SimpleAdmin.Api.Auth;
|
||
|
||
/// <summary>
|
||
/// 接管 OMS 与 RBAC 共用的认证接口,补充客户登录和业务主体 claims 解析。
|
||
/// </summary>
|
||
[Authorize]
|
||
[ApiExplorerSettings(GroupName = "RBAC")]
|
||
public partial class AuthService(
|
||
AccountManager accountManager,
|
||
IJwtService jwtService,
|
||
IUserRepository userRepository,
|
||
IHttpContextAccessor httpContextAccessor,
|
||
FakeRbacDbContext rbacDbContext,
|
||
ICustomerRepository customerRepository,
|
||
IWeChatLoginSessionStore weChatLoginSessionStore,
|
||
IMemoryCache memoryCache,
|
||
IRbacAuthCache rbacAuthCache,
|
||
IObjectStorage fileStorage)
|
||
: ApplicationService
|
||
{
|
||
private static readonly TimeSpan UserInfoCacheTtl = TimeSpan.FromMinutes(3);
|
||
private static readonly TimeSpan AllMenusCacheTtl = TimeSpan.FromMinutes(5);
|
||
|
||
private Guid CurrentUserGuid => CurrentUser.FindClaimValueOrNull(ClaimTypes.NameIdentifier)!.To<Guid>();
|
||
|
||
/// <summary>
|
||
/// 用户或客户登录。
|
||
/// </summary>
|
||
/// <param name="input">入参:登录类型、账号和密码。</param>
|
||
/// <param name="cancellationToken">取消令牌。</param>
|
||
/// <returns>登录令牌、刷新令牌和用户完整信息(含 menus)。</returns>
|
||
[AllowAnonymous]
|
||
[HttpPost("/rbac/auth/login")]
|
||
public async Task<OmsLoginResultDto> LoginAsync([FromBody] OmsLoginInput input, CancellationToken cancellationToken = default)
|
||
{
|
||
return input.Type switch
|
||
{
|
||
OmsLoginType.User => await LoginAdminAsync(input.Account, input.Password, cancellationToken),
|
||
OmsLoginType.Customer => await LoginCustomerAsync(input.Account, input.Password, cancellationToken),
|
||
_ => throw new BusinessException("不支持的登录类型")
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 管理员代登客户门户(免密)。
|
||
/// </summary>
|
||
/// <param name="input">入参:目标客户主键。</param>
|
||
/// <param name="cancellationToken">取消令牌。</param>
|
||
/// <returns>客户门户访问令牌。</returns>
|
||
[HttpPost("/rbac/auth/customer-oms-login")]
|
||
public async Task<OmsLoginResultDto> CustomerOmsLoginAsync(
|
||
[FromBody] OmsCustomerOmsLoginInput input,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
EnsureAdminPrincipal();
|
||
|
||
var customer = await customerRepository.FirstOrDefaultAsync(x => x.Id == input.CustomerId, cancellationToken);
|
||
if (customer == null)
|
||
{
|
||
throw new BusinessException("客户不存在");
|
||
}
|
||
|
||
if (customer.ParentCustomerId.HasValue)
|
||
{
|
||
ValidateSubCustomerStatus(customer);
|
||
var mainCustomer = await customerRepository.FirstOrDefaultAsync(
|
||
x => x.Id == customer.ParentCustomerId.Value,
|
||
cancellationToken);
|
||
if (mainCustomer == null)
|
||
{
|
||
throw new BusinessException("主客户不存在");
|
||
}
|
||
|
||
ValidateMainCustomerStatus(mainCustomer);
|
||
}
|
||
else
|
||
{
|
||
ValidateMainCustomerStatus(customer);
|
||
}
|
||
|
||
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
|
||
if (!userGuid.HasValue)
|
||
{
|
||
throw new BusinessException("客户未关联用户");
|
||
}
|
||
|
||
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 微信扫码登录轮询状态(key 是否已有绑定账号)。
|
||
/// </summary>
|
||
[AllowAnonymous]
|
||
[HttpGet("/rbac/auth/wx-login/status")]
|
||
public async Task<WxLoginStatusOutput> WxLoginStatusAsync(
|
||
[FromQuery] string loginUuid,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(loginUuid))
|
||
{
|
||
throw new BusinessException("缺少 loginUuid");
|
||
}
|
||
|
||
var session = await weChatLoginSessionStore.GetSessionAsync(loginUuid.Trim(), cancellationToken);
|
||
if (session == null)
|
||
{
|
||
return new WxLoginStatusOutput { Ready = false };
|
||
}
|
||
|
||
return new WxLoginStatusOutput
|
||
{
|
||
Ready = true,
|
||
Account = string.IsNullOrWhiteSpace(session.CustomerCode) ? session.OpenId : session.CustomerCode
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// 微信扫码登录(轮询)。
|
||
/// </summary>
|
||
[AllowAnonymous]
|
||
[HttpPost("/rbac/auth/wx-login")]
|
||
public async Task<OmsLoginResultDto> WxLoginAsync(
|
||
[FromBody] OmsWxLoginInput input,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(input.LoginUuid))
|
||
{
|
||
throw new BusinessException("缺少 loginUuid");
|
||
}
|
||
|
||
var loginKey = input.LoginUuid.Trim();
|
||
var session = await weChatLoginSessionStore.GetSessionAsync(loginKey, cancellationToken);
|
||
if (session == null)
|
||
{
|
||
throw new BusinessException("请扫码登录");
|
||
}
|
||
|
||
var customer = await ResolveCustomerFromWxSessionAsync(session, cancellationToken);
|
||
if (customer == null)
|
||
{
|
||
throw new BusinessException("未绑定微信,请先注册或绑定账号");
|
||
}
|
||
|
||
if (customer.ParentCustomerId.HasValue)
|
||
{
|
||
throw new BusinessException("子客户不支持微信扫码登录");
|
||
}
|
||
|
||
ValidateMainCustomerStatus(customer);
|
||
|
||
customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||
customer.LastLoginTime = DateTime.Now;
|
||
await customerRepository.UpdateAsync(customer, cancellationToken);
|
||
|
||
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
|
||
if (!userGuid.HasValue)
|
||
{
|
||
throw new BusinessException("客户未关联用户");
|
||
}
|
||
|
||
await weChatLoginSessionStore.RemoveAsync(loginKey, cancellationToken);
|
||
|
||
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 微信网页授权登录(H5 内点按钮授权,无需扫码)。
|
||
/// </summary>
|
||
[AllowAnonymous]
|
||
[HttpPost("/rbac/auth/wx-oauth-login")]
|
||
public async Task<OmsLoginResultDto> WxOAuthLoginAsync(
|
||
[FromBody] OmsWxOAuthLoginInput input,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(input.Code))
|
||
{
|
||
throw new BusinessException("缺少微信授权 code");
|
||
}
|
||
|
||
var token = await WeChatMpApiClient.GetOAuthAccessTokenAsync(input.Code.Trim(), cancellationToken);
|
||
if (token == null || string.IsNullOrWhiteSpace(token.OpenId))
|
||
{
|
||
throw new BusinessException("微信授权失败,请重试");
|
||
}
|
||
|
||
var customer = await customerRepository.GetByWechatOpenidAsync(token.OpenId, cancellationToken);
|
||
if (customer == null)
|
||
{
|
||
throw new BusinessException("未绑定微信,请先注册或绑定账号");
|
||
}
|
||
|
||
if (customer.ParentCustomerId.HasValue)
|
||
{
|
||
throw new BusinessException("子客户不支持微信授权登录");
|
||
}
|
||
|
||
ValidateMainCustomerStatus(customer);
|
||
|
||
customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||
customer.LastLoginTime = DateTime.Now;
|
||
await customerRepository.UpdateAsync(customer, cancellationToken);
|
||
|
||
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
|
||
if (!userGuid.HasValue)
|
||
{
|
||
throw new BusinessException("客户未关联用户");
|
||
}
|
||
|
||
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
|
||
}
|
||
|
||
private async Task<Customer?> ResolveCustomerFromWxSessionAsync(
|
||
WeChatLoginSessionPayload session,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (session.CustomerId > 0)
|
||
{
|
||
return await customerRepository.FirstOrDefaultAsync(
|
||
x => x.Id == session.CustomerId,
|
||
cancellationToken: cancellationToken);
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(session.OpenId))
|
||
{
|
||
return await customerRepository.GetByWechatOpenidAsync(session.OpenId, cancellationToken);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 刷新登录令牌。
|
||
/// </summary>
|
||
/// <param name="input">入参:刷新令牌。</param>
|
||
/// <param name="cancellationToken">取消令牌。</param>
|
||
/// <returns>新的访问令牌、刷新令牌和用户基础信息。</returns>
|
||
[AllowAnonymous]
|
||
[HttpPost("/rbac/auth/refresh-token")]
|
||
public async Task<OmsLoginResultDto> RefreshTokenAsync([FromBody] OmsRefreshTokenInput input, CancellationToken cancellationToken = default)
|
||
{
|
||
var refreshToken = input.RefreshToken;
|
||
var userIdText = jwtService.ValidateRefreshToken(refreshToken);
|
||
if (string.IsNullOrWhiteSpace(userIdText))
|
||
{
|
||
throw new BusinessException("无效的刷新令牌");
|
||
}
|
||
|
||
var token = new JwtSecurityTokenHandler().ReadJwtToken(refreshToken);
|
||
var loginType = ParseLoginType(token.Claims);
|
||
|
||
if (loginType == OmsLoginType.Customer)
|
||
{
|
||
return await RefreshCustomerTokenAsync(token.Claims, cancellationToken);
|
||
}
|
||
|
||
if (!Guid.TryParse(userIdText, out var userId))
|
||
{
|
||
throw new BusinessException("无效的刷新令牌");
|
||
}
|
||
|
||
var user = await userRepository.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken: cancellationToken);
|
||
if (user == null)
|
||
{
|
||
throw new BusinessException("用户不存在");
|
||
}
|
||
|
||
// 刷新令牌只发新 token,不重建 UserInfo(前端忽略该字段)
|
||
var claims = ReuseClaimsFromRefreshToken(token.Claims, OmsLoginType.User);
|
||
return BuildTokenOnlyResult(user.Id, claims);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 获取当前登录用户信息。
|
||
/// </summary>
|
||
/// <param name="cancellationToken">取消令牌。</param>
|
||
/// <returns>当前登录用户信息、角色和菜单权限。</returns>
|
||
[HttpGet("/rbac/auth/get-current-user")]
|
||
public async Task<UserInfoDto> GetCurrentUserAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
if (!CurrentUser.IsAuthenticated)
|
||
{
|
||
throw new UnauthorizedAccessException("用户未登录");
|
||
}
|
||
|
||
if (IsCustomerPrincipal())
|
||
{
|
||
return await BuildAdminUserInfoAsync(CurrentUserGuid, cancellationToken);
|
||
}
|
||
|
||
return await BuildAdminUserInfoAsync(CurrentUserGuid, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 修改当前登录账号密码。
|
||
/// </summary>
|
||
/// <param name="input">入参:旧密码和新密码。</param>
|
||
/// <param name="cancellationToken">取消令牌。</param>
|
||
/// <returns>无返回体,仅完成密码修改。</returns>
|
||
[HttpPost("/rbac/auth/change-password")]
|
||
public async Task ChangePasswordAsync([FromBody] OmsChangePasswordInput input, CancellationToken cancellationToken = default)
|
||
{
|
||
if (IsCustomerPrincipal())
|
||
{
|
||
await ChangeCustomerPasswordAsync(input.OldPassword, input.NewPassword, cancellationToken);
|
||
return;
|
||
}
|
||
|
||
await accountManager.UpdatePasswordAsync(CurrentUserGuid, input.OldPassword, input.NewPassword, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 更新当前管理员个人资料。
|
||
/// </summary>
|
||
/// <param name="input">入参:姓名和邮箱等个人资料。</param>
|
||
/// <param name="cancellationToken">取消令牌。</param>
|
||
/// <returns>更新后的当前用户信息。</returns>
|
||
[HttpPut("/rbac/auth/update-profile")]
|
||
public async Task<UserInfoDto> UpdateProfileAsync([FromBody] OmsUpdateProfileInput input, CancellationToken cancellationToken = default)
|
||
{
|
||
EnsureAdminPrincipal();
|
||
|
||
var userId = CurrentUserGuid;
|
||
var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
|
||
user.Update(input.Name, input.Email);
|
||
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
|
||
await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken);
|
||
InvalidateUserInfoCache(userId);
|
||
return await BuildAdminUserInfoAsync(userId, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 上传当前管理员头像。
|
||
/// </summary>
|
||
/// <param name="file">入参:头像文件。</param>
|
||
/// <param name="cancellationToken">取消令牌。</param>
|
||
/// <returns>头像访问路径。</returns>
|
||
[HttpPost("/rbac/auth/upload-avatar")]
|
||
public async Task<string> UploadAvatarAsync(IFormFile file, CancellationToken cancellationToken = default)
|
||
{
|
||
EnsureAdminPrincipal();
|
||
|
||
var userId = CurrentUserGuid;
|
||
if (file == null || file.Length == 0)
|
||
{
|
||
throw new DomainException("请选择要上传的文件");
|
||
}
|
||
|
||
var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".webp" };
|
||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||
if (!allowedExtensions.Contains(extension))
|
||
{
|
||
throw new DomainException("只支持 jpg、jpeg、png、gif、webp 格式的图片");
|
||
}
|
||
|
||
if (file.Length > 10 * 1024 * 1024)
|
||
{
|
||
throw new DomainException("文件大小不能超过10MB");
|
||
}
|
||
|
||
var fileName = $"{userId}_{DateTime.Now:yyyyMMddHHmmss}.jpg";
|
||
var relativeKey = $"uploads/avatars/{fileName}";
|
||
|
||
await using var output = new MemoryStream();
|
||
using (var stream = file.OpenReadStream())
|
||
using (var image = await Image.LoadAsync(stream, cancellationToken))
|
||
{
|
||
var maxSize = 200;
|
||
var width = image.Width;
|
||
var height = image.Height;
|
||
|
||
if (width > maxSize || height > maxSize)
|
||
{
|
||
if (width > height)
|
||
{
|
||
height = (int)(height * ((float)maxSize / width));
|
||
width = maxSize;
|
||
}
|
||
else
|
||
{
|
||
width = (int)(width * ((float)maxSize / height));
|
||
height = maxSize;
|
||
}
|
||
}
|
||
|
||
image.Mutate(x => x.Resize(width, height));
|
||
await image.SaveAsJpegAsync(output, new SixLabors.ImageSharp.Formats.Jpeg.JpegEncoder
|
||
{
|
||
Quality = 80
|
||
}, cancellationToken);
|
||
}
|
||
|
||
output.Position = 0;
|
||
var avatarKey = await fileStorage.SaveObjectAsync(output, relativeKey, "image/jpeg", cancellationToken);
|
||
var user = await userRepository.FirstAsync(u => u.Id == userId, cancellationToken: cancellationToken);
|
||
user.UpdateAvatar(avatarKey);
|
||
await userRepository.UpdateAsync(user, cancellationToken: cancellationToken);
|
||
await UnitOfWorkManager.Current!.SaveChangesAsync(cancellationToken);
|
||
InvalidateUserInfoCache(userId);
|
||
return fileStorage.GetPublicUrl(avatarKey);
|
||
}
|
||
|
||
private async Task<OmsLoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
|
||
{
|
||
var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken);
|
||
var profileRepository = LazyServiceProvider.GetRequiredService<Oms.Application.Rbac.UserProfileRepository>();
|
||
await profileRepository.EnsureEnabledOrThrowAsync(user.Id, cancellationToken);
|
||
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
|
||
}
|
||
|
||
private async Task<OmsLoginResultDto> LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
|
||
{
|
||
var normalizedAccount = account.Trim();
|
||
var customer = await FindCustomerByAccountAsync(normalizedAccount, cancellationToken);
|
||
if (customer == null)
|
||
{
|
||
throw new BusinessException("客户账号或密码错误");
|
||
}
|
||
|
||
if (customer.ParentCustomerId.HasValue)
|
||
{
|
||
ValidateSubCustomerStatus(customer);
|
||
var mainCustomer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value, cancellationToken);
|
||
if (mainCustomer == null)
|
||
{
|
||
throw new BusinessException("主客户不存在");
|
||
}
|
||
|
||
ValidateMainCustomerStatus(mainCustomer);
|
||
}
|
||
else
|
||
{
|
||
ValidateMainCustomerStatus(customer);
|
||
}
|
||
|
||
if (!string.Equals(customer.Password, OmsPasswordHelper.ToMd5(password), StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new BusinessException("客户账号或密码错误");
|
||
}
|
||
|
||
customer.LastLoginIp = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||
customer.LastLoginTime = DateTime.Now;
|
||
await customerRepository.UpdateAsync(customer, cancellationToken);
|
||
|
||
var userGuid = await GetRbacUserGuidByMapIdAsync(customer.UserId, cancellationToken);
|
||
if (!userGuid.HasValue)
|
||
{
|
||
throw new BusinessException("客户未关联用户");
|
||
}
|
||
|
||
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
|
||
}
|
||
|
||
private async Task<OmsLoginResultDto> RefreshCustomerTokenAsync(IEnumerable<Claim> claims, CancellationToken cancellationToken)
|
||
{
|
||
var mapId = ParseIntClaim(claims, SimpleOmsClaimTypes.MapId);
|
||
if (mapId <= 0)
|
||
{
|
||
throw new BusinessException("无效的刷新令牌");
|
||
}
|
||
|
||
var userGuid = await GetRbacUserGuidByMapIdAsync(mapId, cancellationToken);
|
||
if (!userGuid.HasValue)
|
||
{
|
||
throw new BusinessException("用户不存在");
|
||
}
|
||
|
||
var refreshedClaims = ReuseClaimsFromRefreshToken(claims, OmsLoginType.Customer);
|
||
return BuildTokenOnlyResult(userGuid.Value, refreshedClaims);
|
||
}
|
||
|
||
private async Task<UserInfoDto> BuildAdminUserInfoAsync(Guid userId, CancellationToken cancellationToken)
|
||
{
|
||
var cacheKey = rbacAuthCache.UserInfoKey(userId);
|
||
if (memoryCache.TryGetValue(cacheKey, out UserInfoDto? cached) && cached != null)
|
||
{
|
||
return cached;
|
||
}
|
||
|
||
// 一次加载:用户 + 角色 + 权限,避免 Fake UserService 多次 GetWithRoles / GetRolesWithPermissions
|
||
var user = await rbacDbContext.Users
|
||
.AsNoTracking()
|
||
.Include(x => x.Roles)
|
||
.FirstOrDefaultAsync(x => x.Id == userId && !x.IsDeleted, cancellationToken);
|
||
if (user == null)
|
||
{
|
||
throw new BusinessException("用户不存在");
|
||
}
|
||
|
||
var roleIds = user.Roles.Select(x => x.RoleId).Distinct().ToList();
|
||
var roles = roleIds.Count == 0
|
||
? []
|
||
: await rbacDbContext.Roles
|
||
.AsNoTracking()
|
||
.Include(x => x.Permissions)
|
||
.Where(x => roleIds.Contains(x.Id) && !x.IsDeleted)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
string? organizationName = null;
|
||
if (user.OrganizationId.HasValue)
|
||
{
|
||
organizationName = await rbacDbContext.Organizations
|
||
.AsNoTracking()
|
||
.Where(x => x.Id == user.OrganizationId.Value)
|
||
.Select(x => x.Name)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
}
|
||
|
||
var isAdminRole = roles.Any(x =>
|
||
string.Equals(x.Code, "Admin", StringComparison.OrdinalIgnoreCase));
|
||
|
||
List<string> permissions;
|
||
List<MenuTreeDto> menus;
|
||
if (isAdminRole)
|
||
{
|
||
// Admin:默认全菜单/全权限码,无需在角色管理里逐项勾选
|
||
var allMenus = await GetAllActiveMenusCachedAsync(cancellationToken);
|
||
permissions = allMenus
|
||
.Select(x => x.PermissionCode)
|
||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.Select(x => x!)
|
||
.ToList();
|
||
menus = BuildMenuTree(allMenus);
|
||
}
|
||
else
|
||
{
|
||
permissions = roles
|
||
.SelectMany(x => x.Permissions.Select(p => p.PermissionCode))
|
||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
menus = await BuildMenusByPermissionsAsync(permissions, cancellationToken);
|
||
}
|
||
|
||
var dto = new UserInfoDto
|
||
{
|
||
Id = user.Id,
|
||
Name = user.Name,
|
||
Account = user.Account,
|
||
Email = user.Email,
|
||
Avatar = fileStorage.GetPublicUrl(user.Avatar),
|
||
OrganizationId = user.OrganizationId,
|
||
OrganizationName = organizationName,
|
||
Roles = roles
|
||
.Select(x => new RoleSimpleDto
|
||
{
|
||
Id = x.Id,
|
||
Name = x.Name,
|
||
Code = x.Code
|
||
})
|
||
.ToList(),
|
||
Permissions = permissions,
|
||
Menus = menus,
|
||
CreatedAt = user.CreateTime,
|
||
CreatedBy = user.CreateUserId,
|
||
UpdatedAt = user.UpdateTime == default ? null : user.UpdateTime,
|
||
UpdatedBy = user.UpdateUserId
|
||
};
|
||
|
||
memoryCache.Set(
|
||
cacheKey,
|
||
dto,
|
||
new MemoryCacheEntryOptions
|
||
{
|
||
AbsoluteExpirationRelativeToNow = UserInfoCacheTtl
|
||
});
|
||
return dto;
|
||
}
|
||
|
||
private void InvalidateUserInfoCache(Guid userId)
|
||
{
|
||
rbacAuthCache.InvalidateUser(userId);
|
||
}
|
||
|
||
private async Task ChangeCustomerPasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken)
|
||
{
|
||
accountManager.ValidatePasswordStrength(newPassword);
|
||
var context = await GetCurrentCustomerContextAsync(cancellationToken);
|
||
|
||
if (!string.Equals(context.Customer.Password, OmsPasswordHelper.ToMd5(oldPassword), StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
throw new BusinessException("旧密码不正确");
|
||
}
|
||
|
||
context.Customer.Password = OmsPasswordHelper.ToMd5(newPassword);
|
||
await customerRepository.UpdateAsync(context.Customer, cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 登录快照:一次构建 UserInfo,并从中派生 JWT claims,避免再走 Fake GetUserRoles/GetAsync。
|
||
/// </summary>
|
||
private async Task<OmsLoginResultDto> BuildLoginResultAsync(
|
||
Guid userId,
|
||
OmsLoginType loginType,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var userInfo = await BuildAdminUserInfoAsync(userId, cancellationToken);
|
||
var claims = await BuildClaimsFromUserInfoAsync(userId, userInfo, loginType, cancellationToken);
|
||
return BuildTokenResult(userId, claims, userInfo);
|
||
}
|
||
|
||
private OmsLoginResultDto BuildTokenOnlyResult(Guid userId, List<Claim> claims)
|
||
{
|
||
return BuildTokenResult(userId, claims, userInfo: null);
|
||
}
|
||
|
||
private OmsLoginResultDto BuildTokenResult(Guid userId, IReadOnlyCollection<Claim> claims, UserInfoDto? userInfo)
|
||
{
|
||
var accessClaims = CloneClaims(claims);
|
||
var refreshClaims = CloneClaims(claims);
|
||
|
||
return new OmsLoginResultDto
|
||
{
|
||
AccessToken = jwtService.GenerateAccessToken(accessClaims),
|
||
RefreshToken = jwtService.GenerateRefreshToken(refreshClaims),
|
||
TokenType = "Bearer",
|
||
ExpiresIn = jwtService.GetExpiresInSeconds(),
|
||
UserId = userId,
|
||
UserInfo = userInfo
|
||
};
|
||
}
|
||
|
||
private async Task<List<Claim>> BuildClaimsFromUserInfoAsync(
|
||
Guid userId,
|
||
UserInfoDto userInfo,
|
||
OmsLoginType loginType,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var claims = new List<Claim>
|
||
{
|
||
new(FakeClaimTypes.UserName, userInfo.Name ?? string.Empty)
|
||
};
|
||
|
||
foreach (var roleCode in (userInfo.Roles ?? [])
|
||
.Select(x => x.Code)
|
||
.Where(x => !string.IsNullOrWhiteSpace(x)))
|
||
{
|
||
claims.Add(new Claim(FakeClaimTypes.Role, roleCode!));
|
||
}
|
||
|
||
var mapId = await GetMapIdByUserGuidAsync(userId, cancellationToken);
|
||
if (mapId.HasValue)
|
||
{
|
||
claims.Add(new Claim(FakeClaimTypes.UserId, mapId.Value.ToString()));
|
||
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
|
||
}
|
||
else
|
||
{
|
||
claims.Add(new Claim(FakeClaimTypes.UserId, userId.ToString()));
|
||
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
|
||
}
|
||
|
||
ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, ((int)loginType).ToString());
|
||
return claims;
|
||
}
|
||
|
||
private static List<Claim> ReuseClaimsFromRefreshToken(IEnumerable<Claim> source, OmsLoginType loginType)
|
||
{
|
||
var skip = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"jti", "iat", "nbf", "exp", "iss", "aud", "token_type"
|
||
};
|
||
|
||
var claims = source
|
||
.Where(x => !skip.Contains(x.Type))
|
||
.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer))
|
||
.ToList();
|
||
ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, ((int)loginType).ToString());
|
||
return claims;
|
||
}
|
||
|
||
private static List<Claim> CloneClaims(IEnumerable<Claim> claims)
|
||
{
|
||
return claims.Select(x => new Claim(x.Type, x.Value, x.ValueType, x.Issuer, x.OriginalIssuer)).ToList();
|
||
}
|
||
|
||
private bool IsCustomerPrincipal()
|
||
{
|
||
return CurrentUser.FindClaimOrNull(SimpleOmsClaimTypes.LoginType)?.Value == ((int)OmsLoginType.Customer).ToString();
|
||
}
|
||
|
||
private void EnsureAdminPrincipal()
|
||
{
|
||
if (IsCustomerPrincipal())
|
||
{
|
||
throw new BusinessException("当前登录主体暂不支持此操作");
|
||
}
|
||
}
|
||
|
||
private async Task<CustomerPrincipalContext> GetCurrentCustomerContextAsync(CancellationToken cancellationToken)
|
||
{
|
||
var userId = CurrentUser.GetId<int>();
|
||
if (userId <= 0)
|
||
{
|
||
throw new UnauthorizedAccessException("客户未登录");
|
||
}
|
||
|
||
var customer = await customerRepository.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
|
||
if (customer == null)
|
||
{
|
||
throw new BusinessException("客户不存在");
|
||
}
|
||
|
||
if (customer.ParentCustomerId.HasValue)
|
||
{
|
||
ValidateSubCustomerStatus(customer);
|
||
var mainCustomer = await customerRepository.FirstOrDefaultAsync(x => x.Id == customer.ParentCustomerId.Value, cancellationToken);
|
||
if (mainCustomer == null)
|
||
{
|
||
throw new BusinessException("主客户不存在");
|
||
}
|
||
|
||
ValidateMainCustomerStatus(mainCustomer);
|
||
}
|
||
else
|
||
{
|
||
ValidateMainCustomerStatus(customer);
|
||
}
|
||
|
||
return new CustomerPrincipalContext
|
||
{
|
||
PrincipalId = CurrentUserGuid,
|
||
Customer = customer,
|
||
DisplayName = customer.WechatNickname ?? customer.CustomerName,
|
||
AccountCode = customer.CustomerCode,
|
||
Email = customer.Email,
|
||
Avatar = fileStorage.GetPublicUrl(customer.WechatAvatar),
|
||
CreateTime = customer.CreateTime,
|
||
UpdateTime = customer.UpdateTime
|
||
};
|
||
}
|
||
|
||
private async Task<Customer?> FindCustomerByAccountAsync(string account, CancellationToken cancellationToken)
|
||
{
|
||
var customer = await customerRepository.GetByPhoneAsync(account, cancellationToken);
|
||
if (customer != null)
|
||
{
|
||
return customer;
|
||
}
|
||
|
||
customer = await customerRepository.GetByEmailAsync(account, cancellationToken);
|
||
if (customer != null)
|
||
{
|
||
return customer;
|
||
}
|
||
|
||
return await customerRepository.GetByCustomerCodeAsync(account, cancellationToken);
|
||
}
|
||
|
||
private async Task<List<MenuTreeDto>> BuildMenusByPermissionsAsync(IReadOnlyCollection<string> permissionCodes, CancellationToken cancellationToken)
|
||
{
|
||
if (permissionCodes.Count == 0)
|
||
{
|
||
return [];
|
||
}
|
||
|
||
var allMenus = await GetAllActiveMenusCachedAsync(cancellationToken);
|
||
var permissionSet = permissionCodes as HashSet<string>
|
||
?? new HashSet<string>(permissionCodes, StringComparer.OrdinalIgnoreCase);
|
||
|
||
var menuMap = allMenus.ToDictionary(x => x.Id);
|
||
var selectedMenuIds = new HashSet<Guid>(allMenus
|
||
.Where(x => !string.IsNullOrWhiteSpace(x.PermissionCode) && permissionSet.Contains(x.PermissionCode!))
|
||
.Select(x => x.Id));
|
||
|
||
var queue = new Queue<Guid>(selectedMenuIds);
|
||
while (queue.Count > 0)
|
||
{
|
||
var menuId = queue.Dequeue();
|
||
if (!menuMap.TryGetValue(menuId, out var currentMenu))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (currentMenu.PId != Guid.Empty && selectedMenuIds.Add(currentMenu.PId))
|
||
{
|
||
queue.Enqueue(currentMenu.PId);
|
||
}
|
||
}
|
||
|
||
return BuildMenuTree(allMenus.Where(x => selectedMenuIds.Contains(x.Id)));
|
||
}
|
||
|
||
private static List<MenuTreeDto> BuildMenuTree(IEnumerable<Fake.Rbac.Domain.MenuAggregate.Menu> menus)
|
||
{
|
||
var menuDtos = menus
|
||
.OrderBy(x => x.Order)
|
||
.ThenBy(x => x.CreateTime)
|
||
.Select(x => new MenuTreeDto
|
||
{
|
||
Id = x.Id,
|
||
PId = x.PId,
|
||
Name = x.Name,
|
||
PermissionCode = x.PermissionCode,
|
||
Type = x.Type,
|
||
Icon = x.Icon,
|
||
Route = x.Route,
|
||
Component = x.Component,
|
||
Order = x.Order,
|
||
IsHidden = x.IsHidden,
|
||
IsCached = x.IsCached,
|
||
Description = x.Description,
|
||
CreatedAt = x.CreateTime,
|
||
UpdatedAt = x.UpdateTime == default ? null : x.UpdateTime
|
||
})
|
||
.ToList();
|
||
|
||
var dtoMap = menuDtos.ToDictionary(x => x.Id);
|
||
var roots = new List<MenuTreeDto>();
|
||
foreach (var menu in menuDtos)
|
||
{
|
||
if (menu.PId == Guid.Empty || !dtoMap.TryGetValue(menu.PId, out var parentMenu))
|
||
{
|
||
roots.Add(menu);
|
||
continue;
|
||
}
|
||
|
||
parentMenu.Children.Add(menu);
|
||
}
|
||
|
||
return roots.OrderBy(x => x.Order).ToList();
|
||
}
|
||
|
||
private async Task<List<Fake.Rbac.Domain.MenuAggregate.Menu>> GetAllActiveMenusCachedAsync(
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var cacheKey = rbacAuthCache.AllMenusKey();
|
||
if (memoryCache.TryGetValue(cacheKey, out List<Fake.Rbac.Domain.MenuAggregate.Menu>? cached)
|
||
&& cached is { Count: > 0 })
|
||
{
|
||
return cached;
|
||
}
|
||
|
||
var allMenus = await rbacDbContext.Menus
|
||
.AsNoTracking()
|
||
.Where(x => !x.IsDeleted)
|
||
.OrderBy(x => x.Order)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
memoryCache.Set(cacheKey, allMenus, AllMenusCacheTtl);
|
||
return allMenus;
|
||
}
|
||
|
||
private static void ValidateMainCustomerStatus(Customer customer)
|
||
{
|
||
if (customer.Status == CustomerStatus.Disabled)
|
||
{
|
||
throw new BusinessException("客户已禁用");
|
||
}
|
||
|
||
if (customer.Status == CustomerStatus.Frozen)
|
||
{
|
||
throw new BusinessException("客户已冻结");
|
||
}
|
||
}
|
||
|
||
private static void ValidateSubCustomerStatus(Customer customer)
|
||
{
|
||
if (customer.Status == CustomerStatus.Disabled)
|
||
{
|
||
throw new BusinessException("子客户已禁用");
|
||
}
|
||
|
||
if (customer.Status == CustomerStatus.Frozen)
|
||
{
|
||
throw new BusinessException("子客户已冻结");
|
||
}
|
||
}
|
||
|
||
private static OmsLoginType ParseLoginType(IEnumerable<Claim> claims)
|
||
{
|
||
return int.TryParse(claims.FirstOrDefault(x => x.Type == SimpleOmsClaimTypes.LoginType)?.Value, out var loginType) &&
|
||
Enum.IsDefined(typeof(OmsLoginType), loginType)
|
||
? (OmsLoginType)loginType
|
||
: OmsLoginType.User;
|
||
}
|
||
|
||
private async Task<Guid?> GetRbacUserGuidByMapIdAsync(int userId, CancellationToken cancellationToken)
|
||
{
|
||
var sql = $"select `Id` as `Value` from `{FakeTables.User}` where `IsDeleted` = 0 and `userid` = {{0}}";
|
||
return await rbacDbContext.Database.SqlQueryRaw<Guid?>(sql, userId)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
}
|
||
|
||
private async Task<int?> GetMapIdByUserGuidAsync(Guid userId, CancellationToken cancellationToken)
|
||
{
|
||
var sql = $"select `userid` as `Value` from `{FakeTables.User}` where `Id` = {{0}}";
|
||
return await rbacDbContext.Database.SqlQueryRaw<int?>(sql, userId)
|
||
.FirstOrDefaultAsync(cancellationToken);
|
||
}
|
||
|
||
private static int ParseIntClaim(IEnumerable<Claim> claims, string claimType)
|
||
{
|
||
return int.TryParse(claims.FirstOrDefault(x => x.Type == claimType)?.Value, out var value) ? value : 0;
|
||
}
|
||
|
||
private static void ReplaceOrAppendClaim(List<Claim> claims, string claimType, string value)
|
||
{
|
||
claims.RemoveAll(x => x.Type == claimType);
|
||
claims.Add(new Claim(claimType, value));
|
||
}
|
||
|
||
private sealed class CustomerPrincipalContext
|
||
{
|
||
public Guid PrincipalId { get; set; }
|
||
|
||
public required Customer Customer { get; init; }
|
||
|
||
public required string DisplayName { get; init; }
|
||
|
||
public required string AccountCode { get; init; }
|
||
|
||
public string? Email { get; init; }
|
||
|
||
public string? Avatar { get; init; }
|
||
|
||
public DateTime CreateTime { get; init; }
|
||
|
||
public DateTime UpdateTime { get; init; }
|
||
}
|
||
|
||
}
|