perf: 登录单次快照、refresh 去 UserInfo,并失效角色菜单缓存

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-18 23:39:29 +08:00
parent b0813ea12d
commit 2b9c677fa7
5 changed files with 235 additions and 53 deletions

View File

@ -4,12 +4,13 @@ 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.Application.Services;
using Fake.Rbac.Domain.Managers;
using Fake.Rbac.Domain.UserAggregate;
using Fake.Rbac.Infrastructure;
using Fake.Security.Claims;
using Fake.Users;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -33,7 +34,6 @@ namespace SimpleAdmin.Api.Auth;
[ApiExplorerSettings(GroupName = "RBAC")]
public partial class AuthService(
AccountManager accountManager,
UserService userService,
IJwtService jwtService,
IUserRepository userRepository,
IWebHostEnvironment webHostEnvironment,
@ -41,16 +41,15 @@ public partial class AuthService(
FakeRbacDbContext rbacDbContext,
ICustomerRepository customerRepository,
IWeChatLoginSessionStore weChatLoginSessionStore,
IMemoryCache memoryCache)
IMemoryCache memoryCache,
IRbacAuthCache rbacAuthCache)
: ApplicationService
{
private const string AllMenusCacheKey = "rbac:menus:all:v1";
private const string UserInfoCacheKeyPrefix = "rbac:userinfo:v2:";
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>();
private static string UserInfoCacheKey(Guid userId) => UserInfoCacheKeyPrefix + userId;
/// <summary>
/// 用户或客户登录。
/// </summary>
@ -112,8 +111,7 @@ public partial class AuthService(
throw new BusinessException("客户未关联用户");
}
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return await BuildLoginResultAsync(userGuid.Value, claims, cancellationToken);
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
}
/// <summary>
@ -189,8 +187,7 @@ public partial class AuthService(
await weChatLoginSessionStore.RemoveAsync(loginKey, cancellationToken);
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return await BuildLoginResultAsync(userGuid.Value, claims, cancellationToken);
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
}
private async Task<Customer?> ResolveCustomerFromWxSessionAsync(
@ -248,8 +245,9 @@ public partial class AuthService(
throw new BusinessException("用户不存在");
}
var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken);
return await BuildLoginResultAsync(user.Id, claims, cancellationToken);
// 刷新令牌只发新 token,不重建 UserInfo(前端忽略该字段)
var claims = ReuseClaimsFromRefreshToken(token.Claims, OmsLoginType.User);
return BuildTokenOnlyResult(user.Id, claims);
}
/// <summary>
@ -386,8 +384,7 @@ public partial class AuthService(
private async Task<OmsLoginResultDto> LoginAdminAsync(string account, string password, CancellationToken cancellationToken)
{
var user = await accountManager.ValidateCredentialsAsync(account, password, cancellationToken);
var claims = await BuildAdminClaimsAsync(user.Id, cancellationToken);
return await BuildLoginResultAsync(user.Id, claims, cancellationToken);
return await BuildLoginResultAsync(user.Id, OmsLoginType.User, cancellationToken);
}
private async Task<OmsLoginResultDto> LoginCustomerAsync(string account, string password, CancellationToken cancellationToken)
@ -430,8 +427,7 @@ public partial class AuthService(
throw new BusinessException("客户未关联用户");
}
var claims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return await BuildLoginResultAsync(userGuid.Value, claims, cancellationToken);
return await BuildLoginResultAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
}
private async Task<OmsLoginResultDto> RefreshCustomerTokenAsync(IEnumerable<Claim> claims, CancellationToken cancellationToken)
@ -448,39 +444,77 @@ public partial class AuthService(
throw new BusinessException("用户不存在");
}
var refreshedClaims = await BuildClaimsAsync(userGuid.Value, OmsLoginType.Customer, cancellationToken);
return await BuildLoginResultAsync(userGuid.Value, refreshedClaims, cancellationToken);
var refreshedClaims = ReuseClaimsFromRefreshToken(claims, OmsLoginType.Customer);
return BuildTokenOnlyResult(userGuid.Value, refreshedClaims);
}
private async Task<UserInfoDto> BuildAdminUserInfoAsync(Guid userId, CancellationToken cancellationToken)
{
var cacheKey = UserInfoCacheKey(userId);
var cacheKey = rbacAuthCache.UserInfoKey(userId);
if (memoryCache.TryGetValue(cacheKey, out UserInfoDto? cached) && cached != null)
{
return cached;
}
// 同一 scoped DbContext 不能并行查询,必须串行
var userInfo = await userService.GetAsync(userId, cancellationToken);
var permissions = await userService.GetUserPermissionsAsync(userId, cancellationToken);
// 一次加载:用户 + 角色 + 权限,避免 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 permissions = roles
.SelectMany(x => x.Permissions.Select(p => p.PermissionCode))
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var menus = await BuildMenusByPermissionsAsync(permissions, cancellationToken);
var dto = new UserInfoDto
{
Id = userInfo.Id,
Name = userInfo.Name,
Account = userInfo.Account,
Email = userInfo.Email,
Avatar = userInfo.Avatar,
OrganizationId = userInfo.OrganizationId,
OrganizationName = userInfo.OrganizationName,
Roles = userInfo.Roles,
Id = user.Id,
Name = user.Name,
Account = user.Account,
Email = user.Email,
Avatar = 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 = userInfo.CreatedAt,
CreatedBy = userInfo.CreatedBy,
UpdatedAt = userInfo.UpdatedAt,
UpdatedBy = userInfo.UpdatedBy
CreatedAt = user.CreateTime,
CreatedBy = user.CreateUserId,
UpdatedAt = user.UpdateTime == default ? null : user.UpdateTime,
UpdatedBy = user.UpdateUserId
};
memoryCache.Set(
@ -495,7 +529,7 @@ public partial class AuthService(
private void InvalidateUserInfoCache(Guid userId)
{
memoryCache.Remove(UserInfoCacheKey(userId));
rbacAuthCache.InvalidateUser(userId);
}
private async Task ChangeCustomerPasswordAsync(string oldPassword, string newPassword, CancellationToken cancellationToken)
@ -512,22 +546,25 @@ public partial class AuthService(
await customerRepository.UpdateAsync(context.Customer, cancellationToken);
}
private async Task<List<Claim>> BuildAdminClaimsAsync(Guid userId, CancellationToken cancellationToken)
{
return await BuildClaimsAsync(userId, OmsLoginType.User, cancellationToken);
}
private async Task<List<Claim>> BuildClaimsAsync(Guid userId, OmsLoginType loginType, CancellationToken cancellationToken)
{
var claims = await jwtService.GenerateClaimsByUserIdAsync(userId, cancellationToken);
ReplaceOrAppendClaim(claims, SimpleOmsClaimTypes.LoginType, ((int)loginType).ToString());
return claims;
}
/// <summary>
/// 登录快照:一次构建 UserInfo,并从中派生 JWT claims,避免再走 Fake GetUserRoles/GetAsync。
/// </summary>
private async Task<OmsLoginResultDto> BuildLoginResultAsync(
Guid userId,
IReadOnlyCollection<Claim> claims,
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);
@ -539,10 +576,59 @@ public partial class AuthService(
TokenType = "Bearer",
ExpiresIn = jwtService.GetExpiresInSeconds(),
UserId = userId,
UserInfo = await BuildAdminUserInfoAsync(userId, cancellationToken)
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();
@ -694,7 +780,8 @@ public partial class AuthService(
private async Task<List<Fake.Rbac.Domain.MenuAggregate.Menu>> GetAllActiveMenusCachedAsync(
CancellationToken cancellationToken)
{
if (memoryCache.TryGetValue(AllMenusCacheKey, out List<Fake.Rbac.Domain.MenuAggregate.Menu>? cached)
var cacheKey = rbacAuthCache.AllMenusKey();
if (memoryCache.TryGetValue(cacheKey, out List<Fake.Rbac.Domain.MenuAggregate.Menu>? cached)
&& cached is { Count: > 0 })
{
return cached;
@ -706,7 +793,7 @@ public partial class AuthService(
.OrderBy(x => x.Order)
.ToListAsync(cancellationToken);
memoryCache.Set(AllMenusCacheKey, allMenus, TimeSpan.FromMinutes(5));
memoryCache.Set(cacheKey, allMenus, AllMenusCacheTtl);
return allMenus;
}
@ -751,6 +838,13 @@ public partial class AuthService(
.FirstOrDefaultAsync(cancellationToken);
}
private async Task<int?> GetMapIdByUserGuidAsync(Guid userId, CancellationToken cancellationToken)
{
return await rbacDbContext.Database.SqlQuery<int?>(
$"select `userid` as `Value` from `user` where `Id` = {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;

View File

@ -0,0 +1,18 @@
namespace SimpleAdmin.Api.Auth;
/// <summary>
/// RBAC 登录用户信息 / 菜单缓存。通过 epoch 批量失效,避免角色权限变更后脏读。
/// </summary>
public interface IRbacAuthCache
{
string UserInfoKey(Guid userId);
string AllMenusKey();
void InvalidateUser(Guid userId);
/// <summary>
/// 角色/菜单等全局变更时 bump epoch,使全部 userInfo 与全量菜单缓存失效。
/// </summary>
void InvalidateAll();
}

View File

@ -0,0 +1,35 @@
using Microsoft.Extensions.Caching.Memory;
namespace SimpleAdmin.Api.Auth;
public sealed class RbacAuthCache(IMemoryCache memoryCache) : IRbacAuthCache
{
private const string EpochKey = "rbac:auth:epoch";
private const string UserInfoKeyPrefix = "rbac:userinfo:v3:";
private const string AllMenusKeyPrefix = "rbac:menus:all:v3:";
private string GetEpoch()
{
return memoryCache.GetOrCreate(
EpochKey,
entry =>
{
entry.Priority = CacheItemPriority.NeverRemove;
return "0";
})!;
}
public string UserInfoKey(Guid userId) => UserInfoKeyPrefix + GetEpoch() + ":" + userId;
public string AllMenusKey() => AllMenusKeyPrefix + GetEpoch();
public void InvalidateUser(Guid userId)
{
memoryCache.Remove(UserInfoKey(userId));
}
public void InvalidateAll()
{
memoryCache.Set(EpochKey, Guid.NewGuid().ToString("N"));
}
}

View File

@ -0,0 +1,31 @@
namespace SimpleAdmin.Api.Auth;
/// <summary>
/// 角色/菜单/分配角色等写操作成功后,失效 auth 相关内存缓存。
/// </summary>
public sealed class RbacCacheInvalidationMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, IRbacAuthCache authCache)
{
await next(context);
if (context.Response.StatusCode is < 200 or >= 300)
{
return;
}
var method = context.Request.Method;
if (method is not ("POST" or "PUT" or "DELETE"))
{
return;
}
var path = context.Request.Path.Value ?? string.Empty;
if (path.Equals("/rbac/user/assign-roles", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/rbac/role/", StringComparison.OrdinalIgnoreCase) ||
path.StartsWith("/rbac/menu/", StringComparison.OrdinalIgnoreCase))
{
authCache.InvalidateAll();
}
}
}

View File

@ -61,7 +61,8 @@ public class SimpleAdminApiModule : FakeModule
});
services.AddOmsWeChat(configuration);
services.AddSingleton<IRbacAuthCache, RbacAuthCache>();
//Json格式化全局处理
context.Services.AddFakeNewtonsoft();
@ -121,6 +122,9 @@ public class SimpleAdminApiModule : FakeModule
app.UseAuthentication();
app.UseAuthorization();
// 角色/菜单写操作后失效登录用户信息缓存
app.UseMiddleware<RbacCacheInvalidationMiddleware>();
// 端点映射
app.MapControllers();