320 lines
13 KiB
C#
320 lines
13 KiB
C#
using MediatR;
|
|
using System.Text.Json;
|
|
using TakeoutSaaS.Application.Identity.Abstractions;
|
|
using TakeoutSaaS.Application.Identity.Commands;
|
|
using TakeoutSaaS.Application.Identity.Contracts;
|
|
using TakeoutSaaS.Application.Identity.Events;
|
|
using TakeoutSaaS.Application.Identity.Models;
|
|
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.Security;
|
|
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
|
|
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
|
|
|
/// <summary>
|
|
/// 批量用户操作处理器。
|
|
/// </summary>
|
|
public sealed class BatchIdentityUserOperationCommandHandler(
|
|
IIdentityUserRepository identityUserRepository,
|
|
IUserRoleRepository userRoleRepository,
|
|
IRoleRepository roleRepository,
|
|
ITenantProvider tenantProvider,
|
|
ICurrentUserAccessor currentUserAccessor,
|
|
IAdminAuthService adminAuthService,
|
|
IIdentityOperationLogPublisher operationLogPublisher)
|
|
: IRequestHandler<BatchIdentityUserOperationCommand, BatchIdentityUserOperationResult>
|
|
{
|
|
/// <inheritdoc />
|
|
public async Task<BatchIdentityUserOperationResult> Handle(BatchIdentityUserOperationCommand 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, "禁止跨租户批量操作用户");
|
|
}
|
|
|
|
if (isSuperAdmin && !request.TenantId.HasValue)
|
|
{
|
|
throw new BusinessException(ErrorCodes.BadRequest, "批量操作必须指定租户");
|
|
}
|
|
|
|
// 3. (空行后) 解析用户 ID 列表
|
|
var tenantId = request.TenantId ?? currentTenantId;
|
|
var userIds = ParseIds(request.UserIds, "用户");
|
|
if (userIds.Length == 0)
|
|
{
|
|
return new BatchIdentityUserOperationResult
|
|
{
|
|
SuccessCount = 0,
|
|
FailureCount = 0,
|
|
Failures = Array.Empty<BatchIdentityUserFailureItem>(),
|
|
ExportItems = Array.Empty<UserListItemDto>()
|
|
};
|
|
}
|
|
|
|
// 4. (空行后) 查询目标用户集合
|
|
var includeDeleted = request.Operation == IdentityUserBatchOperation.Restore;
|
|
var users = await identityUserRepository.GetForUpdateByIdsAsync(tenantId, userIds, includeDeleted, isSuperAdmin, cancellationToken);
|
|
var usersById = users.ToDictionary(user => user.Id, user => user, EqualityComparer<long>.Default);
|
|
|
|
// 5. (空行后) 预计算租户管理员约束
|
|
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
|
var tenantAdminUserIds = tenantAdminRole == null
|
|
? Array.Empty<long>()
|
|
: (await userRoleRepository.GetByUserIdsAsync(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
|
|
}, isSuperAdmin, cancellationToken)).Total;
|
|
var remainingActiveAdmins = activeAdminCount;
|
|
|
|
// 6. (空行后) 执行批量操作
|
|
var failures = new List<BatchIdentityUserFailureItem>();
|
|
var successCount = 0;
|
|
var exportItems = new List<UserListItemDto>();
|
|
foreach (var userId in userIds)
|
|
{
|
|
if (!usersById.TryGetValue(userId, out var user))
|
|
{
|
|
failures.Add(new BatchIdentityUserFailureItem
|
|
{
|
|
UserId = userId,
|
|
Reason = "用户不存在"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try
|
|
{
|
|
switch (request.Operation)
|
|
{
|
|
case IdentityUserBatchOperation.Enable:
|
|
user.Status = IdentityUserStatus.Active;
|
|
user.LockedUntil = null;
|
|
user.FailedLoginCount = 0;
|
|
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
|
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++;
|
|
break;
|
|
case IdentityUserBatchOperation.Restore:
|
|
if (!user.DeletedAt.HasValue)
|
|
{
|
|
throw new BusinessException(ErrorCodes.BadRequest, "用户未删除");
|
|
}
|
|
|
|
user.DeletedAt = null;
|
|
user.DeletedBy = null;
|
|
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
|
successCount++;
|
|
break;
|
|
case IdentityUserBatchOperation.Export:
|
|
successCount++;
|
|
break;
|
|
default:
|
|
throw new BusinessException(ErrorCodes.BadRequest, "无效的批量操作类型");
|
|
}
|
|
}
|
|
catch (Exception ex) when (IsConcurrencyException(ex))
|
|
{
|
|
failures.Add(new BatchIdentityUserFailureItem
|
|
{
|
|
UserId = userId,
|
|
Reason = "用户数据已被修改,请刷新后重试"
|
|
});
|
|
}
|
|
catch (BusinessException ex)
|
|
{
|
|
failures.Add(new BatchIdentityUserFailureItem
|
|
{
|
|
UserId = userId,
|
|
Reason = ex.Message
|
|
});
|
|
}
|
|
}
|
|
// 6.1 (空行后) 处理导出数据
|
|
if (request.Operation == IdentityUserBatchOperation.Export)
|
|
{
|
|
var roleCodesLookup = await ResolveRoleCodesAsync(users, userRoleRepository, roleRepository, cancellationToken);
|
|
var now = DateTime.UtcNow;
|
|
exportItems.AddRange(users.Select(user => new UserListItemDto
|
|
{
|
|
UserId = user.Id,
|
|
TenantId = user.TenantId,
|
|
Account = user.Account,
|
|
DisplayName = user.DisplayName,
|
|
Phone = user.Phone,
|
|
Email = user.Email,
|
|
Status = user.Status,
|
|
IsLocked = user.Status == IdentityUserStatus.Locked || (user.LockedUntil.HasValue && user.LockedUntil.Value > now),
|
|
IsDeleted = user.DeletedAt.HasValue,
|
|
Roles = roleCodesLookup.TryGetValue(user.Id, out var codes) ? codes : Array.Empty<string>(),
|
|
CreatedAt = user.CreatedAt,
|
|
LastLoginAt = user.LastLoginAt
|
|
}));
|
|
}
|
|
|
|
// 7. (空行后) 构建操作日志消息
|
|
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
|
? operatorProfile.Account
|
|
: operatorProfile.DisplayName;
|
|
if (string.IsNullOrWhiteSpace(operatorName))
|
|
{
|
|
operatorName = $"user:{currentUserAccessor.UserId}";
|
|
}
|
|
|
|
var logMessage = new IdentityUserOperationLogMessage
|
|
{
|
|
OperationType = "identity-user:batch",
|
|
TargetType = "identity_user",
|
|
TargetIds = JsonSerializer.Serialize(userIds),
|
|
OperatorId = currentUserAccessor.UserId.ToString(),
|
|
OperatorName = operatorName,
|
|
Parameters = JsonSerializer.Serialize(new { tenantId, operation = request.Operation.ToString() }),
|
|
Result = JsonSerializer.Serialize(new { successCount, failureCount = failures.Count }),
|
|
Success = failures.Count == 0
|
|
};
|
|
|
|
// 8. (空行后) 写入 Outbox 并保存变更
|
|
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
|
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
|
|
|
return new BatchIdentityUserOperationResult
|
|
{
|
|
SuccessCount = successCount,
|
|
FailureCount = failures.Count,
|
|
Failures = failures,
|
|
ExportItems = exportItems
|
|
};
|
|
}
|
|
|
|
private static long[] ParseIds(string[] values, string name)
|
|
{
|
|
// 1. 空数组直接返回
|
|
if (values.Length == 0)
|
|
{
|
|
return Array.Empty<long>();
|
|
}
|
|
|
|
// 2. (空行后) 解析并去重
|
|
var ids = new List<long>(values.Length);
|
|
foreach (var value in values)
|
|
{
|
|
if (!long.TryParse(value, out var id) || id <= 0)
|
|
{
|
|
throw new BusinessException(ErrorCodes.BadRequest, $"{name} ID 无效");
|
|
}
|
|
|
|
ids.Add(id);
|
|
}
|
|
|
|
// 3. (空行后) 返回去重结果
|
|
return ids.Distinct().ToArray();
|
|
}
|
|
|
|
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
|
IReadOnlyList<IdentityUser> users,
|
|
IUserRoleRepository userRoleRepository,
|
|
IRoleRepository roleRepository,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// 1. 预分配字典容量
|
|
var result = new Dictionary<long, string[]>(users.Count);
|
|
|
|
// 2. (空行后) 按租户分组,降低角色查询次数
|
|
foreach (var group in users.GroupBy(user => user.TenantId))
|
|
{
|
|
var tenantId = group.Key;
|
|
var userIds = group.Select(user => user.Id).Distinct().ToArray();
|
|
if (userIds.Length == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 3. (空行后) 查询用户角色映射
|
|
var relations = await userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
|
|
if (relations.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// 4. (空行后) 查询角色并构建映射
|
|
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
|
var roles = roleIds.Length == 0
|
|
? Array.Empty<Role>()
|
|
: await roleRepository.GetByIdsAsync(tenantId, 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;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static bool IsConcurrencyException(Exception exception)
|
|
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
|
}
|