refactor: 用户管理仅平台管理员
This commit is contained in:
@@ -9,11 +9,6 @@ namespace TakeoutSaaS.Application.Identity.Commands;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record BatchIdentityUserOperationCommand : IRequest<BatchIdentityUserOperationResult>
|
public sealed record BatchIdentityUserOperationCommand : IRequest<BatchIdentityUserOperationResult>
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// 目标租户 ID(超级管理员可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 操作类型。
|
/// 操作类型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ public sealed record ChangeIdentityUserStatusCommand : IRequest<bool>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long UserId { get; init; }
|
public long UserId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 目标租户 ID(超级管理员可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 目标状态。
|
/// 目标状态。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ namespace TakeoutSaaS.Application.Identity.Commands;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record CreateIdentityUserCommand : IRequest<UserDetailDto>
|
public sealed record CreateIdentityUserCommand : IRequest<UserDetailDto>
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// 目标租户 ID(超级管理员可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 登录账号。
|
/// 登录账号。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -11,9 +11,4 @@ public sealed record DeleteIdentityUserCommand : IRequest<bool>
|
|||||||
/// 用户 ID。
|
/// 用户 ID。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long UserId { get; init; }
|
public long UserId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 目标租户 ID(超级管理员可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,4 @@ public sealed record ResetIdentityUserPasswordCommand : IRequest<ResetIdentityUs
|
|||||||
/// 用户 ID。
|
/// 用户 ID。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long UserId { get; init; }
|
public long UserId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 目标租户 ID(超级管理员可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,4 @@ public sealed record RestoreIdentityUserCommand : IRequest<bool>
|
|||||||
/// 用户 ID。
|
/// 用户 ID。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public long UserId { get; init; }
|
public long UserId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 目标租户 ID(超级管理员可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ public sealed record UpdateIdentityUserCommand : IRequest<UserDetailDto?>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public long UserId { get; init; }
|
public long UserId { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 目标租户 ID(超级管理员可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 展示名称。
|
/// 展示名称。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -32,14 +32,7 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
|||||||
// 1. 获取操作者档案(用于操作日志)
|
// 1. 获取操作者档案(用于操作日志)
|
||||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||||
|
|
||||||
// 2. (空行后) 校验租户参数
|
// 2. (空行后) 解析用户 ID 列表
|
||||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. (空行后) 解析用户 ID 列表
|
|
||||||
var tenantId = request.TenantId.Value;
|
|
||||||
var userIds = ParseIds(request.UserIds, "用户");
|
var userIds = ParseIds(request.UserIds, "用户");
|
||||||
if (userIds.Length == 0)
|
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 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);
|
var usersById = users.ToDictionary(user => user.Id, user => user, EqualityComparer<long>.Default);
|
||||||
|
|
||||||
// 5. 预计算租户管理员约束
|
// 4. (空行后) 执行批量操作
|
||||||
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. 执行批量操作
|
|
||||||
var failures = new List<BatchIdentityUserFailureItem>();
|
var failures = new List<BatchIdentityUserFailureItem>();
|
||||||
var successCount = 0;
|
var successCount = 0;
|
||||||
var exportItems = new List<UserListItemDto>();
|
var exportItems = new List<UserListItemDto>();
|
||||||
@@ -107,36 +79,12 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
|||||||
successCount++;
|
successCount++;
|
||||||
break;
|
break;
|
||||||
case IdentityUserBatchOperation.Disable:
|
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.Status = IdentityUserStatus.Disabled;
|
||||||
user.LockedUntil = null;
|
user.LockedUntil = null;
|
||||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||||
successCount++;
|
successCount++;
|
||||||
break;
|
break;
|
||||||
case IdentityUserBatchOperation.Delete:
|
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.RemoveAsync(user, cancellationToken);
|
||||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||||
successCount++;
|
successCount++;
|
||||||
@@ -176,10 +124,10 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 6.1 处理导出数据
|
// 5. (空行后) 处理导出数据
|
||||||
if (request.Operation == IdentityUserBatchOperation.Export)
|
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;
|
var now = DateTime.UtcNow;
|
||||||
exportItems.AddRange(users.Select(user => new UserListItemDto
|
exportItems.AddRange(users.Select(user => new UserListItemDto
|
||||||
{
|
{
|
||||||
@@ -200,7 +148,7 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 构建操作日志消息
|
// 6. (空行后) 构建操作日志消息
|
||||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||||
? operatorProfile.Account
|
? operatorProfile.Account
|
||||||
: operatorProfile.DisplayName;
|
: operatorProfile.DisplayName;
|
||||||
@@ -216,12 +164,12 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
|||||||
TargetIds = JsonSerializer.Serialize(userIds),
|
TargetIds = JsonSerializer.Serialize(userIds),
|
||||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||||
OperatorName = operatorName,
|
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 }),
|
Result = JsonSerializer.Serialize(new { successCount, failureCount = failures.Count }),
|
||||||
Success = failures.Count == 0
|
Success = failures.Count == 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// 8. 写入 Outbox 并保存变更
|
// 7. (空行后) 写入 Outbox 并保存变更
|
||||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -259,6 +207,7 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
||||||
|
PortalType portal,
|
||||||
IReadOnlyList<IdentityUser> users,
|
IReadOnlyList<IdentityUser> users,
|
||||||
IUserRoleRepository userRoleRepository,
|
IUserRoleRepository userRoleRepository,
|
||||||
IRoleRepository roleRepository,
|
IRoleRepository roleRepository,
|
||||||
@@ -267,42 +216,37 @@ public sealed class BatchIdentityUserOperationCommandHandler(
|
|||||||
// 1. 预分配字典容量
|
// 1. 预分配字典容量
|
||||||
var result = new Dictionary<long, string[]>(users.Count);
|
var result = new Dictionary<long, string[]>(users.Count);
|
||||||
|
|
||||||
// 2. 按租户分组,降低角色查询次数
|
// 2. (空行后) 提取用户 ID 集合
|
||||||
foreach (var group in users.GroupBy(user => new { user.Portal, user.TenantId }))
|
var userIds = users.Select(user => user.Id).Distinct().ToArray();
|
||||||
|
if (userIds.Length == 0)
|
||||||
{
|
{
|
||||||
var portal = group.Key.Portal;
|
return result;
|
||||||
var tenantId = group.Key.TenantId;
|
}
|
||||||
var userIds = group.Select(user => user.Id).Distinct().ToArray();
|
|
||||||
if (userIds.Length == 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 查询用户角色映射
|
// 3. (空行后) 查询用户角色映射
|
||||||
var relations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
|
var relations = await userRoleRepository.GetByUserIdsAsync(portal, null, userIds, cancellationToken);
|
||||||
if (relations.Count == 0)
|
if (relations.Count == 0)
|
||||||
{
|
{
|
||||||
continue;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 查询角色并构建映射
|
// 4. (空行后) 查询角色并构建映射
|
||||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||||
var roles = roleIds.Length == 0
|
var roles = roleIds.Length == 0
|
||||||
? Array.Empty<Role>()
|
? Array.Empty<Role>()
|
||||||
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
: await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
|
||||||
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
|
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
|
||||||
|
|
||||||
// 5. 组装用户角色编码列表
|
// 5. (空行后) 组装用户角色编码列表
|
||||||
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
|
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
|
||||||
{
|
{
|
||||||
var codes = relationGroup
|
var codes = relationGroup
|
||||||
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
|
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
|
||||||
.OfType<string>()
|
.OfType<string>()
|
||||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
|
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ChangeIdentityUserStatusCommandHandler(
|
public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||||
IIdentityUserRepository identityUserRepository,
|
IIdentityUserRepository identityUserRepository,
|
||||||
IUserRoleRepository userRoleRepository,
|
|
||||||
IRoleRepository roleRepository,
|
|
||||||
ICurrentUserAccessor currentUserAccessor,
|
ICurrentUserAccessor currentUserAccessor,
|
||||||
IAdminAuthService adminAuthService,
|
IAdminAuthService adminAuthService,
|
||||||
IIdentityOperationLogPublisher operationLogPublisher)
|
IIdentityOperationLogPublisher operationLogPublisher)
|
||||||
@@ -36,13 +34,10 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 校验租户管理员保留规则(仅租户侧用户适用)
|
// 3. (空行后) 限定仅允许操作平台管理员账号
|
||||||
if (user.Portal == PortalType.Tenant
|
if (user.Portal != PortalType.Admin || user.TenantId is not null)
|
||||||
&& request.Status == IdentityUserStatus.Disabled
|
|
||||||
&& user.Status == IdentityUserStatus.Active
|
|
||||||
&& user.TenantId.HasValue)
|
|
||||||
{
|
{
|
||||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId.Value, user.Id, cancellationToken);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 更新状态
|
// 4. 更新状态
|
||||||
@@ -85,7 +80,7 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
|||||||
Parameters = JsonSerializer.Serialize(new
|
Parameters = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
userId = user.Id,
|
userId = user.Id,
|
||||||
tenantId = user.TenantId,
|
portal = PortalType.Admin.ToString(),
|
||||||
previousStatus = previousStatus.ToString(),
|
previousStatus = previousStatus.ToString(),
|
||||||
currentStatus = user.Status.ToString()
|
currentStatus = user.Status.ToString()
|
||||||
}),
|
}),
|
||||||
@@ -99,37 +94,4 @@ public sealed class ChangeIdentityUserStatusCommandHandler(
|
|||||||
|
|
||||||
return true;
|
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, "至少保留一个管理员");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,56 +37,48 @@ public sealed class CreateIdentityUserCommandHandler(
|
|||||||
// 1. 获取操作者档案(用于操作日志)
|
// 1. 获取操作者档案(用于操作日志)
|
||||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||||
|
|
||||||
// 2. (空行后) 校验租户参数
|
// 2. (空行后) 规范化输入并准备校验
|
||||||
if (!request.TenantId.HasValue || request.TenantId.Value <= 0)
|
var portal = PortalType.Admin;
|
||||||
{
|
|
||||||
throw new BusinessException(ErrorCodes.ValidationFailed, "TenantId 不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. (空行后) 规范化输入并准备校验
|
|
||||||
var tenantId = request.TenantId.Value;
|
|
||||||
var account = request.Account.Trim();
|
var account = request.Account.Trim();
|
||||||
var displayName = request.DisplayName.Trim();
|
var displayName = request.DisplayName.Trim();
|
||||||
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
||||||
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
||||||
var roleIds = ParseIds(request.RoleIds, "角色");
|
var roleIds = ParseIds(request.RoleIds, "角色");
|
||||||
|
|
||||||
// 4. 唯一性校验
|
// 3. 唯一性校验
|
||||||
if (await identityUserRepository.ExistsByAccountAsync(tenantId, account, null, cancellationToken))
|
if (await identityUserRepository.ExistsByAccountAsync(portal, null, account, null, cancellationToken))
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.Conflict, "账号已存在");
|
throw new BusinessException(ErrorCodes.Conflict, "账号已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(phone)
|
if (!string.IsNullOrWhiteSpace(phone)
|
||||||
&& await identityUserRepository.ExistsByPhoneAsync(tenantId, phone, null, cancellationToken))
|
&& await identityUserRepository.ExistsByPhoneAsync(portal, null, phone, null, cancellationToken))
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
|
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(email)
|
if (!string.IsNullOrWhiteSpace(email)
|
||||||
&& await identityUserRepository.ExistsByEmailAsync(tenantId, email, null, cancellationToken))
|
&& await identityUserRepository.ExistsByEmailAsync(portal, null, email, null, cancellationToken))
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
|
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 校验角色合法性
|
// 4. 校验角色合法性
|
||||||
if (roleIds.Length > 0)
|
if (roleIds.Length > 0)
|
||||||
{
|
{
|
||||||
var portal = PortalType.Tenant;
|
var roles = await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
|
||||||
var roles = await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
|
||||||
if (roles.Count != roleIds.Length)
|
if (roles.Count != roleIds.Length)
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
|
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 创建用户实体
|
// 5. 创建用户实体
|
||||||
var userPortal = PortalType.Tenant;
|
|
||||||
var user = new IdentityUser
|
var user = new IdentityUser
|
||||||
{
|
{
|
||||||
Id = idGenerator.NextId(),
|
Id = idGenerator.NextId(),
|
||||||
Portal = userPortal,
|
Portal = portal,
|
||||||
TenantId = tenantId,
|
TenantId = null,
|
||||||
Account = account,
|
Account = account,
|
||||||
DisplayName = displayName,
|
DisplayName = displayName,
|
||||||
Phone = phone,
|
Phone = phone,
|
||||||
@@ -97,11 +89,12 @@ public sealed class CreateIdentityUserCommandHandler(
|
|||||||
LockedUntil = null,
|
LockedUntil = null,
|
||||||
LastLoginAt = null,
|
LastLoginAt = null,
|
||||||
MustChangePassword = false,
|
MustChangePassword = false,
|
||||||
|
MerchantId = null,
|
||||||
PasswordHash = string.Empty
|
PasswordHash = string.Empty
|
||||||
};
|
};
|
||||||
user.PasswordHash = passwordHasher.HashPassword(user, request.Password);
|
user.PasswordHash = passwordHasher.HashPassword(user, request.Password);
|
||||||
|
|
||||||
// 7. 构建操作日志消息
|
// 6. 构建操作日志消息
|
||||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||||
? operatorProfile.Account
|
? operatorProfile.Account
|
||||||
: operatorProfile.DisplayName;
|
: operatorProfile.DisplayName;
|
||||||
@@ -119,7 +112,7 @@ public sealed class CreateIdentityUserCommandHandler(
|
|||||||
OperatorName = operatorName,
|
OperatorName = operatorName,
|
||||||
Parameters = JsonSerializer.Serialize(new
|
Parameters = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
tenantId,
|
portal = portal.ToString(),
|
||||||
account,
|
account,
|
||||||
displayName,
|
displayName,
|
||||||
phone,
|
phone,
|
||||||
@@ -130,18 +123,18 @@ public sealed class CreateIdentityUserCommandHandler(
|
|||||||
Success = true
|
Success = true
|
||||||
};
|
};
|
||||||
|
|
||||||
// 8. 持久化用户并写入 Outbox
|
// 7. 持久化用户并写入 Outbox
|
||||||
await identityUserRepository.AddAsync(user, cancellationToken);
|
await identityUserRepository.AddAsync(user, cancellationToken);
|
||||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// 9. 绑定角色
|
// 8. 绑定角色
|
||||||
if (roleIds.Length > 0)
|
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);
|
var detail = await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
|
||||||
return detail ?? new UserDetailDto
|
return detail ?? new UserDetailDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ using TakeoutSaaS.Application.Identity.Commands;
|
|||||||
using TakeoutSaaS.Application.Identity.Events;
|
using TakeoutSaaS.Application.Identity.Events;
|
||||||
using TakeoutSaaS.Domain.Identity.Enums;
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||||
@@ -16,8 +14,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DeleteIdentityUserCommandHandler(
|
public sealed class DeleteIdentityUserCommandHandler(
|
||||||
IIdentityUserRepository identityUserRepository,
|
IIdentityUserRepository identityUserRepository,
|
||||||
IUserRoleRepository userRoleRepository,
|
|
||||||
IRoleRepository roleRepository,
|
|
||||||
ICurrentUserAccessor currentUserAccessor,
|
ICurrentUserAccessor currentUserAccessor,
|
||||||
IAdminAuthService adminAuthService,
|
IAdminAuthService adminAuthService,
|
||||||
IIdentityOperationLogPublisher operationLogPublisher)
|
IIdentityOperationLogPublisher operationLogPublisher)
|
||||||
@@ -36,10 +32,10 @@ public sealed class DeleteIdentityUserCommandHandler(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 校验租户管理员保留规则(仅租户侧用户适用)
|
// 3. (空行后) 限定仅允许删除平台管理员账号
|
||||||
if (user.Portal == PortalType.Tenant && user.Status == IdentityUserStatus.Active && user.TenantId.HasValue)
|
if (user.Portal != PortalType.Admin || user.TenantId is not null)
|
||||||
{
|
{
|
||||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId.Value, user.Id, cancellationToken);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 构建操作日志消息
|
// 4. 构建操作日志消息
|
||||||
@@ -58,7 +54,7 @@ public sealed class DeleteIdentityUserCommandHandler(
|
|||||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||||
OperatorName = operatorName,
|
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 }),
|
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||||
Success = true
|
Success = true
|
||||||
};
|
};
|
||||||
@@ -70,37 +66,4 @@ public sealed class DeleteIdentityUserCommandHandler(
|
|||||||
|
|
||||||
return true;
|
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, "至少保留一个管理员");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,23 +31,28 @@ public sealed class GetIdentityUserDetailQueryHandler(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 加载角色与权限
|
// 2. (空行后) 限定仅允许查看平台管理员账号
|
||||||
var portal = user.Portal;
|
if (user.Portal != PortalType.Admin || user.TenantId is not null)
|
||||||
var tenantId = user.TenantId;
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 查询用户角色关系
|
// 3. (空行后) 加载角色与权限
|
||||||
var roleRelations = await userRoleRepository.GetByUserIdAsync(portal, tenantId, user.Id, cancellationToken);
|
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 roleIds = roleRelations.Select(x => x.RoleId).Distinct().ToArray();
|
||||||
var roles = roleIds.Length == 0
|
var roles = roleIds.Length == 0
|
||||||
? Array.Empty<Role>()
|
? Array.Empty<Role>()
|
||||||
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
: await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
|
||||||
var roleCodes = roles.Select(x => x.Code)
|
var roleCodes = roles.Select(x => x.Code)
|
||||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
var permissionIds = roleIds.Length == 0
|
var permissionIds = roleIds.Length == 0
|
||||||
? Array.Empty<long>()
|
? Array.Empty<long>()
|
||||||
: (await rolePermissionRepository.GetByRoleIdsAsync(portal, tenantId, roleIds, cancellationToken))
|
: (await rolePermissionRepository.GetByRoleIdsAsync(portal, null, roleIds, cancellationToken))
|
||||||
.Select(x => x.PermissionId)
|
.Select(x => x.PermissionId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -59,7 +64,7 @@ public sealed class GetIdentityUserDetailQueryHandler(
|
|||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// 4. 组装详情 DTO
|
// 5. 组装详情 DTO
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
return new UserDetailDto
|
return new UserDetailDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,11 +36,17 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
|
|||||||
throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
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 expiresAt = DateTime.UtcNow.AddHours(1);
|
||||||
var token = await tokenStore.IssueAsync(user.Id, expiresAt, cancellationToken);
|
var token = await tokenStore.IssueAsync(user.Id, expiresAt, cancellationToken);
|
||||||
|
|
||||||
// 4. 标记用户需重置密码
|
// 5. (空行后) 标记用户需重置密码
|
||||||
user.MustChangePassword = true;
|
user.MustChangePassword = true;
|
||||||
user.FailedLoginCount = 0;
|
user.FailedLoginCount = 0;
|
||||||
user.LockedUntil = null;
|
user.LockedUntil = null;
|
||||||
@@ -49,7 +55,7 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
|
|||||||
user.Status = IdentityUserStatus.Active;
|
user.Status = IdentityUserStatus.Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 构建操作日志消息
|
// 6. (空行后) 构建操作日志消息
|
||||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||||
? operatorProfile.Account
|
? operatorProfile.Account
|
||||||
: operatorProfile.DisplayName;
|
: operatorProfile.DisplayName;
|
||||||
@@ -65,12 +71,12 @@ public sealed class ResetIdentityUserPasswordCommandHandler(
|
|||||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||||
OperatorName = operatorName,
|
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 }),
|
Result = JsonSerializer.Serialize(new { userId = user.Id, expiresAt }),
|
||||||
Success = true
|
Success = true
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6. 写入 Outbox 并保存变更
|
// 7. (空行后) 写入 Outbox 并保存变更
|
||||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ using System.Text.Json;
|
|||||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||||
using TakeoutSaaS.Application.Identity.Commands;
|
using TakeoutSaaS.Application.Identity.Commands;
|
||||||
using TakeoutSaaS.Application.Identity.Events;
|
using TakeoutSaaS.Application.Identity.Events;
|
||||||
|
using TakeoutSaaS.Domain.Identity.Enums;
|
||||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
|
||||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||||
|
|
||||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||||
@@ -33,12 +32,18 @@ public sealed class RestoreIdentityUserCommandHandler(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. (空行后) 限定仅允许恢复平台管理员账号
|
||||||
|
if (user.Portal != PortalType.Admin || user.TenantId is not null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user.DeletedAt.HasValue)
|
if (!user.DeletedAt.HasValue)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. (空行后) 构建操作日志消息
|
// 4. (空行后) 构建操作日志消息
|
||||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||||
? operatorProfile.Account
|
? operatorProfile.Account
|
||||||
: operatorProfile.DisplayName;
|
: operatorProfile.DisplayName;
|
||||||
@@ -54,12 +59,12 @@ public sealed class RestoreIdentityUserCommandHandler(
|
|||||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||||
OperatorName = operatorName,
|
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 }),
|
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||||
Success = true
|
Success = true
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. 恢复软删除状态并写入 Outbox
|
// 5. 恢复软删除状态并写入 Outbox
|
||||||
user.DeletedAt = null;
|
user.DeletedAt = null;
|
||||||
user.DeletedBy = null;
|
user.DeletedBy = null;
|
||||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ public sealed class SearchIdentityUsersQueryHandler(
|
|||||||
public async Task<PagedResult<UserListItemDto>> Handle(SearchIdentityUsersQuery request, CancellationToken cancellationToken)
|
public async Task<PagedResult<UserListItemDto>> Handle(SearchIdentityUsersQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// 1. 组装查询过滤条件
|
// 1. 组装查询过滤条件
|
||||||
|
var portal = PortalType.Admin;
|
||||||
var filter = new IdentityUserSearchFilter
|
var filter = new IdentityUserSearchFilter
|
||||||
{
|
{
|
||||||
TenantId = request.TenantId,
|
Portal = portal,
|
||||||
|
TenantId = null,
|
||||||
Keyword = request.Keyword,
|
Keyword = request.Keyword,
|
||||||
Status = request.Status,
|
Status = request.Status,
|
||||||
RoleId = request.RoleId,
|
RoleId = request.RoleId,
|
||||||
@@ -46,7 +48,7 @@ public sealed class SearchIdentityUsersQueryHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 加载角色编码映射
|
// 3. 加载角色编码映射
|
||||||
var roleCodesLookup = await ResolveRoleCodesAsync(items, userRoleRepository, roleRepository, cancellationToken);
|
var roleCodesLookup = await ResolveRoleCodesAsync(portal, items, userRoleRepository, roleRepository, cancellationToken);
|
||||||
|
|
||||||
// 4. 组装 DTO
|
// 4. 组装 DTO
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
@@ -77,6 +79,7 @@ public sealed class SearchIdentityUsersQueryHandler(
|
|||||||
|| (user.LockedUntil.HasValue && user.LockedUntil.Value > now);
|
|| (user.LockedUntil.HasValue && user.LockedUntil.Value > now);
|
||||||
|
|
||||||
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
||||||
|
PortalType portal,
|
||||||
IReadOnlyList<IdentityUser> users,
|
IReadOnlyList<IdentityUser> users,
|
||||||
IUserRoleRepository userRoleRepository,
|
IUserRoleRepository userRoleRepository,
|
||||||
IRoleRepository roleRepository,
|
IRoleRepository roleRepository,
|
||||||
@@ -85,42 +88,37 @@ public sealed class SearchIdentityUsersQueryHandler(
|
|||||||
// 1. 预分配字典容量
|
// 1. 预分配字典容量
|
||||||
var result = new Dictionary<long, string[]>(users.Count);
|
var result = new Dictionary<long, string[]>(users.Count);
|
||||||
|
|
||||||
// 2. 按 Portal + TenantId 分组,降低角色查询次数
|
// 2. (空行后) 提取用户 ID 集合
|
||||||
foreach (var group in users.GroupBy(user => new { user.Portal, user.TenantId }))
|
var userIds = users.Select(user => user.Id).Distinct().ToArray();
|
||||||
|
if (userIds.Length == 0)
|
||||||
{
|
{
|
||||||
var portal = group.Key.Portal;
|
return result;
|
||||||
var tenantId = group.Key.TenantId;
|
}
|
||||||
var userIds = group.Select(user => user.Id).Distinct().ToArray();
|
|
||||||
if (userIds.Length == 0)
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 查询用户角色映射
|
// 3. (空行后) 查询用户角色映射
|
||||||
var relations = await userRoleRepository.GetByUserIdsAsync(portal, tenantId, userIds, cancellationToken);
|
var relations = await userRoleRepository.GetByUserIdsAsync(portal, null, userIds, cancellationToken);
|
||||||
if (relations.Count == 0)
|
if (relations.Count == 0)
|
||||||
{
|
{
|
||||||
continue;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 查询角色并构建映射
|
// 4. (空行后) 查询角色并构建映射
|
||||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||||
var roles = roleIds.Length == 0
|
var roles = roleIds.Length == 0
|
||||||
? Array.Empty<Role>()
|
? Array.Empty<Role>()
|
||||||
: await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken);
|
: await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken);
|
||||||
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
|
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
|
||||||
|
|
||||||
// 5. 组装用户角色编码列表
|
// 5. (空行后) 组装用户角色编码列表
|
||||||
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
|
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
|
||||||
{
|
{
|
||||||
var codes = relationGroup
|
var codes = relationGroup
|
||||||
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
|
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
|
||||||
.OfType<string>()
|
.OfType<string>()
|
||||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
|
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -40,52 +40,50 @@ public sealed class UpdateIdentityUserCommandHandler(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. (空行后) 规范化输入并校验唯一性
|
// 3. (空行后) 限定仅允许更新平台管理员账号
|
||||||
var portal = user.Portal;
|
if (user.Portal != PortalType.Admin || user.TenantId is not null)
|
||||||
var tenantId = user.TenantId;
|
|
||||||
if (portal == PortalType.Tenant && (!tenantId.HasValue || tenantId.Value == 0))
|
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.InternalServerError, "用户缺少有效的租户标识");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. (空行后) 规范化输入并校验唯一性
|
||||||
|
var portal = PortalType.Admin;
|
||||||
var displayName = request.DisplayName.Trim();
|
var displayName = request.DisplayName.Trim();
|
||||||
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
||||||
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
||||||
var roleIds = request.RoleIds == null ? null : ParseIds(request.RoleIds, "角色");
|
var roleIds = request.RoleIds == null ? null : ParseIds(request.RoleIds, "角色");
|
||||||
|
|
||||||
if (portal == PortalType.Tenant
|
if (!string.IsNullOrWhiteSpace(phone)
|
||||||
&& !string.IsNullOrWhiteSpace(phone)
|
|
||||||
&& !string.Equals(phone, user.Phone, StringComparison.OrdinalIgnoreCase)
|
&& !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, "手机号已存在");
|
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (portal == PortalType.Tenant
|
if (!string.IsNullOrWhiteSpace(email)
|
||||||
&& !string.IsNullOrWhiteSpace(email)
|
|
||||||
&& !string.Equals(email, user.Email, StringComparison.OrdinalIgnoreCase)
|
&& !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, "邮箱已存在");
|
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleIds is { Length: > 0 })
|
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)
|
if (roles.Count != roleIds.Length)
|
||||||
{
|
{
|
||||||
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
|
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 更新用户字段
|
// 5. 更新用户字段
|
||||||
user.DisplayName = displayName;
|
user.DisplayName = displayName;
|
||||||
user.Phone = phone;
|
user.Phone = phone;
|
||||||
user.Email = email;
|
user.Email = email;
|
||||||
user.Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim();
|
user.Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim();
|
||||||
user.RowVersion = request.RowVersion;
|
user.RowVersion = request.RowVersion;
|
||||||
|
|
||||||
// 5. 构建操作日志消息
|
// 6. 构建操作日志消息
|
||||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||||
? operatorProfile.Account
|
? operatorProfile.Account
|
||||||
: operatorProfile.DisplayName;
|
: operatorProfile.DisplayName;
|
||||||
@@ -104,6 +102,7 @@ public sealed class UpdateIdentityUserCommandHandler(
|
|||||||
Parameters = JsonSerializer.Serialize(new
|
Parameters = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
userId = user.Id,
|
userId = user.Id,
|
||||||
|
portal = portal.ToString(),
|
||||||
displayName,
|
displayName,
|
||||||
phone,
|
phone,
|
||||||
email,
|
email,
|
||||||
@@ -113,7 +112,7 @@ public sealed class UpdateIdentityUserCommandHandler(
|
|||||||
Success = true
|
Success = true
|
||||||
};
|
};
|
||||||
|
|
||||||
// 6. 持久化用户更新并写入 Outbox
|
// 7. 持久化用户更新并写入 Outbox
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
await operationLogPublisher.PublishAsync(logMessage, cancellationToken);
|
||||||
@@ -124,13 +123,13 @@ public sealed class UpdateIdentityUserCommandHandler(
|
|||||||
throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试");
|
throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. 覆盖角色绑定(仅当显式传入时)
|
// 8. 覆盖角色绑定(仅当显式传入时)
|
||||||
if (roleIds != null)
|
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);
|
return await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ namespace TakeoutSaaS.Application.Identity.Queries;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record SearchIdentityUsersQuery : IRequest<PagedResult<UserListItemDto>>
|
public sealed record SearchIdentityUsersQuery : IRequest<PagedResult<UserListItemDto>>
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// 租户 ID(超级管理员可选)。
|
|
||||||
/// </summary>
|
|
||||||
public long? TenantId { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 关键字(账号/姓名/手机号/邮箱)。
|
/// 关键字(账号/姓名/手机号/邮箱)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -19,6 +19,5 @@ public sealed class BatchIdentityUserOperationCommandValidator : AbstractValidat
|
|||||||
RuleForEach(x => x.UserIds)
|
RuleForEach(x => x.UserIds)
|
||||||
.Must(value => long.TryParse(value, out _))
|
.Must(value => long.TryParse(value, out _))
|
||||||
.WithMessage("用户 ID 必须为有效的数字字符串");
|
.WithMessage("用户 ID 必须为有效的数字字符串");
|
||||||
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ public sealed class CreateIdentityUserCommandValidator : AbstractValidator<Creat
|
|||||||
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
|
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
|
||||||
RuleFor(x => x.Password).NotEmpty().Length(6, 32);
|
RuleFor(x => x.Password).NotEmpty().Length(6, 32);
|
||||||
RuleFor(x => x.Avatar).MaximumLength(512);
|
RuleFor(x => x.Avatar).MaximumLength(512);
|
||||||
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
|
|
||||||
RuleForEach(x => x.RoleIds)
|
RuleForEach(x => x.RoleIds)
|
||||||
.Must(value => long.TryParse(value, out _))
|
.Must(value => long.TryParse(value, out _))
|
||||||
.WithMessage("角色 ID 必须为有效的数字字符串");
|
.WithMessage("角色 ID 必须为有效的数字字符串");
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ public sealed class SearchIdentityUsersQueryValidator : AbstractValidator<Search
|
|||||||
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||||
RuleFor(x => x.Keyword).MaximumLength(128);
|
RuleFor(x => x.Keyword).MaximumLength(128);
|
||||||
RuleFor(x => x.SortBy).MaximumLength(64);
|
RuleFor(x => x.SortBy).MaximumLength(64);
|
||||||
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
|
|
||||||
RuleFor(x => x.CreatedAtTo)
|
RuleFor(x => x.CreatedAtTo)
|
||||||
.GreaterThanOrEqualTo(x => x.CreatedAtFrom)
|
.GreaterThanOrEqualTo(x => x.CreatedAtFrom)
|
||||||
.When(x => x.CreatedAtFrom.HasValue && x.CreatedAtTo.HasValue);
|
.When(x => x.CreatedAtFrom.HasValue && x.CreatedAtTo.HasValue);
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ public sealed class UpdateIdentityUserCommandValidator : AbstractValidator<Updat
|
|||||||
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
|
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
|
||||||
RuleFor(x => x.Avatar).MaximumLength(512);
|
RuleFor(x => x.Avatar).MaximumLength(512);
|
||||||
RuleFor(x => x.RowVersion).NotEmpty();
|
RuleFor(x => x.RowVersion).NotEmpty();
|
||||||
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
|
|
||||||
RuleForEach(x => x.RoleIds)
|
RuleForEach(x => x.RoleIds)
|
||||||
.Must(value => long.TryParse(value, out _))
|
.Must(value => long.TryParse(value, out _))
|
||||||
.WithMessage("角色 ID 必须为有效的数字字符串")
|
.WithMessage("角色 ID 必须为有效的数字字符串")
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ public interface IIdentityUserRepository
|
|||||||
long? excludeUserId = null,
|
long? excludeUserId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断账号是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="portal">Portal 类型。</param>
|
||||||
|
/// <param name="tenantId">租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。</param>
|
||||||
|
/// <param name="account">账号。</param>
|
||||||
|
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true。</returns>
|
||||||
|
Task<bool> ExistsByAccountAsync(
|
||||||
|
PortalType portal,
|
||||||
|
long? tenantId,
|
||||||
|
string account,
|
||||||
|
long? excludeUserId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 判断手机号是否存在(租户内,可排除指定用户)。
|
/// 判断手机号是否存在(租户内,可排除指定用户)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -58,6 +74,22 @@ public interface IIdentityUserRepository
|
|||||||
long? excludeUserId = null,
|
long? excludeUserId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断手机号是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="portal">Portal 类型。</param>
|
||||||
|
/// <param name="tenantId">租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。</param>
|
||||||
|
/// <param name="phone">手机号。</param>
|
||||||
|
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true。</returns>
|
||||||
|
Task<bool> ExistsByPhoneAsync(
|
||||||
|
PortalType portal,
|
||||||
|
long? tenantId,
|
||||||
|
string phone,
|
||||||
|
long? excludeUserId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 判断邮箱是否存在(租户内,可排除指定用户)。
|
/// 判断邮箱是否存在(租户内,可排除指定用户)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -72,6 +104,22 @@ public interface IIdentityUserRepository
|
|||||||
long? excludeUserId = null,
|
long? excludeUserId = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断邮箱是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="portal">Portal 类型。</param>
|
||||||
|
/// <param name="tenantId">租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。</param>
|
||||||
|
/// <param name="email">邮箱。</param>
|
||||||
|
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true。</returns>
|
||||||
|
Task<bool> ExistsByEmailAsync(
|
||||||
|
PortalType portal,
|
||||||
|
long? tenantId,
|
||||||
|
string email,
|
||||||
|
long? excludeUserId = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据 ID 获取后台用户。
|
/// 根据 ID 获取后台用户。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -138,6 +186,22 @@ public interface IIdentityUserRepository
|
|||||||
bool includeDeleted,
|
bool includeDeleted,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量获取后台用户(可用于更新,按 Portal 与租户范围精确匹配,支持包含已删除数据)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="portal">Portal 类型。</param>
|
||||||
|
/// <param name="tenantId">租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。</param>
|
||||||
|
/// <param name="userIds">用户 ID 集合。</param>
|
||||||
|
/// <param name="includeDeleted">是否包含已删除数据。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>后台用户列表。</returns>
|
||||||
|
Task<IReadOnlyList<IdentityUser>> GetForUpdateByIdsAsync(
|
||||||
|
PortalType portal,
|
||||||
|
long? tenantId,
|
||||||
|
IEnumerable<long> userIds,
|
||||||
|
bool includeDeleted,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 新增后台用户。
|
/// 新增后台用户。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -167,6 +231,11 @@ public interface IIdentityUserRepository
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record IdentityUserSearchFilter
|
public sealed record IdentityUserSearchFilter
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Portal 类型。
|
||||||
|
/// </summary>
|
||||||
|
public PortalType Portal { get; init; } = PortalType.Admin;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 租户 ID。
|
/// 租户 ID。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -57,10 +57,20 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
/// <returns>存在返回 true。</returns>
|
/// <returns>存在返回 true。</returns>
|
||||||
public Task<bool> ExistsByAccountAsync(string account, CancellationToken cancellationToken = default)
|
public Task<bool> ExistsByAccountAsync(string account, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. 标准化账号
|
// 1. 参数校验
|
||||||
|
if (string.IsNullOrWhiteSpace(account))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("账号不能为空。", nameof(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. (空行后) 标准化账号
|
||||||
var normalized = account.Trim();
|
var normalized = account.Trim();
|
||||||
// 2. 查询是否存在
|
|
||||||
return dbContext.IdentityUsers.AnyAsync(x => x.Account == normalized, cancellationToken);
|
// 3. (空行后) 查询是否存在(包含已删除数据)
|
||||||
|
return dbContext.IdentityUsers
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(x => x.Account == normalized, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -80,7 +90,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
var query = dbContext.IdentityUsers
|
var query = dbContext.IdentityUsers
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId && x.Account == normalized);
|
.Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId && x.Account == normalized);
|
||||||
|
|
||||||
if (excludeUserId.HasValue)
|
if (excludeUserId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -91,6 +101,47 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
return query.AnyAsync(cancellationToken);
|
return query.AnyAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断账号是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="portal">Portal 类型。</param>
|
||||||
|
/// <param name="tenantId">租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。</param>
|
||||||
|
/// <param name="account">账号。</param>
|
||||||
|
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true。</returns>
|
||||||
|
public Task<bool> ExistsByAccountAsync(
|
||||||
|
PortalType portal,
|
||||||
|
long? tenantId,
|
||||||
|
string account,
|
||||||
|
long? excludeUserId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 参数校验
|
||||||
|
if (string.IsNullOrWhiteSpace(account))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("账号不能为空。", nameof(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. (空行后) 校验 Portal 与 tenantId 组合
|
||||||
|
ValidatePortalTenantId(portal, tenantId);
|
||||||
|
|
||||||
|
// 3. (空行后) 标准化账号并构建查询(包含已删除数据)
|
||||||
|
var normalized = account.Trim();
|
||||||
|
var query = dbContext.IdentityUsers
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.Account == normalized);
|
||||||
|
|
||||||
|
if (excludeUserId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. (空行后) 返回是否存在
|
||||||
|
return query.AnyAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 判断手机号是否存在(租户内,可排除指定用户)。
|
/// 判断手机号是否存在(租户内,可排除指定用户)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -108,7 +159,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
var query = dbContext.IdentityUsers
|
var query = dbContext.IdentityUsers
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId && x.Phone == normalized);
|
.Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId && x.Phone == normalized);
|
||||||
|
|
||||||
if (excludeUserId.HasValue)
|
if (excludeUserId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -119,6 +170,47 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
return query.AnyAsync(cancellationToken);
|
return query.AnyAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断手机号是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="portal">Portal 类型。</param>
|
||||||
|
/// <param name="tenantId">租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。</param>
|
||||||
|
/// <param name="phone">手机号。</param>
|
||||||
|
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true。</returns>
|
||||||
|
public Task<bool> ExistsByPhoneAsync(
|
||||||
|
PortalType portal,
|
||||||
|
long? tenantId,
|
||||||
|
string phone,
|
||||||
|
long? excludeUserId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 参数校验
|
||||||
|
if (string.IsNullOrWhiteSpace(phone))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("手机号不能为空。", nameof(phone));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. (空行后) 校验 Portal 与 tenantId 组合
|
||||||
|
ValidatePortalTenantId(portal, tenantId);
|
||||||
|
|
||||||
|
// 3. (空行后) 标准化手机号并构建查询(包含已删除数据)
|
||||||
|
var normalized = phone.Trim();
|
||||||
|
var query = dbContext.IdentityUsers
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.Phone == normalized);
|
||||||
|
|
||||||
|
if (excludeUserId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. (空行后) 返回是否存在
|
||||||
|
return query.AnyAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 判断邮箱是否存在(租户内,可排除指定用户)。
|
/// 判断邮箱是否存在(租户内,可排除指定用户)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -136,7 +228,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
var query = dbContext.IdentityUsers
|
var query = dbContext.IdentityUsers
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId && x.Email == normalized);
|
.Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId && x.Email == normalized);
|
||||||
|
|
||||||
if (excludeUserId.HasValue)
|
if (excludeUserId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -147,6 +239,47 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
return query.AnyAsync(cancellationToken);
|
return query.AnyAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 判断邮箱是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="portal">Portal 类型。</param>
|
||||||
|
/// <param name="tenantId">租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。</param>
|
||||||
|
/// <param name="email">邮箱。</param>
|
||||||
|
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>存在返回 true。</returns>
|
||||||
|
public Task<bool> ExistsByEmailAsync(
|
||||||
|
PortalType portal,
|
||||||
|
long? tenantId,
|
||||||
|
string email,
|
||||||
|
long? excludeUserId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 参数校验
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("邮箱不能为空。", nameof(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. (空行后) 校验 Portal 与 tenantId 组合
|
||||||
|
ValidatePortalTenantId(portal, tenantId);
|
||||||
|
|
||||||
|
// 3. (空行后) 标准化邮箱并构建查询(包含已删除数据)
|
||||||
|
var normalized = email.Trim();
|
||||||
|
var query = dbContext.IdentityUsers
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Portal == portal && x.TenantId == tenantId && x.Email == normalized);
|
||||||
|
|
||||||
|
if (excludeUserId.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. (空行后) 返回是否存在
|
||||||
|
return query.AnyAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据 ID 获取后台用户。
|
/// 根据 ID 获取后台用户。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -188,7 +321,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
// 1. 构建基础查询
|
// 1. 构建基础查询
|
||||||
var query = dbContext.IdentityUsers
|
var query = dbContext.IdentityUsers
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId);
|
.Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId);
|
||||||
|
|
||||||
// 2. 关键字过滤
|
// 2. 关键字过滤
|
||||||
if (!string.IsNullOrWhiteSpace(keyword))
|
if (!string.IsNullOrWhiteSpace(keyword))
|
||||||
@@ -211,21 +344,20 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
IdentityUserSearchFilter filter,
|
IdentityUserSearchFilter filter,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// 1. 构建基础查询
|
// 1. 校验 Portal 与 tenantId 组合
|
||||||
var query = dbContext.IdentityUsers.AsNoTracking();
|
ValidatePortalTenantId(filter.Portal, filter.TenantId);
|
||||||
|
|
||||||
// 2. (空行后) 包含软删除数据时忽略全局过滤
|
// 2. (空行后) 构建基础查询(按 Portal + TenantId 精确匹配)
|
||||||
|
var query = dbContext.IdentityUsers
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Portal == filter.Portal && x.TenantId == filter.TenantId);
|
||||||
|
|
||||||
|
// 3. (空行后) 包含软删除数据时忽略全局过滤
|
||||||
if (filter.IncludeDeleted)
|
if (filter.IncludeDeleted)
|
||||||
{
|
{
|
||||||
query = query.IgnoreQueryFilters();
|
query = query.IgnoreQueryFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. (空行后) 可选租户过滤
|
|
||||||
if (filter.TenantId.HasValue)
|
|
||||||
{
|
|
||||||
query = query.Where(x => x.TenantId == filter.TenantId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. (空行后) 关键字筛选
|
// 4. (空行后) 关键字筛选
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||||
{
|
{
|
||||||
@@ -248,7 +380,9 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
if (filter.RoleId.HasValue)
|
if (filter.RoleId.HasValue)
|
||||||
{
|
{
|
||||||
var roleId = filter.RoleId.Value;
|
var roleId = filter.RoleId.Value;
|
||||||
var userRoles = dbContext.UserRoles.AsNoTracking();
|
var userRoles = dbContext.UserRoles
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Portal == filter.Portal && x.TenantId == filter.TenantId);
|
||||||
|
|
||||||
// 6.1 包含软删除数据时忽略全局过滤
|
// 6.1 包含软删除数据时忽略全局过滤
|
||||||
if (filter.IncludeDeleted)
|
if (filter.IncludeDeleted)
|
||||||
@@ -256,13 +390,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
userRoles = userRoles.IgnoreQueryFilters();
|
userRoles = userRoles.IgnoreQueryFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6.2 (空行后) 可选租户过滤
|
// 6.2 (空行后) 用户角色关联过滤
|
||||||
if (filter.TenantId.HasValue)
|
|
||||||
{
|
|
||||||
userRoles = userRoles.Where(x => x.TenantId == filter.TenantId.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6.3 (空行后) 用户角色关联过滤
|
|
||||||
query = query.Where(user => userRoles.Any(role => role.UserId == user.Id && role.RoleId == roleId));
|
query = query.Where(user => userRoles.Any(role => role.UserId == user.Id && role.RoleId == roleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +466,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
// 2. (空行后) 查询并返回列表
|
// 2. (空行后) 查询并返回列表
|
||||||
return await dbContext.IdentityUsers
|
return await dbContext.IdentityUsers
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(x => x.TenantId == tenantId && ids.Contains(x.Id))
|
.Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId && ids.Contains(x.Id))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,7 +493,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
|
|
||||||
// 2. (空行后) 构建查询
|
// 2. (空行后) 构建查询
|
||||||
var query = dbContext.IdentityUsers
|
var query = dbContext.IdentityUsers
|
||||||
.Where(x => x.TenantId == tenantId && ids.Contains(x.Id));
|
.Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId && ids.Contains(x.Id));
|
||||||
|
|
||||||
// 3. (空行后) 包含软删除数据时忽略全局过滤
|
// 3. (空行后) 包含软删除数据时忽略全局过滤
|
||||||
if (includeDeleted)
|
if (includeDeleted)
|
||||||
@@ -377,6 +505,46 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
return await query.ToListAsync(cancellationToken);
|
return await query.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量获取后台用户(可用于更新,按 Portal 与租户范围精确匹配,支持包含已删除数据)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="portal">Portal 类型。</param>
|
||||||
|
/// <param name="tenantId">租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。</param>
|
||||||
|
/// <param name="userIds">用户 ID 集合。</param>
|
||||||
|
/// <param name="includeDeleted">是否包含已删除数据。</param>
|
||||||
|
/// <param name="cancellationToken">取消标记。</param>
|
||||||
|
/// <returns>后台用户列表。</returns>
|
||||||
|
public async Task<IReadOnlyList<IdentityUser>> GetForUpdateByIdsAsync(
|
||||||
|
PortalType portal,
|
||||||
|
long? tenantId,
|
||||||
|
IEnumerable<long> userIds,
|
||||||
|
bool includeDeleted,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// 1. 去重并快速返回空集合
|
||||||
|
var ids = userIds.Distinct().ToArray();
|
||||||
|
if (ids.Length == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<IdentityUser>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. (空行后) 校验 Portal 与 tenantId 组合
|
||||||
|
ValidatePortalTenantId(portal, tenantId);
|
||||||
|
|
||||||
|
// 3. (空行后) 构建查询
|
||||||
|
var query = dbContext.IdentityUsers
|
||||||
|
.Where(x => x.Portal == portal && x.TenantId == tenantId && ids.Contains(x.Id));
|
||||||
|
|
||||||
|
// 4. (空行后) 包含软删除数据时忽略全局过滤
|
||||||
|
if (includeDeleted)
|
||||||
|
{
|
||||||
|
query = query.IgnoreQueryFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. (空行后) 返回列表
|
||||||
|
return await query.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 新增后台用户。
|
/// 新增后台用户。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -405,6 +573,25 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portal 与 tenantId 参数校验。
|
||||||
|
private static void ValidatePortalTenantId(PortalType portal, long? tenantId)
|
||||||
|
{
|
||||||
|
// 1. 按 Portal 规则校验 tenantId
|
||||||
|
switch (portal)
|
||||||
|
{
|
||||||
|
case PortalType.Admin when tenantId is null:
|
||||||
|
return;
|
||||||
|
case PortalType.Admin:
|
||||||
|
throw new ArgumentException("Portal=Admin 时 tenantId 必须为空。", nameof(tenantId));
|
||||||
|
case PortalType.Tenant when tenantId.HasValue && tenantId.Value > 0:
|
||||||
|
return;
|
||||||
|
case PortalType.Tenant:
|
||||||
|
throw new ArgumentException("Portal=Tenant 时必须指定 tenantId。", nameof(tenantId));
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(portal), portal, "未知 Portal 类型。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 持久化仓储变更。
|
/// 持久化仓储变更。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user