refactor: 用户管理仅平台管理员

This commit is contained in:
2026-01-30 02:10:32 +00:00
parent 45d08a79df
commit 6143943bf0
23 changed files with 429 additions and 342 deletions

View File

@@ -9,11 +9,6 @@ namespace TakeoutSaaS.Application.Identity.Commands;
/// </summary>
public sealed record BatchIdentityUserOperationCommand : IRequest<BatchIdentityUserOperationResult>
{
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 操作类型。
/// </summary>

View File

@@ -13,11 +13,6 @@ public sealed record ChangeIdentityUserStatusCommand : IRequest<bool>
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 目标状态。
/// </summary>

View File

@@ -10,11 +10,6 @@ namespace TakeoutSaaS.Application.Identity.Commands;
/// </summary>
public sealed record CreateIdentityUserCommand : IRequest<UserDetailDto>
{
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 登录账号。
/// </summary>

View File

@@ -11,9 +11,4 @@ public sealed record DeleteIdentityUserCommand : IRequest<bool>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
}

View File

@@ -12,9 +12,4 @@ public sealed record ResetIdentityUserPasswordCommand : IRequest<ResetIdentityUs
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
}

View File

@@ -11,9 +11,4 @@ public sealed record RestoreIdentityUserCommand : IRequest<bool>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
}

View File

@@ -14,11 +14,6 @@ public sealed record UpdateIdentityUserCommand : IRequest<UserDetailDto?>
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 展示名称。
/// </summary>

View File

@@ -32,14 +32,7 @@ public sealed class BatchIdentityUserOperationCommandHandler(
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. (空行后) 解析用户 ID 列表
var tenantId = request.TenantId.Value;
// 2. (空行后) 解析用户 ID 列表
var userIds = ParseIds(request.UserIds, "用户");
if (userIds.Length == 0)
{
@@ -52,34 +45,13 @@ public sealed class BatchIdentityUserOperationCommandHandler(
};
}
// 4. 查询目标用户集合
// 3. (空行后) 查询目标用户集合(仅平台管理员)
var portal = PortalType.Admin;
var includeDeleted = request.Operation == IdentityUserBatchOperation.Restore;
var users = await identityUserRepository.GetForUpdateByIdsAsync(tenantId, userIds, includeDeleted, cancellationToken);
var users = await identityUserRepository.GetForUpdateByIdsAsync(portal, null, userIds, includeDeleted, cancellationToken);
var usersById = users.ToDictionary(user => user.Id, user => user, EqualityComparer<long>.Default);
// 5. 预计算租户管理员约束
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenantId, "tenant-admin", cancellationToken);
var tenantAdminUserIds = tenantAdminRole == null
? Array.Empty<long>()
: (await userRoleRepository.GetByUserIdsAsync(PortalType.Tenant, tenantId, usersById.Keys, cancellationToken))
.Where(x => x.RoleId == tenantAdminRole.Id)
.Select(x => x.UserId)
.Distinct()
.ToArray();
var activeAdminCount = tenantAdminRole == null
? 0
: (await identityUserRepository.SearchPagedAsync(new IdentityUserSearchFilter
{
TenantId = tenantId,
RoleId = tenantAdminRole.Id,
Status = IdentityUserStatus.Active,
IncludeDeleted = false,
Page = 1,
PageSize = 1
}, cancellationToken)).Total;
var remainingActiveAdmins = activeAdminCount;
// 6. 执行批量操作
// 4. (空行后) 执行批量操作
var failures = new List<BatchIdentityUserFailureItem>();
var successCount = 0;
var exportItems = new List<UserListItemDto>();
@@ -107,36 +79,12 @@ public sealed class BatchIdentityUserOperationCommandHandler(
successCount++;
break;
case IdentityUserBatchOperation.Disable:
if (user.Status == IdentityUserStatus.Active
&& tenantAdminUserIds.Contains(user.Id)
&& remainingActiveAdmins <= 1)
{
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
}
if (user.Status == IdentityUserStatus.Active && tenantAdminUserIds.Contains(user.Id))
{
remainingActiveAdmins--;
}
user.Status = IdentityUserStatus.Disabled;
user.LockedUntil = null;
await identityUserRepository.SaveChangesAsync(cancellationToken);
successCount++;
break;
case IdentityUserBatchOperation.Delete:
if (user.Status == IdentityUserStatus.Active
&& tenantAdminUserIds.Contains(user.Id)
&& remainingActiveAdmins <= 1)
{
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
}
if (user.Status == IdentityUserStatus.Active && tenantAdminUserIds.Contains(user.Id))
{
remainingActiveAdmins--;
}
await identityUserRepository.RemoveAsync(user, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);
successCount++;
@@ -176,10 +124,10 @@ public sealed class BatchIdentityUserOperationCommandHandler(
});
}
}
// 6.1 处理导出数据
// 5. (空行后) 处理导出数据
if (request.Operation == IdentityUserBatchOperation.Export)
{
var roleCodesLookup = await ResolveRoleCodesAsync(users, userRoleRepository, roleRepository, cancellationToken);
var roleCodesLookup = await ResolveRoleCodesAsync(portal, users, userRoleRepository, roleRepository, cancellationToken);
var now = DateTime.UtcNow;
exportItems.AddRange(users.Select(user => new UserListItemDto
{
@@ -200,7 +148,7 @@ public sealed class BatchIdentityUserOperationCommandHandler(
}));
}
// 7. 构建操作日志消息
// 6. (空行后) 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -216,12 +164,12 @@ public sealed class BatchIdentityUserOperationCommandHandler(
TargetIds = JsonSerializer.Serialize(userIds),
OperatorId = currentUserAccessor.UserId.ToString(),
OperatorName = operatorName,
Parameters = JsonSerializer.Serialize(new { tenantId, operation = request.Operation.ToString() }),
Parameters = JsonSerializer.Serialize(new { portal = portal.ToString(), operation = request.Operation.ToString() }),
Result = JsonSerializer.Serialize(new { successCount, failureCount = failures.Count }),
Success = failures.Count == 0
};
// 8. 写入 Outbox 并保存变更
// 7. (空行后) 写入 Outbox 并保存变更
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);
@@ -259,6 +207,7 @@ public sealed class BatchIdentityUserOperationCommandHandler(
}
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
PortalType portal,
IReadOnlyList<IdentityUser> users,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
@@ -267,42 +216,37 @@ public sealed class BatchIdentityUserOperationCommandHandler(
// 1. 预分配字典容量
var result = new Dictionary<long, string[]>(users.Count);
// 2. 按租户分组,降低角色查询次数
foreach (var group in users.GroupBy(user => new { user.Portal, user.TenantId }))
// 2. (空行后) 提取用户 ID 集合
var userIds = users.Select(user => user.Id).Distinct().ToArray();
if (userIds.Length == 0)
{
var portal = group.Key.Portal;
var tenantId = group.Key.TenantId;
var userIds = group.Select(user => user.Id).Distinct().ToArray();
if (userIds.Length == 0)
{
continue;
}
return result;
}
// 3. 查询用户角色映射
var relations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
if (relations.Count == 0)
{
continue;
}
// 3. (空行后) 查询用户角色映射
var relations = await userRoleRepository.GetByUserIdsAsync(portal, null, userIds, cancellationToken);
if (relations.Count == 0)
{
return result;
}
// 4. 查询角色并构建映射
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
var roles = roleIds.Length == 0
? Array.Empty<Role>()
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
// 4. (空行后) 查询角色并构建映射
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
var roles = roleIds.Length == 0
? Array.Empty<Role>()
: await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
// 5. 组装用户角色编码列表
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
{
var codes = relationGroup
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
.OfType<string>()
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
}
// 5. (空行后) 组装用户角色编码列表
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
{
var codes = relationGroup
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
.OfType<string>()
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
}
return result;

View File

@@ -16,8 +16,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// </summary>
public sealed class ChangeIdentityUserStatusCommandHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher)
@@ -36,13 +34,10 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
return false;
}
// 3. 校验租户管理员保留规则(仅租户侧用户适用)
if (user.Portal == PortalType.Tenant
&& request.Status == IdentityUserStatus.Disabled
&& user.Status == IdentityUserStatus.Active
&& user.TenantId.HasValue)
// 3. (空行后) 限定仅允许操作平台管理员账号
if (user.Portal != PortalType.Admin || user.TenantId is not null)
{
await EnsureNotLastActiveTenantAdminAsync(user.TenantId.Value, user.Id, cancellationToken);
return false;
}
// 4. 更新状态
@@ -85,7 +80,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
Parameters = JsonSerializer.Serialize(new
{
userId = user.Id,
tenantId = user.TenantId,
portal = PortalType.Admin.ToString(),
previousStatus = previousStatus.ToString(),
currentStatus = user.Status.ToString()
}),
@@ -99,37 +94,4 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
return true;
}
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, CancellationToken cancellationToken)
{
// 1. 获取租户管理员角色
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenantId, "tenant-admin", cancellationToken);
if (tenantAdminRole == null)
{
return;
}
// 2. 判断用户是否为租户管理员
var relations = await userRoleRepository.GetByUserIdAsync(PortalType.Tenant, tenantId, userId, cancellationToken);
if (!relations.Any(x => x.RoleId == tenantAdminRole.Id))
{
return;
}
// 3. 统计活跃管理员数量
var filter = new IdentityUserSearchFilter
{
TenantId = tenantId,
RoleId = tenantAdminRole.Id,
Status = IdentityUserStatus.Active,
IncludeDeleted = false,
Page = 1,
PageSize = 1
};
var result = await identityUserRepository.SearchPagedAsync(filter, cancellationToken);
if (result.Total <= 1)
{
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
}
}
}

View File

@@ -37,56 +37,48 @@ public sealed class CreateIdentityUserCommandHandler(
// 1. 获取操作者档案(用于操作日志)
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
// 2. (空行后) 校验租户参数
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
{
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
}
// 3. (空行后) 规范化输入并准备校验
var tenantId = request.TenantId.Value;
// 2. (空行后) 规范化输入并准备校验
var portal = PortalType.Admin;
var account = request.Account.Trim();
var displayName = request.DisplayName.Trim();
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
var roleIds = ParseIds(request.RoleIds, "角色");
// 4. 唯一性校验
if (await identityUserRepository.ExistsByAccountAsync(tenantId, account, null, cancellationToken))
// 3. 唯一性校验
if (await identityUserRepository.ExistsByAccountAsync(portal, null, account, null, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, "账号已存在");
}
if (!string.IsNullOrWhiteSpace(phone)
&& await identityUserRepository.ExistsByPhoneAsync(tenantId, phone, null, cancellationToken))
&& await identityUserRepository.ExistsByPhoneAsync(portal, null, phone, null, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
}
if (!string.IsNullOrWhiteSpace(email)
&& await identityUserRepository.ExistsByEmailAsync(tenantId, email, null, cancellationToken))
&& await identityUserRepository.ExistsByEmailAsync(portal, null, email, null, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
}
// 5. 校验角色合法性
// 4. 校验角色合法性
if (roleIds.Length > 0)
{
var portal = PortalType.Tenant;
var roles = await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
var roles = await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
if (roles.Count != roleIds.Length)
{
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
}
}
// 6. 创建用户实体
var userPortal = PortalType.Tenant;
// 5. 创建用户实体
var user = new IdentityUser
{
Id = idGenerator.NextId(),
Portal = userPortal,
TenantId = tenantId,
Portal = portal,
TenantId = null,
Account = account,
DisplayName = displayName,
Phone = phone,
@@ -97,11 +89,12 @@ public sealed class CreateIdentityUserCommandHandler(
LockedUntil = null,
LastLoginAt = null,
MustChangePassword = false,
MerchantId = null,
PasswordHash = string.Empty
};
user.PasswordHash = passwordHasher.HashPassword(user, request.Password);
// 7. 构建操作日志消息
// 6. 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -119,7 +112,7 @@ public sealed class CreateIdentityUserCommandHandler(
OperatorName = operatorName,
Parameters = JsonSerializer.Serialize(new
{
tenantId,
portal = portal.ToString(),
account,
displayName,
phone,
@@ -130,18 +123,18 @@ public sealed class CreateIdentityUserCommandHandler(
Success = true
};
// 8. 持久化用户并写入 Outbox
// 7. 持久化用户并写入 Outbox
await identityUserRepository.AddAsync(user, cancellationToken);
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);
// 9. 绑定角色
// 8. 绑定角色
if (roleIds.Length > 0)
{
await userRoleRepository.ReplaceUserRolesAsync(userPortal, tenantId, user.Id, roleIds, cancellationToken);
await userRoleRepository.ReplaceUserRolesAsync(portal, null, user.Id, roleIds, cancellationToken);
}
// 10. 返回用户详情
// 9. 返回用户详情
var detail = await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
return detail ?? new UserDetailDto
{

View File

@@ -5,8 +5,6 @@ using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Events;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -16,8 +14,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// </summary>
public sealed class DeleteIdentityUserCommandHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
ICurrentUserAccessor currentUserAccessor,
IAdminAuthService adminAuthService,
IIdentityOperationLogPublisher operationLogPublisher)
@@ -36,10 +32,10 @@ public sealed class DeleteIdentityUserCommandHandler(
return false;
}
// 3. 校验租户管理员保留规则(仅租户侧用户适用)
if (user.Portal == PortalType.Tenant && user.Status == IdentityUserStatus.Active && user.TenantId.HasValue)
// 3. (空行后) 限定仅允许删除平台管理员账号
if (user.Portal != PortalType.Admin || user.TenantId is not null)
{
await EnsureNotLastActiveTenantAdminAsync(user.TenantId.Value, user.Id, cancellationToken);
return false;
}
// 4. 构建操作日志消息
@@ -58,7 +54,7 @@ public sealed class DeleteIdentityUserCommandHandler(
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
OperatorId = currentUserAccessor.UserId.ToString(),
OperatorName = operatorName,
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
Parameters = JsonSerializer.Serialize(new { userId = user.Id, portal = PortalType.Admin.ToString() }),
Result = JsonSerializer.Serialize(new { userId = user.Id }),
Success = true
};
@@ -70,37 +66,4 @@ public sealed class DeleteIdentityUserCommandHandler(
return true;
}
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, CancellationToken cancellationToken)
{
// 1. 获取租户管理员角色
var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenantId, "tenant-admin", cancellationToken);
if (tenantAdminRole == null)
{
return;
}
// 2. 判断用户是否为租户管理员
var relations = await userRoleRepository.GetByUserIdAsync(PortalType.Tenant, tenantId, userId, cancellationToken);
if (!relations.Any(x => x.RoleId == tenantAdminRole.Id))
{
return;
}
// 3. 统计活跃管理员数量
var filter = new IdentityUserSearchFilter
{
TenantId = tenantId,
RoleId = tenantAdminRole.Id,
Status = IdentityUserStatus.Active,
IncludeDeleted = false,
Page = 1,
PageSize = 1
};
var result = await identityUserRepository.SearchPagedAsync(filter, cancellationToken);
if (result.Total <= 1)
{
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
}
}
}

View File

@@ -31,23 +31,28 @@ public sealed class GetIdentityUserDetailQueryHandler(
return null;
}
// 2. 加载角色与权限
var portal = user.Portal;
var tenantId = user.TenantId;
// 2. (空行后) 限定仅允许查看平台管理员账号
if (user.Portal != PortalType.Admin || user.TenantId is not null)
{
return null;
}
// 3. 查询用户角色关系
var roleRelations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, user.Id, cancellationToken);
// 3. (空行后) 加载角色与权限
var portal = PortalType.Admin;
// 4. 查询用户角色关系
var roleRelations = await userRoleRepository.GetByUserIdAsync(portal, null, user.Id, cancellationToken);
var roleIds = roleRelations.Select(x => x.RoleId).Distinct().ToArray();
var roles = roleIds.Length == 0
? Array.Empty<Role>()
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
: await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
var roleCodes = roles.Select(x => x.Code)
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var permissionIds = roleIds.Length == 0
? Array.Empty<long>()
: (await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken))
: (await rolePermissionRepository.GetByRoleIdsAsync(portal, null, roleIds, cancellationToken))
.Select(x => x.PermissionId)
.Distinct()
.ToArray();
@@ -59,7 +64,7 @@ public sealed class GetIdentityUserDetailQueryHandler(
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
// 4. 组装详情 DTO
// 5. 组装详情 DTO
var now = DateTime.UtcNow;
return new UserDetailDto
{

View File

@@ -36,11 +36,17 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
}
// 3. 签发重置令牌1 小时有效)
// 3. (空行后) 限定仅允许重置平台管理员账号
if (user.Portal != PortalType.Admin || user.TenantId is not null)
{
throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
}
// 4. (空行后) 签发重置令牌1 小时有效)
var expiresAt = DateTime.UtcNow.AddHours(1);
var token = await tokenStore.IssueAsync(user.Id, expiresAt, cancellationToken);
// 4. 标记用户需重置密码
// 5. (空行后) 标记用户需重置密码
user.MustChangePassword = true;
user.FailedLoginCount = 0;
user.LockedUntil = null;
@@ -49,7 +55,7 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
user.Status = IdentityUserStatus.Active;
}
// 5. 构建操作日志消息
// 6. (空行后) 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -65,12 +71,12 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
OperatorId = currentUserAccessor.UserId.ToString(),
OperatorName = operatorName,
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
Parameters = JsonSerializer.Serialize(new { userId = user.Id, portal = PortalType.Admin.ToString() }),
Result = JsonSerializer.Serialize(new { userId = user.Id, expiresAt }),
Success = true
};
// 6. 写入 Outbox 并保存变更
// 7. (空行后) 写入 Outbox 并保存变更
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
await identityUserRepository.SaveChangesAsync(cancellationToken);

View File

@@ -3,9 +3,8 @@ using System.Text.Json;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Events;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Security;
namespace TakeoutSaaS.Application.Identity.Handlers;
@@ -33,12 +32,18 @@ public sealed class RestoreIdentityUserCommandHandler(
return false;
}
// 3. (空行后) 限定仅允许恢复平台管理员账号
if (user.Portal != PortalType.Admin || user.TenantId is not null)
{
return false;
}
if (!user.DeletedAt.HasValue)
{
return false;
}
// 3. (空行后) 构建操作日志消息
// 4. (空行后) 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -54,12 +59,12 @@ public sealed class RestoreIdentityUserCommandHandler(
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
OperatorId = currentUserAccessor.UserId.ToString(),
OperatorName = operatorName,
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
Parameters = JsonSerializer.Serialize(new { userId = user.Id, portal = PortalType.Admin.ToString() }),
Result = JsonSerializer.Serialize(new { userId = user.Id }),
Success = true
};
// 4. 恢复软删除状态并写入 Outbox
// 5. 恢复软删除状态并写入 Outbox
user.DeletedAt = null;
user.DeletedBy = null;
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);

View File

@@ -21,9 +21,11 @@ public sealed class SearchIdentityUsersQueryHandler(
public async Task<PagedResult<UserListItemDto>> Handle(SearchIdentityUsersQuery request, CancellationToken cancellationToken)
{
// 1. 组装查询过滤条件
var portal = PortalType.Admin;
var filter = new IdentityUserSearchFilter
{
TenantId = request.TenantId,
Portal = portal,
TenantId = null,
Keyword = request.Keyword,
Status = request.Status,
RoleId = request.RoleId,
@@ -46,7 +48,7 @@ public sealed class SearchIdentityUsersQueryHandler(
}
// 3. 加载角色编码映射
var roleCodesLookup = await ResolveRoleCodesAsync(items, userRoleRepository, roleRepository, cancellationToken);
var roleCodesLookup = await ResolveRoleCodesAsync(portal, items, userRoleRepository, roleRepository, cancellationToken);
// 4. 组装 DTO
var now = DateTime.UtcNow;
@@ -77,6 +79,7 @@ public sealed class SearchIdentityUsersQueryHandler(
|| (user.LockedUntil.HasValue && user.LockedUntil.Value > now);
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
PortalType portal,
IReadOnlyList<IdentityUser> users,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
@@ -85,42 +88,37 @@ public sealed class SearchIdentityUsersQueryHandler(
// 1. 预分配字典容量
var result = new Dictionary<long, string[]>(users.Count);
// 2. 按 Portal + TenantId 分组,降低角色查询次数
foreach (var group in users.GroupBy(user => new { user.Portal, user.TenantId }))
// 2. (空行后) 提取用户 ID 集合
var userIds = users.Select(user => user.Id).Distinct().ToArray();
if (userIds.Length == 0)
{
var portal = group.Key.Portal;
var tenantId = group.Key.TenantId;
var userIds = group.Select(user => user.Id).Distinct().ToArray();
if (userIds.Length == 0)
{
continue;
}
return result;
}
// 3. 查询用户角色映射
var relations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
if (relations.Count == 0)
{
continue;
}
// 3. (空行后) 查询用户角色映射
var relations = await userRoleRepository.GetByUserIdsAsync(portal, null, userIds, cancellationToken);
if (relations.Count == 0)
{
return result;
}
// 4. 查询角色并构建映射
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
var roles = roleIds.Length == 0
? Array.Empty<Role>()
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
// 4. (空行后) 查询角色并构建映射
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
var roles = roleIds.Length == 0
? Array.Empty<Role>()
: await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
// 5. 组装用户角色编码列表
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
{
var codes = relationGroup
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
.OfType<string>()
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
}
// 5. (空行后) 组装用户角色编码列表
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
{
var codes = relationGroup
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
.OfType<string>()
.Where(code => !string.IsNullOrWhiteSpace(code))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
}
return result;

View File

@@ -40,52 +40,50 @@ public sealed class UpdateIdentityUserCommandHandler(
return null;
}
// 3. (空行后) 规范化输入并校验唯一性
var portal = user.Portal;
var tenantId = user.TenantId;
if (portal == PortalType.Tenant && (!tenantId.HasValue || tenantId.Value == 0))
// 3. (空行后) 限定仅允许更新平台管理员账号
if (user.Portal != PortalType.Admin || user.TenantId is not null)
{
throw new BusinessException(ErrorCodes.InternalServerError, "用户缺少有效的租户标识");
return null;
}
// 4. (空行后) 规范化输入并校验唯一性
var portal = PortalType.Admin;
var displayName = request.DisplayName.Trim();
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
var roleIds = request.RoleIds == null ? null : ParseIds(request.RoleIds, "角色");
if (portal == PortalType.Tenant
&& !string.IsNullOrWhiteSpace(phone)
if (!string.IsNullOrWhiteSpace(phone)
&& !string.Equals(phone, user.Phone, StringComparison.OrdinalIgnoreCase)
&& await identityUserRepository.ExistsByPhoneAsync(tenantId!.Value, phone, user.Id, cancellationToken))
&& await identityUserRepository.ExistsByPhoneAsync(portal, null, phone, user.Id, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
}
if (portal == PortalType.Tenant
&& !string.IsNullOrWhiteSpace(email)
if (!string.IsNullOrWhiteSpace(email)
&& !string.Equals(email, user.Email, StringComparison.OrdinalIgnoreCase)
&& await identityUserRepository.ExistsByEmailAsync(tenantId!.Value, email, user.Id, cancellationToken))
&& await identityUserRepository.ExistsByEmailAsync(portal, null, email, user.Id, cancellationToken))
{
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
}
if (roleIds is { Length: > 0 })
{
var roles = await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
var roles = await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
if (roles.Count != roleIds.Length)
{
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
}
}
// 4. 更新用户字段
// 5. 更新用户字段
user.DisplayName = displayName;
user.Phone = phone;
user.Email = email;
user.Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim();
user.RowVersion = request.RowVersion;
// 5. 构建操作日志消息
// 6. 构建操作日志消息
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
? operatorProfile.Account
: operatorProfile.DisplayName;
@@ -104,6 +102,7 @@ public sealed class UpdateIdentityUserCommandHandler(
Parameters = JsonSerializer.Serialize(new
{
userId = user.Id,
portal = portal.ToString(),
displayName,
phone,
email,
@@ -113,7 +112,7 @@ public sealed class UpdateIdentityUserCommandHandler(
Success = true
};
// 6. 持久化用户更新并写入 Outbox
// 7. 持久化用户更新并写入 Outbox
try
{
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
@@ -124,13 +123,13 @@ public sealed class UpdateIdentityUserCommandHandler(
throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试");
}
// 7. 覆盖角色绑定(仅当显式传入时)
// 8. 覆盖角色绑定(仅当显式传入时)
if (roleIds != null)
{
await userRoleRepository.ReplaceUserRolesAsync(portal, tenantId, user.Id, roleIds, cancellationToken);
await userRoleRepository.ReplaceUserRolesAsync(portal, null, user.Id, roleIds, cancellationToken);
}
// 8. 返回用户详情
// 9. 返回用户详情
return await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
}

View File

@@ -10,11 +10,6 @@ namespace TakeoutSaaS.Application.Identity.Queries;
/// </summary>
public sealed record SearchIdentityUsersQuery : IRequest<PagedResult<UserListItemDto>>
{
/// <summary>
/// 租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 关键字(账号/姓名/手机号/邮箱)。
/// </summary>

View File

@@ -19,6 +19,5 @@ public sealed class BatchIdentityUserOperationCommandValidator : AbstractValidat
RuleForEach(x => x.UserIds)
.Must(value => long.TryParse(value, out _))
.WithMessage("用户 ID 必须为有效的数字字符串");
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
}
}

View File

@@ -17,7 +17,6 @@ public sealed class CreateIdentityUserCommandValidator : AbstractValidator<Creat
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().Length(6, 32);
RuleFor(x => x.Avatar).MaximumLength(512);
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
RuleForEach(x => x.RoleIds)
.Must(value => long.TryParse(value, out _))
.WithMessage("角色 ID 必须为有效的数字字符串");

View File

@@ -17,7 +17,6 @@ public sealed class SearchIdentityUsersQueryValidator : AbstractValidator<Search
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.Keyword).MaximumLength(128);
RuleFor(x => x.SortBy).MaximumLength(64);
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
RuleFor(x => x.CreatedAtTo)
.GreaterThanOrEqualTo(x => x.CreatedAtFrom)
.When(x => x.CreatedAtFrom.HasValue && x.CreatedAtTo.HasValue);

View File

@@ -17,7 +17,6 @@ public sealed class UpdateIdentityUserCommandValidator : AbstractValidator<Updat
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
RuleFor(x => x.Avatar).MaximumLength(512);
RuleFor(x => x.RowVersion).NotEmpty();
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
RuleForEach(x => x.RoleIds)
.Must(value => long.TryParse(value, out _))
.WithMessage("角色 ID 必须为有效的数字字符串")