refactor: AdminApi 剔除租户侧能力

This commit is contained in:
2026-01-29 23:24:44 +00:00
parent 71e5a9dc29
commit 4f8424adb6
139 changed files with 622 additions and 4691 deletions

View File

@@ -1,5 +1,4 @@
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.Identity.Abstractions;
@@ -28,16 +27,6 @@ public interface IAdminAuthService
/// </summary>
Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default);
/// <summary>
/// 获取用户权限概览。
/// </summary>
Task<UserPermissionDto?> GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default);
/// <summary>
/// 搜索用户权限概览列表。
/// </summary>
Task<PagedResult<UserPermissionDto>> SearchUserPermissionsAsync(string? keyword, int page, int pageSize, string? sortBy, bool sortDescending, CancellationToken cancellationToken = default);
/// <summary>
/// 获取当前用户可见菜单树。
/// </summary>

View File

@@ -1,13 +0,0 @@
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Abstractions;
/// <summary>
/// 小程序认证服务。
/// </summary>
public interface IMiniAuthService
{
Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default);
Task<TokenResponse> RefreshTokenAsync(RefreshTokenRequest request, CancellationToken cancellationToken = default);
Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default);
}

View File

@@ -1,36 +0,0 @@
namespace TakeoutSaaS.Application.Identity.Abstractions;
/// <summary>
/// 微信 code2Session 服务契约。
/// </summary>
public interface IWeChatAuthService
{
/// <summary>
/// 调用微信接口完成 code2Session 交换。
/// </summary>
/// <param name="code">临时登录凭证 code。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>会话信息。</returns>
Task<WeChatSessionInfo> Code2SessionAsync(string code, CancellationToken cancellationToken = default);
}
/// <summary>
/// 微信会话信息。
/// </summary>
public sealed class WeChatSessionInfo
{
/// <summary>
/// OpenId。
/// </summary>
public string OpenId { get; init; } = string.Empty;
/// <summary>
/// UnionId。
/// </summary>
public string? UnionId { get; init; }
/// <summary>
/// 会话密钥。
/// </summary>
public string SessionKey { get; init; } = string.Empty;
}

View File

@@ -1,19 +0,0 @@
using MediatR;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 为用户分配角色(覆盖式)。
/// </summary>
public sealed record AssignUserRolesCommand : IRequest<bool>
{
/// <summary>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 角色 ID 集合。
/// </summary>
public long[] RoleIds { get; init; } = Array.Empty<long>();
}

View File

@@ -1,5 +1,6 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Enums;
namespace TakeoutSaaS.Application.Identity.Commands;
@@ -8,6 +9,16 @@ namespace TakeoutSaaS.Application.Identity.Commands;
/// </summary>
public sealed record CopyRoleTemplateCommand : IRequest<RoleDto>
{
/// <summary>
/// 目标 Portal。
/// </summary>
public PortalType Portal { get; init; }
/// <summary>
/// 目标租户 IDPortal=Tenant 时必填Portal=Admin 时必须为空)。
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 模板编码。
/// </summary>

View File

@@ -1,15 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 批量为当前租户初始化角色模板。
/// </summary>
public sealed record InitializeRoleTemplatesCommand : IRequest<IReadOnlyList<RoleDto>>
{
/// <summary>
/// 需要初始化的模板编码列表(为空则全部)。
/// </summary>
public IReadOnlyCollection<string>? TemplateCodes { get; init; }
}

View File

@@ -1,38 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
/// 微信小程序登录请求。
/// </summary>
public sealed class WeChatLoginRequest
{
/// <summary>
/// wx.login 返回的临时 code。
/// </summary>
[Required]
[MaxLength(128)]
public string Code { get; set; } = string.Empty;
/// <summary>
/// 用户昵称。
/// </summary>
[MaxLength(64)]
public string? Nickname { get; set; }
/// <summary>
/// 头像地址。
/// </summary>
[MaxLength(256)]
public string? Avatar { get; set; }
/// <summary>
/// 加密用户数据。
/// </summary>
public string? EncryptedData { get; set; }
/// <summary>
/// 加密向量。
/// </summary>
public string? Iv { get; set; }
}

View File

@@ -13,16 +13,10 @@ public static class IdentityServiceCollectionExtensions
/// 注册身份认证相关应用服务
/// </summary>
/// <param name="services">服务集合</param>
/// <param name="enableMiniSupport">是否注册小程序认证服务</param>
public static IServiceCollection AddIdentityApplication(this IServiceCollection services, bool enableMiniSupport = false)
public static IServiceCollection AddIdentityApplication(this IServiceCollection services)
{
services.AddScoped<IAdminAuthService, AdminAuthService>();
if (enableMiniSupport)
{
services.AddScoped<IMiniAuthService, MiniAuthService>();
}
return services;
}
}

View File

@@ -1,38 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 用户角色分配处理器。
/// </summary>
public sealed class AssignUserRolesCommandHandler(
IUserRoleRepository userRoleRepository,
ITenantProvider tenantProvider)
: IRequestHandler<AssignUserRolesCommand, bool>
{
/// <summary>
/// 处理用户角色分配请求。
/// </summary>
/// <param name="request">分配命令。</param>
/// <param name="cancellationToken">取消标记。</param>
/// <returns>执行结果。</returns>
public async Task<bool> Handle(AssignUserRolesCommand request, CancellationToken cancellationToken)
{
// 1. 固定为租户侧用户分配角色
var portal = PortalType.Tenant;
// 2. 获取租户上下文
var tenantId = tenantProvider.GetCurrentTenantId();
// 3. 覆盖式绑定角色
await userRoleRepository.ReplaceUserRolesAsync(portal, tenantId, request.UserId, request.RoleIds, cancellationToken);
await userRoleRepository.SaveChangesAsync(cancellationToken);
// 4. 返回执行结果
return true;
}
}

View File

@@ -11,7 +11,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -22,7 +21,6 @@ public sealed class BatchIdentityUserOperationCommandHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher)
@@ -31,24 +29,17 @@ public sealed class BatchIdentityUserOperationCommandHandler(
/// <inheritdoc />
public async Task<BatchIdentityUserOperationResult> Handle(BatchIdentityUserOperationCommand request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 校验租户访问权限
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户批量操作用户");
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
if (isSuperAdmin && !request.TenantId.HasValue)
{
throw new BusinessException(ErrorCodes.BadRequest, "批量操作必须指定租户");
}
// 3. 解析用户 ID 列表
var tenantId = request.TenantId ?? currentTenantId;
// 3. (空行后) 解析用户 ID 列表
var tenantId = request.TenantId.Value;
var userIds = ParseIds(request.UserIds, "用户");
if (userIds.Length == 0)
{

View File

@@ -2,7 +2,8 @@ using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -10,8 +11,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// 绑定角色权限处理器。
/// </summary>
public sealed class BindRolePermissionsCommandHandler(
IRolePermissionRepository rolePermissionRepository,
ITenantProvider tenantProvider)
IRolePermissionRepository rolePermissionRepository)
: IRequestHandler<BindRolePermissionsCommand, bool>
{
/// <summary>
@@ -25,10 +25,16 @@ public sealed class BindRolePermissionsCommandHandler(
// 1. 固定绑定租户侧角色权限
var portal = PortalType.Tenant;
// 2. 获取租户上下文
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. 覆盖式绑定权限
// 3. (空行后) 获取租户标识
var tenantId = request.TenantId.Value;
// 4. (空行后) 覆盖式绑定权限
var distinctPermissionIds = request.PermissionIds
.Where(id => id > 0)
.Distinct()
@@ -37,7 +43,7 @@ public sealed class BindRolePermissionsCommandHandler(
await rolePermissionRepository.ReplaceRolePermissionsAsync(portal, tenantId, request.RoleId, distinctPermissionIds, cancellationToken);
await rolePermissionRepository.SaveChangesAsync(cancellationToken);
// 4. 返回执行结果
// 5. 返回执行结果
return true;
}
}

View File

@@ -8,7 +8,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -19,7 +18,6 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher)
@@ -28,30 +26,17 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
/// <inheritdoc />
public async Task<bool> Handle(ChangeIdentityUserStatusCommand request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 校验跨租户访问权限
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户修改用户状态");
}
// 3. 查询用户实体
// 2. (空行后) 查询用户实体
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
if (user == null)
{
return false;
}
if (!isSuperAdmin && user.TenantId != currentTenantId)
{
return false;
}
// 4. 校验租户管理员保留规则(仅租户侧用户适用)
// 3. 校验租户管理员保留规则(仅租户侧用户适用)
if (user.Portal == PortalType.Tenant
&& request.Status == IdentityUserStatus.Disabled
&& user.Status == IdentityUserStatus.Active
@@ -60,7 +45,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
await EnsureNotLastActiveTenantAdminAsync(user.TenantId.Value, user.Id, cancellationToken);
}
// 5. 更新状态
// 4. 更新状态
var previousStatus = user.Status;
switch (request.Status)
{
@@ -81,7 +66,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
throw new BusinessException(ErrorCodes.BadRequest, "无效的用户状态");
}
// 6. 构建操作日志消息
// 5. 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -108,7 +93,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
Success = true
};
// 7. 写入 Outbox 并保存变更
// 6. 写入 Outbox 并保存变更
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);

View File

@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -17,14 +16,24 @@ public sealed class CopyRoleTemplateCommandHandler(
IRoleTemplateRepository roleTemplateRepository,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IRolePermissionRepository rolePermissionRepository,
ITenantProvider tenantProvider)
IRolePermissionRepository rolePermissionRepository)
: IRequestHandler<CopyRoleTemplateCommand, RoleDto>
{
/// <inheritdoc />
public async Task<RoleDto> Handle(CopyRoleTemplateCommand request, CancellationToken cancellationToken)
{
// 1. 查询模板与模板权限
// 1. 校验 Portal 与 TenantId 参数
if (request.Portal == PortalType.Admin && request.TenantId is not null)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "Portal=Admin 时 TenantId 必须为空");
}
if (request.Portal == PortalType.Tenant && (!request.TenantId.HasValue || request.TenantId.Value <= 0))
{
throw new BusinessException(ErrorCodes.ValidationFailed, "Portal=Tenant 时 TenantId 必须大于 0");
}
// 2. 查询模板与模板权限
var template = await roleTemplateRepository.FindByCodeAsync(request.TemplateCode, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {request.TemplateCode} 不存在");
@@ -35,16 +44,16 @@ public sealed class CopyRoleTemplateCommandHandler(
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// 2. 计算角色名称/编码与描述
var tenantId = tenantProvider.GetCurrentTenantId();
// 3. 计算角色名称/编码与描述
var portal = request.Portal;
var tenantId = request.TenantId;
// 3. 固定复制为租户侧角色
var portal = PortalType.Tenant;
// 4. (空行后) 解析目标角色信息
var roleCode = string.IsNullOrWhiteSpace(request.RoleCode) ? template.TemplateCode : request.RoleCode.Trim();
var roleName = string.IsNullOrWhiteSpace(request.RoleName) ? template.Name : request.RoleName.Trim();
var roleDescription = request.Description ?? template.Description;
// 4. 准备或更新角色主体(幂等创建)。
// 5. 准备或更新角色主体(幂等创建)。
var role = await roleRepository.FindByCodeAsync(portal, tenantId, roleCode, cancellationToken);
if (role is null)
{
@@ -73,7 +82,7 @@ public sealed class CopyRoleTemplateCommandHandler(
await roleRepository.UpdateAsync(role, cancellationToken);
}
// 5. 确保模板权限全部存在,不存在则按模板定义创建。
// 6. 确保模板权限全部存在,不存在则按模板定义创建。
var existingPermissions = await permissionRepository.GetByCodesAsync(permissionCodes, cancellationToken);
var permissionMap = existingPermissions.ToDictionary(x => x.Code, StringComparer.OrdinalIgnoreCase);
@@ -97,7 +106,7 @@ public sealed class CopyRoleTemplateCommandHandler(
await roleRepository.SaveChangesAsync(cancellationToken);
// 6. 绑定缺失的权限,保留租户自定义的已有授权。
// 7. 绑定缺失的权限,保留租户自定义的已有授权。
var rolePermissions = await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, new[] { role.Id }, cancellationToken);
var existingPermissionIds = rolePermissions
.Select(x => x.PermissionId)

View File

@@ -13,7 +13,6 @@ using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Ids;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -25,7 +24,6 @@ public sealed class CreateIdentityUserCommandHandler(
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IPasswordHasher<IdentityUser> passwordHasher,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher,
@@ -36,19 +34,17 @@ public sealed class CreateIdentityUserCommandHandler(
/// <inheritdoc />
public async Task<UserDetailDto> Handle(CreateIdentityUserCommand request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 校验租户访问权限
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户创建用户");
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. 规范化输入并准备校验
var tenantId = isSuperAdmin ? request.TenantId ?? currentTenantId : currentTenantId;
// 3. (空行后) 规范化输入并准备校验
var tenantId = request.TenantId.Value;
var account = request.Account.Trim();
var displayName = request.DisplayName.Trim();
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();

View File

@@ -6,7 +6,6 @@ using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -14,8 +13,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// 创建角色处理器。
/// </summary>
public sealed class CreateRoleCommandHandler(
IRoleRepository roleRepository,
ITenantProvider tenantProvider)
IRoleRepository roleRepository)
: IRequestHandler<CreateRoleCommand, RoleDto>
{
/// <summary>
@@ -28,11 +26,17 @@ public sealed class CreateRoleCommandHandler(
{
// 1. 固定创建租户侧角色
var portal = PortalType.Tenant;
// 2. 获取租户上下文
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
// 3. 归一化输入并校验唯一
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. (空行后) 获取租户标识
var tenantId = request.TenantId.Value;
// 4. (空行后) 归一化输入并校验唯一
var name = request.Name?.Trim() ?? string.Empty;
var code = request.Code?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(code))
@@ -46,7 +50,7 @@ public sealed class CreateRoleCommandHandler(
throw new BusinessException(ErrorCodes.Conflict, "角色编码已存在");
}
// 4. 构建角色实体
// 5. 构建角色实体
var role = new Role
{
Portal = portal,
@@ -56,11 +60,11 @@ public sealed class CreateRoleCommandHandler(
Description = request.Description
};
// 5. 持久化
// 6. 持久化
await roleRepository.AddAsync(role, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 6. 返回 DTO
// 7. 返回 DTO
return new RoleDto
{
Id = role.Id,

View File

@@ -8,7 +8,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -19,7 +18,6 @@ public sealed class DeleteIdentityUserCommandHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher)
@@ -28,36 +26,23 @@ public sealed class DeleteIdentityUserCommandHandler(
/// <inheritdoc />
public async Task<bool> Handle(DeleteIdentityUserCommand request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 校验跨租户访问权限
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户删除用户");
}
// 3. 查询用户实体
// 2. (空行后) 查询用户实体
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
if (user == null)
{
return false;
}
if (!isSuperAdmin && user.TenantId != currentTenantId)
{
return false;
}
// 4. 校验租户管理员保留规则(仅租户侧用户适用)
// 3. 校验租户管理员保留规则(仅租户侧用户适用)
if (user.Portal == PortalType.Tenant && user.Status == IdentityUserStatus.Active && user.TenantId.HasValue)
{
await EnsureNotLastActiveTenantAdminAsync(user.TenantId.Value, user.Id, cancellationToken);
}
// 5. 构建操作日志消息
// 4. 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -78,7 +63,7 @@ public sealed class DeleteIdentityUserCommandHandler(
Success = true
};
// 6. 软删除用户并写入 Outbox
// 5. 软删除用户并写入 Outbox
await identityUserRepository.RemoveAsync(user, cancellationToken);
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);

View File

@@ -2,7 +2,8 @@ using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -10,8 +11,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// 删除角色处理器。
/// </summary>
public sealed class DeleteRoleCommandHandler(
IRoleRepository roleRepository,
ITenantProvider tenantProvider)
IRoleRepository roleRepository)
: IRequestHandler<DeleteRoleCommand, bool>
{
/// <summary>
@@ -25,14 +25,19 @@ public sealed class DeleteRoleCommandHandler(
// 1. 固定删除租户侧角色
var portal = PortalType.Tenant;
// 2. 获取租户上下文
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. (空行后) 获取租户标识并删除角色
var tenantId = request.TenantId.Value;
// 3. 删除角色
await roleRepository.DeleteAsync(portal, tenantId, request.RoleId, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 4. 返回执行结果
// 4. (空行后) 返回执行结果
return true;
}
}

View File

@@ -1,12 +1,9 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -18,21 +15,13 @@ public sealed class GetIdentityUserDetailQueryHandler(
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IRolePermissionRepository rolePermissionRepository,
IPermissionRepository permissionRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
IPermissionRepository permissionRepository)
: IRequestHandler<GetIdentityUserDetailQuery, UserDetailDto?>
{
/// <inheritdoc />
public async Task<UserDetailDto?> Handle(GetIdentityUserDetailQuery request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 查询用户实体
// 1. 查询用户实体
var user = request.IncludeDeleted
? await identityUserRepository.GetForUpdateIncludingDeletedAsync(request.UserId, cancellationToken)
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
@@ -42,16 +31,11 @@ public sealed class GetIdentityUserDetailQueryHandler(
return null;
}
if (!isSuperAdmin && user.TenantId != currentTenantId)
{
return null;
}
// 3. 加载角色与权限
// 2. 加载角色与权限
var portal = user.Portal;
var tenantId = user.TenantId;
// 4. 查询用户角色关系
// 3. 查询用户角色关系
var roleRelations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, user.Id, cancellationToken);
var roleIds = roleRelations.Select(x => x.RoleId).Distinct().ToArray();
var roles = roleIds.Length == 0
@@ -75,7 +59,7 @@ public sealed class GetIdentityUserDetailQueryHandler(
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// 5. 组装详情 DTO
// 4. 组装详情 DTO
var now = DateTime.UtcNow;
return new UserDetailDto
{

View File

@@ -1,89 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 按用户 ID 获取权限概览处理器。
/// </summary>
public sealed class GetUserPermissionsQueryHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IRolePermissionRepository rolePermissionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetUserPermissionsQuery, UserPermissionDto?>
{
/// <inheritdoc />
public async Task<UserPermissionDto?> Handle(GetUserPermissionsQuery request, CancellationToken cancellationToken)
{
// 1. 获取租户并查询用户
var portal = PortalType.Tenant;
var tenantId = tenantProvider.GetCurrentTenantId();
var user = await identityUserRepository.FindByIdAsync(request.UserId, cancellationToken);
if (user == null || user.TenantId != tenantId)
{
return null;
}
// 2. 解析角色与权限
var roleCodes = await ResolveUserRolesAsync(portal, tenantId, user.Id, cancellationToken);
var permissionCodes = await ResolveUserPermissionsAsync(portal, tenantId, user.Id, cancellationToken);
// 3. 返回用户权限概览
return new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = roleCodes,
Permissions = permissionCodes,
CreatedAt = user.CreatedAt
};
}
private async Task<string[]> ResolveUserRolesAsync(PortalType portal, long tenantId, long userId, CancellationToken cancellationToken)
{
// 1. 查询用户角色关系
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<string>();
}
// 2. 查询角色编码
var roles = await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private async Task<string[]> ResolveUserPermissionsAsync(PortalType portal, long tenantId, long userId, CancellationToken cancellationToken)
{
// 1. 查询用户角色关系
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<string>();
}
// 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<string>();
}
// 3. 查询权限编码
var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}

View File

@@ -1,62 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 租户角色模板批量初始化处理器。
/// </summary>
public sealed class InitializeRoleTemplatesCommandHandler(
IRoleTemplateRepository roleTemplateRepository,
IMediator mediator)
: IRequestHandler<InitializeRoleTemplatesCommand, IReadOnlyList<RoleDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<RoleDto>> Handle(InitializeRoleTemplatesCommand request, CancellationToken cancellationToken)
{
// 1. 解析需要初始化的模板编码,默认取全部模板。
var requestedCodes = request.TemplateCodes?
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var availableTemplates = await roleTemplateRepository.GetAllAsync(true, cancellationToken);
var availableCodes = availableTemplates.Select(t => t.TemplateCode).ToHashSet(StringComparer.OrdinalIgnoreCase);
var targetCodes = requestedCodes?.Length > 0
? requestedCodes
: availableTemplates.Select(template => template.TemplateCode).ToArray();
if (targetCodes.Length == 0)
{
return Array.Empty<RoleDto>();
}
foreach (var code in targetCodes)
{
if (!availableCodes.Contains(code))
{
throw new BusinessException(ErrorCodes.NotFound, $"角色模板 {code} 不存在或未启用");
}
}
// 2. 逐个复制模板,幂等写入角色与权限。
var roles = new List<RoleDto>(targetCodes.Length);
foreach (var templateCode in targetCodes)
{
var role = await mediator.Send(new CopyRoleTemplateCommand
{
TemplateCode = templateCode
}, cancellationToken);
roles.Add(role);
}
return roles;
}
}

View File

@@ -9,7 +9,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -19,7 +18,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
public sealed class ResetIdentityUserPasswordCommandHandler(
IAdminPasswordResetTokenStore tokenStore,
IIdentityUserRepository identityUserRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher)
@@ -28,34 +26,21 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
/// <inheritdoc />
public async Task<ResetIdentityUserPasswordResult> Handle(ResetIdentityUserPasswordCommand request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 校验跨租户访问权限
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
}
// 3. 查询用户实体
// 2. (空行后) 查询用户实体
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
if (user == null)
{
throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
}
if (!isSuperAdmin && user.TenantId != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
}
// 4. 签发重置令牌1 小时有效)
// 3. 签发重置令牌1 小时有效)
var expiresAt = DateTime.UtcNow.AddHours(1);
var token = await tokenStore.IssueAsync(user.Id, expiresAt, cancellationToken);
// 5. 标记用户需重置密码
// 4. 标记用户需重置密码
user.MustChangePassword = true;
user.FailedLoginCount = 0;
user.LockedUntil = null;
@@ -64,7 +49,7 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
user.Status = IdentityUserStatus.Active;
}
// 6. 构建操作日志消息
// 5. 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -85,7 +70,7 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
Success = true
};
// 7. 写入 Outbox 并保存变更
// 6. 写入 Outbox 并保存变更
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);

View File

@@ -7,7 +7,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -16,7 +15,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// </summary>
public sealed class RestoreIdentityUserCommandHandler(
IIdentityUserRepository identityUserRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher)
@@ -25,35 +23,22 @@ public sealed class RestoreIdentityUserCommandHandler(
/// <inheritdoc />
public async Task<bool> Handle(RestoreIdentityUserCommand request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 校验跨租户访问权限
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户恢复用户");
}
// 3. 查询用户实体(包含已删除)
// 2. (空行后) 查询用户实体(包含已删除)
var user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(request.UserId, cancellationToken);
if (user == null)
{
return false;
}
if (!isSuperAdmin && user.TenantId != currentTenantId)
{
return false;
}
if (!user.DeletedAt.HasValue)
{
return false;
}
// 4. 构建操作日志消息
// 3. (空行后) 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -74,7 +59,7 @@ public sealed class RestoreIdentityUserCommandHandler(
Success = true
};
// 5. 恢复软删除状态并写入 Outbox
// 4. 恢复软删除状态并写入 Outbox
user.DeletedAt = null;
user.DeletedBy = null;
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);

View File

@@ -3,7 +3,8 @@ using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -13,8 +14,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
public sealed class RoleDetailQueryHandler(
IRoleRepository roleRepository,
IRolePermissionRepository rolePermissionRepository,
IPermissionRepository permissionRepository,
ITenantProvider tenantProvider)
IPermissionRepository permissionRepository)
: IRequestHandler<RoleDetailQuery, RoleDetailDto?>
{
/// <inheritdoc />
@@ -23,24 +23,30 @@ public sealed class RoleDetailQueryHandler(
// 1. 固定查询租户侧角色详情
var portal = PortalType.Tenant;
// 2. 获取租户上下文并查询角色
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. (空行后) 获取租户标识并查询角色
var tenantId = request.TenantId.Value;
var role = await roleRepository.FindByIdAsync(portal, tenantId, request.RoleId, cancellationToken);
if (role is null)
{
return null;
}
// 3. 查询角色权限关系
// 4. 查询角色权限关系
var relations = await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, new[] { role.Id }, cancellationToken);
var permissionIds = relations.Select(x => x.PermissionId).ToArray();
// 4. 拉取权限实体
// 5. 拉取权限实体
var permissions = permissionIds.Length == 0
? Array.Empty<Domain.Identity.Entities.Permission>()
: await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
// 5. 映射 DTO
// 6. 映射 DTO
var permissionDtos = permissions
.Select(x => new PermissionDto
{
@@ -55,6 +61,7 @@ public sealed class RoleDetailQueryHandler(
})
.ToList();
// 7. (空行后) 返回角色详情
return new RoleDetailDto
{
Id = role.Id,

View File

@@ -1,15 +1,10 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
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;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -19,30 +14,16 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
public sealed class SearchIdentityUsersQueryHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService)
IRoleRepository roleRepository)
: IRequestHandler<SearchIdentityUsersQuery, PagedResult<UserListItemDto>>
{
/// <inheritdoc />
public async Task<PagedResult<UserListItemDto>> Handle(SearchIdentityUsersQuery request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 校验跨租户访问权限
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询用户");
}
// 3. 组装查询过滤条件
// 1. 组装查询过滤条件
var filter = new IdentityUserSearchFilter
{
TenantId = isSuperAdmin ? request.TenantId : currentTenantId,
TenantId = request.TenantId,
Keyword = request.Keyword,
Status = request.Status,
RoleId = request.RoleId,
@@ -57,17 +38,17 @@ public sealed class SearchIdentityUsersQueryHandler(
SortDescending = request.SortDescending
};
// 4. 执行分页查询
// 2. 执行分页查询
var (items, total) = await identityUserRepository.SearchPagedAsync(filter, cancellationToken);
if (items.Count == 0)
{
return new PagedResult<UserListItemDto>(Array.Empty<UserListItemDto>(), request.Page, request.PageSize, total);
}
// 5. 加载角色编码映射
// 3. 加载角色编码映射
var roleCodesLookup = await ResolveRoleCodesAsync(items, userRoleRepository, roleRepository, cancellationToken);
// 6. 组装 DTO
// 4. 组装 DTO
var now = DateTime.UtcNow;
var dtos = items.Select(user => new UserListItemDto
{
@@ -87,7 +68,7 @@ public sealed class SearchIdentityUsersQueryHandler(
LastLoginAt = user.LastLoginAt
}).ToList();
// 7. 返回分页结果
// 5. 返回分页结果
return new PagedResult<UserListItemDto>(dtos, request.Page, request.PageSize, total);
}

View File

@@ -3,8 +3,9 @@ using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -12,8 +13,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// 角色分页查询处理器。
/// </summary>
public sealed class SearchRolesQueryHandler(
IRoleRepository roleRepository,
ITenantProvider tenantProvider)
IRoleRepository roleRepository)
: IRequestHandler<SearchRolesQuery, PagedResult<RoleDto>>
{
/// <summary>
@@ -27,11 +27,17 @@ public sealed class SearchRolesQueryHandler(
// 1. 固定查询租户侧角色
var portal = PortalType.Tenant;
// 2. 获取租户上下文并查询角色
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. (空行后) 获取租户标识并查询角色
var tenantId = request.TenantId.Value;
var roles = await roleRepository.SearchAsync(portal, tenantId, request.Keyword, cancellationToken);
// 3. 排序
// 4. 排序
var sorted = request.SortBy?.ToLowerInvariant() switch
{
"name" => request.SortDescending
@@ -42,13 +48,13 @@ public sealed class SearchRolesQueryHandler(
: roles.OrderBy(x => x.CreatedAt)
};
// 4. 分页
// 5. 分页
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
// 5. 映射 DTO
// 6. 映射 DTO
var items = paged.Select(role => new RoleDto
{
Id = role.Id,
@@ -59,7 +65,7 @@ public sealed class SearchRolesQueryHandler(
Description = role.Description
}).ToList();
// 6. 返回分页结果
// 7. 返回分页结果
return new PagedResult<RoleDto>(items, request.Page, request.PageSize, roles.Count);
}
}

View File

@@ -1,132 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 租户用户权限分页查询处理器。
/// </summary>
public sealed class SearchUserPermissionsQueryHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IRolePermissionRepository rolePermissionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchUserPermissionsQuery, PagedResult<UserPermissionDto>>
{
/// <inheritdoc />
public async Task<PagedResult<UserPermissionDto>> Handle(SearchUserPermissionsQuery request, CancellationToken cancellationToken)
{
// 1. 获取租户并查询用户
var portal = PortalType.Tenant;
var tenantId = tenantProvider.GetCurrentTenantId();
var users = await identityUserRepository.SearchAsync(tenantId, request.Keyword, cancellationToken);
// 2. 排序与分页
var sorted = SortUsers(users, request.SortBy, request.SortDescending);
var paged = sorted
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToList();
// 3. 解析角色与权限
var resolved = await ResolveRolesAndPermissionsAsync(portal, tenantId, paged, cancellationToken);
var items = paged.Select(user => new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = resolved[user.Id].roles,
Permissions = resolved[user.Id].permissions,
CreatedAt = user.CreatedAt
}).ToList();
return new PagedResult<UserPermissionDto>(items, request.Page, request.PageSize, users.Count);
}
private static IOrderedEnumerable<Domain.Identity.Entities.IdentityUser> SortUsers(
IReadOnlyCollection<Domain.Identity.Entities.IdentityUser> users,
string? sortBy,
bool sortDescending)
{
return sortBy?.ToLowerInvariant() switch
{
"account" => sortDescending
? users.OrderByDescending(x => x.Account)
: users.OrderBy(x => x.Account),
"displayname" => sortDescending
? users.OrderByDescending(x => x.DisplayName)
: users.OrderBy(x => x.DisplayName),
_ => sortDescending
? users.OrderByDescending(x => x.CreatedAt)
: users.OrderBy(x => x.CreatedAt)
};
}
private async Task<Dictionary<long, (string[] roles, string[] permissions)>> ResolveRolesAndPermissionsAsync(
PortalType portal,
long tenantId,
IReadOnlyCollection<Domain.Identity.Entities.IdentityUser> users,
CancellationToken cancellationToken)
{
// 1. 查询用户角色关系
var userIds = users.Select(x => x.Id).ToArray();
var userRoleRelations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray();
// 2. 查询角色信息
var roles = roleIds.Length == 0
? Array.Empty<Domain.Identity.Entities.Role>()
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer<long>.Default);
// 3. 查询角色-权限关系
var rolePermissions = roleIds.Length == 0
? Array.Empty<Domain.Identity.Entities.RolePermission>()
: await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken);
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
// 4. 查询权限详情
var permissions = permissionIds.Length == 0
? Array.Empty<Domain.Identity.Entities.Permission>()
: await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer<long>.Default);
var rolePermissionsLookup = rolePermissions
.GroupBy(rp => rp.RoleId)
.ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer<long>.Default);
var result = new Dictionary<long, (string[] roles, string[] permissions)>();
foreach (var userId in userIds)
{
// 5. 聚合用户角色与权限编码
var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray();
var roleCodes = rolesForUser
.Select(rid => roleCodeMap.GetValueOrDefault(rid))
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var permissionCodes = rolesForUser
.SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty<long>())
.Select(pid => permissionCodeMap.GetValueOrDefault(pid))
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
result[userId] = (roleCodes, permissionCodes);
}
return result;
}
}

View File

@@ -11,7 +11,6 @@ using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -22,7 +21,6 @@ public sealed class UpdateIdentityUserCommandHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
ITenantProvider tenantProvider,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher,
@@ -32,30 +30,17 @@ public sealed class UpdateIdentityUserCommandHandler(
/// <inheritdoc />
public async Task<UserDetailDto?> Handle(UpdateIdentityUserCommand request, CancellationToken cancellationToken)
{
// 1. 获取操作者档案并判断权限
var currentTenantId = tenantProvider.GetCurrentTenantId();
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
// 2. 校验跨租户访问权限
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
{
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户更新用户");
}
// 3. 获取用户实体
// 2. (空行后) 获取用户实体
var user = await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
if (user == null)
{
return null;
}
if (!isSuperAdmin && user.TenantId != currentTenantId)
{
return null;
}
// 4. 规范化输入并校验唯一性
// 3. (空行后) 规范化输入并校验唯一性
var portal = user.Portal;
var tenantId = user.TenantId;
if (portal == PortalType.Tenant && (!tenantId.HasValue || tenantId.Value == 0))
@@ -93,14 +78,14 @@ public sealed class UpdateIdentityUserCommandHandler(
}
}
// 5. 更新用户字段
// 4. 更新用户字段
user.DisplayName = displayName;
user.Phone = phone;
user.Email = email;
user.Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim();
user.RowVersion = request.RowVersion;
// 6. 构建操作日志消息
// 5. 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -128,7 +113,7 @@ public sealed class UpdateIdentityUserCommandHandler(
Success = true
};
// 7. 持久化用户更新并写入 Outbox
// 6. 持久化用户更新并写入 Outbox
try
{
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
@@ -139,13 +124,13 @@ public sealed class UpdateIdentityUserCommandHandler(
throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试");
}
// 8. 覆盖角色绑定(仅当显式传入时)
// 7. 覆盖角色绑定(仅当显式传入时)
if (roleIds != null)
{
await userRoleRepository.ReplaceUserRolesAsync(portal, tenantId, user.Id, roleIds, cancellationToken);
}
// 9. 返回用户详情
// 8. 返回用户详情
return await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
}

View File

@@ -3,7 +3,8 @@ using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -11,8 +12,7 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// 更新角色处理器。
/// </summary>
public sealed class UpdateRoleCommandHandler(
IRoleRepository roleRepository,
ITenantProvider tenantProvider)
IRoleRepository roleRepository)
: IRequestHandler<UpdateRoleCommand, RoleDto?>
{
/// <summary>
@@ -26,23 +26,29 @@ public sealed class UpdateRoleCommandHandler(
// 1. 固定更新租户侧角色
var portal = PortalType.Tenant;
// 2. 获取租户上下文并查询角色
var tenantId = request.TenantId ?? tenantProvider.GetCurrentTenantId();
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. (空行后) 获取租户标识并查询角色
var tenantId = request.TenantId.Value;
var role = await roleRepository.FindByIdAsync(portal, tenantId, request.RoleId, cancellationToken);
if (role == null)
{
return null;
}
// 3. 更新字段
// 4. 更新字段
role.Name = request.Name;
role.Description = request.Description;
// 4. 持久化
// 5. 持久化
await roleRepository.UpdateAsync(role, cancellationToken);
await roleRepository.SaveChangesAsync(cancellationToken);
// 5. 返回 DTO
// 6. 返回 DTO
return new RoleDto
{
Id = role.Id,

View File

@@ -1,15 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 按用户 ID 获取角色/权限概览。
/// </summary>
public sealed class GetUserPermissionsQuery : IRequest<UserPermissionDto?>
{
/// <summary>
/// 用户 ID雪花
/// </summary>
public long UserId { get; init; }
}

View File

@@ -1,36 +0,0 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 按租户分页查询用户的角色/权限概览。
/// </summary>
public sealed class SearchUserPermissionsQuery : IRequest<PagedResult<UserPermissionDto>>
{
/// <summary>
/// 关键字(账号或展示名称)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 页码,从 1 开始。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段account/displayName/createdAt
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否倒序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -6,8 +6,6 @@ using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Results;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Services;
@@ -23,8 +21,7 @@ public sealed class AdminAuthService(
IMenuRepository menuRepository,
IPasswordHasher<IdentityUser> passwordHasher,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
ITenantProvider tenantProvider) : IAdminAuthService
IRefreshTokenStore refreshTokenStore) : IAdminAuthService
{
/// <summary>
/// 管理后台登录:验证账号密码并生成令牌。
@@ -159,92 +156,6 @@ public sealed class AdminAuthService(
return menu;
}
/// <summary>
/// 获取指定用户的权限概览(校验当前租户)。
/// </summary>
public async Task<UserPermissionDto?> GetUserPermissionsAsync(long userId, CancellationToken cancellationToken = default)
{
var tenantId = tenantProvider.GetCurrentTenantId();
var user = await userRepository.FindByIdAsync(userId, cancellationToken);
if (user == null || user.TenantId != tenantId)
{
return null;
}
// 1. 解析角色集合
var roleCodes = await ResolveUserRolesAsync(user.Portal, user.TenantId, user.Id, cancellationToken);
// 2. (空行后) 解析权限集合
var permissionCodes = await ResolveUserPermissionsAsync(user.Portal, user.TenantId, user.Id, cancellationToken);
// 3. (空行后) 返回概览
return new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = roleCodes,
Permissions = permissionCodes,
CreatedAt = user.CreatedAt
};
}
/// <summary>
/// 按租户分页查询用户权限概览。
/// </summary>
public async Task<PagedResult<UserPermissionDto>> SearchUserPermissionsAsync(
string? keyword,
int page,
int pageSize,
string? sortBy,
bool sortDescending,
CancellationToken cancellationToken = default)
{
// 1. 获取当前租户
var tenantId = tenantProvider.GetCurrentTenantId();
// 2. (空行后) 查询用户列表
var users = await userRepository.SearchAsync(tenantId, keyword, cancellationToken);
// 3. (空行后) 排序
var sorted = sortBy?.ToLowerInvariant() switch
{
"account" => sortDescending
? users.OrderByDescending(x => x.Account)
: users.OrderBy(x => x.Account),
"displayname" => sortDescending
? users.OrderByDescending(x => x.DisplayName)
: users.OrderBy(x => x.DisplayName),
_ => sortDescending
? users.OrderByDescending(x => x.CreatedAt)
: users.OrderBy(x => x.CreatedAt)
};
// 4. (空行后) 分页
var paged = sorted
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
// 5. (空行后) 解析角色与权限
var resolved = await ResolveRolesAndPermissionsAsync(PortalType.Tenant, tenantId, paged, cancellationToken);
// 6. (空行后) 映射为 DTO
var items = paged.Select(user => new UserPermissionDto
{
UserId = user.Id,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = resolved[user.Id].roles,
Permissions = resolved[user.Id].permissions,
CreatedAt = user.CreatedAt
}).ToList();
// 7. (空行后) 返回分页结果
return new PagedResult<UserPermissionDto>(items, page, pageSize, users.Count);
}
private async Task<CurrentUserProfile> BuildProfileAsync(IdentityUser user, CancellationToken cancellationToken)
{
// 1. 解析角色集合
@@ -495,68 +406,4 @@ public sealed class AdminAuthService(
var permissions = await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private async Task<Dictionary<long, (string[] roles, string[] permissions)>> ResolveRolesAndPermissionsAsync(
PortalType portal,
long? tenantId,
IReadOnlyCollection<IdentityUser> users,
CancellationToken cancellationToken)
{
// 1. 读取用户-角色关系
var userIds = users.Select(x => x.Id).ToArray();
var userRoleRelations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray();
// 2. (空行后) 读取角色定义
var roles = roleIds.Length == 0
? Array.Empty<Role>()
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer<long>.Default);
// 3. (空行后) 读取角色-权限关系
var rolePermissions = roleIds.Length == 0
? Array.Empty<RolePermission>()
: await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken);
// 4. (空行后) 读取权限定义
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
var permissions = permissionIds.Length == 0
? Array.Empty<Permission>()
: await permissionRepository.GetByIdsAsync(permissionIds, cancellationToken);
var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer<long>.Default);
// 5. (空行后) 构建 Role -> PermissionId[] 映射
var rolePermissionsLookup = rolePermissions
.GroupBy(rp => rp.RoleId)
.ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer<long>.Default);
// 6. (空行后) 按用户聚合角色码与权限码
var result = new Dictionary<long, (string[] roles, string[] permissions)>();
foreach (var userId in userIds)
{
// 6.1 解析用户角色码
var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray();
var roleCodes = rolesForUser
.Select(rid => roleCodeMap.GetValueOrDefault(rid))
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// 6.2 (空行后) 解析用户权限码
var permissionCodes = rolesForUser
.SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty<long>())
.Select(pid => permissionCodeMap.GetValueOrDefault(pid))
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// 6.3 (空行后) 写入结果
result[userId] = (roleCodes, permissionCodes);
}
// 7. (空行后) 返回聚合结果
return result;
}
}

View File

@@ -1,148 +0,0 @@
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;
/// <summary>
/// 小程序认证服务实现。
/// </summary>
public sealed class MiniAuthService(
IWeChatAuthService weChatAuthService,
IMiniUserRepository miniUserRepository,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
ILoginRateLimiter rateLimiter,
IHttpContextAccessor httpContextAccessor,
ITenantProvider tenantProvider) : IMiniAuthService
{
/// <summary>
/// 微信小程序登录:通过微信 code 获取用户信息并生成令牌。
/// </summary>
/// <param name="request">微信登录请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>令牌响应</returns>
/// <exception cref="BusinessException">获取微信用户信息失败、缺少租户标识时抛出</exception>
public async Task<TokenResponse> LoginWithWeChatAsync(WeChatLoginRequest request, CancellationToken cancellationToken = default)
{
// 1. 限流检查(基于 IP 地址)
var throttleKey = BuildThrottleKey();
await rateLimiter.EnsureAllowedAsync(throttleKey, cancellationToken);
// 2. 通过微信 code 获取 sessionOpenId、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);
}
/// <summary>
/// 刷新访问令牌:使用刷新令牌获取新的访问令牌和刷新令牌。
/// </summary>
/// <param name="request">刷新令牌请求</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>新的令牌响应</returns>
/// <exception cref="BusinessException">刷新令牌无效、已过期或用户不存在时抛出</exception>
public async Task<TokenResponse> 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);
}
/// <summary>
/// 获取用户档案。
/// </summary>
/// <param name="userId">用户 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>用户档案</returns>
/// <exception cref="BusinessException">用户不存在时抛出</exception>
public async Task<CurrentUserProfile> GetProfileAsync(long userId, CancellationToken cancellationToken = default)
{
var user = await miniUserRepository.FindByIdAsync(userId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
return BuildProfile(user);
}
/// <summary>
/// 获取或绑定小程序用户:如果 OpenId 已存在则返回现有用户,否则创建新用户。
/// </summary>
/// <param name="openId">微信 OpenId</param>
/// <param name="unionId">微信 UnionId可选</param>
/// <param name="nickname">昵称</param>
/// <param name="avatar">头像地址(可选)</param>
/// <param name="tenantId">租户 ID</param>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>用户实体和是否为新用户的元组</returns>
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<string>(),
Permissions = Array.Empty<string>(),
Avatar = user.Avatar
};
private string BuildThrottleKey()
{
var ip = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress ?? IPAddress.Loopback;
return $"mini-login:{ip}";
}
}