From 6143943bf043542334fe9f12ad10a2813178b284 Mon Sep 17 00:00:00 2001 From: MSuMshk <2039814060@qq.com> Date: Fri, 30 Jan 2026 02:10:32 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=BB=85=E5=B9=B3=E5=8F=B0=E7=AE=A1=E7=90=86=E5=91=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BatchIdentityUserOperationCommand.cs | 5 - .../ChangeIdentityUserStatusCommand.cs | 5 - .../Commands/CreateIdentityUserCommand.cs | 5 - .../Commands/DeleteIdentityUserCommand.cs | 5 - .../ResetIdentityUserPasswordCommand.cs | 5 - .../Commands/RestoreIdentityUserCommand.cs | 5 - .../Commands/UpdateIdentityUserCommand.cs | 5 - ...atchIdentityUserOperationCommandHandler.cs | 132 +++------- .../ChangeIdentityUserStatusCommandHandler.cs | 46 +--- .../CreateIdentityUserCommandHandler.cs | 43 ++-- .../DeleteIdentityUserCommandHandler.cs | 45 +--- .../GetIdentityUserDetailQueryHandler.cs | 21 +- ...ResetIdentityUserPasswordCommandHandler.cs | 16 +- .../RestoreIdentityUserCommandHandler.cs | 15 +- .../SearchIdentityUsersQueryHandler.cs | 66 +++-- .../UpdateIdentityUserCommandHandler.cs | 35 ++- .../Queries/SearchIdentityUsersQuery.cs | 5 - ...chIdentityUserOperationCommandValidator.cs | 1 - .../CreateIdentityUserCommandValidator.cs | 1 - .../SearchIdentityUsersQueryValidator.cs | 1 - .../UpdateIdentityUserCommandValidator.cs | 1 - .../Repositories/IIdentityUserRepository.cs | 69 +++++ .../Persistence/EfIdentityUserRepository.cs | 239 ++++++++++++++++-- 23 files changed, 429 insertions(+), 342 deletions(-) diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/BatchIdentityUserOperationCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/BatchIdentityUserOperationCommand.cs index a627339..d33d091 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/BatchIdentityUserOperationCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/BatchIdentityUserOperationCommand.cs @@ -9,11 +9,6 @@ namespace TakeoutSaaS.Application.Identity.Commands; /// public sealed record BatchIdentityUserOperationCommand : IRequest { - /// - /// 目标租户 ID(超级管理员可选)。 - /// - public long? TenantId { get; init; } - /// /// 操作类型。 /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/ChangeIdentityUserStatusCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/ChangeIdentityUserStatusCommand.cs index 3220376..712a68e 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/ChangeIdentityUserStatusCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/ChangeIdentityUserStatusCommand.cs @@ -13,11 +13,6 @@ public sealed record ChangeIdentityUserStatusCommand : IRequest /// public long UserId { get; init; } - /// - /// 目标租户 ID(超级管理员可选)。 - /// - public long? TenantId { get; init; } - /// /// 目标状态。 /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateIdentityUserCommand.cs index c9f7c3d..b66a802 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateIdentityUserCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateIdentityUserCommand.cs @@ -10,11 +10,6 @@ namespace TakeoutSaaS.Application.Identity.Commands; /// public sealed record CreateIdentityUserCommand : IRequest { - /// - /// 目标租户 ID(超级管理员可选)。 - /// - public long? TenantId { get; init; } - /// /// 登录账号。 /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteIdentityUserCommand.cs index 5fc1ce5..ae93c07 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteIdentityUserCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteIdentityUserCommand.cs @@ -11,9 +11,4 @@ public sealed record DeleteIdentityUserCommand : IRequest /// 用户 ID。 /// public long UserId { get; init; } - - /// - /// 目标租户 ID(超级管理员可选)。 - /// - public long? TenantId { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetIdentityUserPasswordCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetIdentityUserPasswordCommand.cs index 124f1e6..5395961 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetIdentityUserPasswordCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetIdentityUserPasswordCommand.cs @@ -12,9 +12,4 @@ public sealed record ResetIdentityUserPasswordCommand : IRequest public long UserId { get; init; } - - /// - /// 目标租户 ID(超级管理员可选)。 - /// - public long? TenantId { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/RestoreIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/RestoreIdentityUserCommand.cs index 6d6515a..5c8e226 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/RestoreIdentityUserCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/RestoreIdentityUserCommand.cs @@ -11,9 +11,4 @@ public sealed record RestoreIdentityUserCommand : IRequest /// 用户 ID。 /// public long UserId { get; init; } - - /// - /// 目标租户 ID(超级管理员可选)。 - /// - public long? TenantId { get; init; } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs index 99025bd..6a7fec7 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs @@ -14,11 +14,6 @@ public sealed record UpdateIdentityUserCommand : IRequest /// public long UserId { get; init; } - /// - /// 目标租户 ID(超级管理员可选)。 - /// - public long? TenantId { get; init; } - /// /// 展示名称。 /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs index 7ff4d3a..fc5f458 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs @@ -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.Default); - // 5. 预计算租户管理员约束 - var tenantAdminRole = await roleRepository.FindByCodeAsync(PortalType.Tenant, tenantId, "tenant-admin", cancellationToken); - var tenantAdminUserIds = tenantAdminRole == null - ? Array.Empty() - : (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(); var successCount = 0; var exportItems = new List(); @@ -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> ResolveRoleCodesAsync( + PortalType portal, IReadOnlyList users, IUserRoleRepository userRoleRepository, IRoleRepository roleRepository, @@ -267,42 +216,37 @@ public sealed class BatchIdentityUserOperationCommandHandler( // 1. 预分配字典容量 var result = new Dictionary(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() - : await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken); - var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer.Default); + // 4. (空行后) 查询角色并构建映射 + var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); + var roles = roleIds.Length == 0 + ? Array.Empty() + : await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken); + var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer.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() - .Where(code => !string.IsNullOrWhiteSpace(code)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - result[relationGroup.Key] = codes.Length == 0 ? Array.Empty() : 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() + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + result[relationGroup.Key] = codes.Length == 0 ? Array.Empty() : codes; } return result; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs index 05f85b7..5488a3b 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs @@ -16,8 +16,6 @@ namespace TakeoutSaaS.Application.Identity.Handlers; /// 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, "至少保留一个管理员"); - } - } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs index 071526a..29f3d9f 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs @@ -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 { diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs index 677c3e2..6ae16ad 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs @@ -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; /// 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, "至少保留一个管理员"); - } - } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetIdentityUserDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetIdentityUserDetailQueryHandler.cs index a08dad6..f59d0d1 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetIdentityUserDetailQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetIdentityUserDetailQueryHandler.cs @@ -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() - : 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() - : (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 { diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs index 6b04abb..7169ce8 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs @@ -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); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs index 4f98d3e..4158e49 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs @@ -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); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchIdentityUsersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchIdentityUsersQueryHandler.cs index 8a7d121..0966e68 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchIdentityUsersQueryHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchIdentityUsersQueryHandler.cs @@ -21,9 +21,11 @@ public sealed class SearchIdentityUsersQueryHandler( public async Task> 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> ResolveRoleCodesAsync( + PortalType portal, IReadOnlyList users, IUserRoleRepository userRoleRepository, IRoleRepository roleRepository, @@ -85,42 +88,37 @@ public sealed class SearchIdentityUsersQueryHandler( // 1. 预分配字典容量 var result = new Dictionary(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() - : await roleRepository.GetByIdsAsync(portal, tenantId, roleIds, cancellationToken); - var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer.Default); + // 4. (空行后) 查询角色并构建映射 + var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray(); + var roles = roleIds.Length == 0 + ? Array.Empty() + : await roleRepository.GetByIdsAsync(portal, null, roleIds, cancellationToken); + var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer.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() - .Where(code => !string.IsNullOrWhiteSpace(code)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - result[relationGroup.Key] = codes.Length == 0 ? Array.Empty() : 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() + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + result[relationGroup.Key] = codes.Length == 0 ? Array.Empty() : codes; } return result; diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs index 02c0e0c..06dab81 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs @@ -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); } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchIdentityUsersQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchIdentityUsersQuery.cs index 6067610..29e8775 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchIdentityUsersQuery.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchIdentityUsersQuery.cs @@ -10,11 +10,6 @@ namespace TakeoutSaaS.Application.Identity.Queries; /// public sealed record SearchIdentityUsersQuery : IRequest> { - /// - /// 租户 ID(超级管理员可选)。 - /// - public long? TenantId { get; init; } - /// /// 关键字(账号/姓名/手机号/邮箱)。 /// diff --git a/src/Application/TakeoutSaaS.Application/Identity/Validators/BatchIdentityUserOperationCommandValidator.cs b/src/Application/TakeoutSaaS.Application/Identity/Validators/BatchIdentityUserOperationCommandValidator.cs index 675e213..a65f0cf 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Validators/BatchIdentityUserOperationCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Validators/BatchIdentityUserOperationCommandValidator.cs @@ -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); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Validators/CreateIdentityUserCommandValidator.cs b/src/Application/TakeoutSaaS.Application/Identity/Validators/CreateIdentityUserCommandValidator.cs index 9c5451b..16edd51 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Validators/CreateIdentityUserCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Validators/CreateIdentityUserCommandValidator.cs @@ -17,7 +17,6 @@ public sealed class CreateIdentityUserCommandValidator : AbstractValidator 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 必须为有效的数字字符串"); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Validators/SearchIdentityUsersQueryValidator.cs b/src/Application/TakeoutSaaS.Application/Identity/Validators/SearchIdentityUsersQueryValidator.cs index 409a51f..c524f26 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Validators/SearchIdentityUsersQueryValidator.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Validators/SearchIdentityUsersQueryValidator.cs @@ -17,7 +17,6 @@ public sealed class SearchIdentityUsersQueryValidator : AbstractValidator 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); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Validators/UpdateIdentityUserCommandValidator.cs b/src/Application/TakeoutSaaS.Application/Identity/Validators/UpdateIdentityUserCommandValidator.cs index cfa600b..16827de 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Validators/UpdateIdentityUserCommandValidator.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Validators/UpdateIdentityUserCommandValidator.cs @@ -17,7 +17,6 @@ public sealed class UpdateIdentityUserCommandValidator : AbstractValidator 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 必须为有效的数字字符串") diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs index 74d73fe..39d2a19 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -44,6 +44,22 @@ public interface IIdentityUserRepository long? excludeUserId = null, CancellationToken cancellationToken = default); + /// + /// 判断账号是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。 + /// + /// Portal 类型。 + /// 租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。 + /// 账号。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsByAccountAsync( + PortalType portal, + long? tenantId, + string account, + long? excludeUserId = null, + CancellationToken cancellationToken = default); + /// /// 判断手机号是否存在(租户内,可排除指定用户)。 /// @@ -58,6 +74,22 @@ public interface IIdentityUserRepository long? excludeUserId = null, CancellationToken cancellationToken = default); + /// + /// 判断手机号是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。 + /// + /// Portal 类型。 + /// 租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。 + /// 手机号。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsByPhoneAsync( + PortalType portal, + long? tenantId, + string phone, + long? excludeUserId = null, + CancellationToken cancellationToken = default); + /// /// 判断邮箱是否存在(租户内,可排除指定用户)。 /// @@ -72,6 +104,22 @@ public interface IIdentityUserRepository long? excludeUserId = null, CancellationToken cancellationToken = default); + /// + /// 判断邮箱是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。 + /// + /// Portal 类型。 + /// 租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。 + /// 邮箱。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsByEmailAsync( + PortalType portal, + long? tenantId, + string email, + long? excludeUserId = null, + CancellationToken cancellationToken = default); + /// /// 根据 ID 获取后台用户。 /// @@ -138,6 +186,22 @@ public interface IIdentityUserRepository bool includeDeleted, CancellationToken cancellationToken = default); + /// + /// 批量获取后台用户(可用于更新,按 Portal 与租户范围精确匹配,支持包含已删除数据)。 + /// + /// Portal 类型。 + /// 租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。 + /// 用户 ID 集合。 + /// 是否包含已删除数据。 + /// 取消标记。 + /// 后台用户列表。 + Task> GetForUpdateByIdsAsync( + PortalType portal, + long? tenantId, + IEnumerable userIds, + bool includeDeleted, + CancellationToken cancellationToken = default); + /// /// 新增后台用户。 /// @@ -167,6 +231,11 @@ public interface IIdentityUserRepository /// public sealed record IdentityUserSearchFilter { + /// + /// Portal 类型。 + /// + public PortalType Portal { get; init; } = PortalType.Admin; + /// /// 租户 ID。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index 63f0aa7..b9a0e90 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -57,10 +57,20 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde /// 存在返回 true。 public Task ExistsByAccountAsync(string account, CancellationToken cancellationToken = default) { - // 1. 标准化账号 + // 1. 参数校验 + if (string.IsNullOrWhiteSpace(account)) + { + throw new ArgumentException("账号不能为空。", nameof(account)); + } + + // 2. (空行后) 标准化账号 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); } /// @@ -80,7 +90,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde var query = dbContext.IdentityUsers .IgnoreQueryFilters() .AsNoTracking() - .Where(x => x.TenantId == tenantId && x.Account == normalized); + .Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId && x.Account == normalized); if (excludeUserId.HasValue) { @@ -91,6 +101,47 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde return query.AnyAsync(cancellationToken); } + /// + /// 判断账号是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。 + /// + /// Portal 类型。 + /// 租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。 + /// 账号。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + public Task 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); + } + /// /// 判断手机号是否存在(租户内,可排除指定用户)。 /// @@ -108,7 +159,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde var query = dbContext.IdentityUsers .IgnoreQueryFilters() .AsNoTracking() - .Where(x => x.TenantId == tenantId && x.Phone == normalized); + .Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId && x.Phone == normalized); if (excludeUserId.HasValue) { @@ -119,6 +170,47 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde return query.AnyAsync(cancellationToken); } + /// + /// 判断手机号是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。 + /// + /// Portal 类型。 + /// 租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。 + /// 手机号。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + public Task 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); + } + /// /// 判断邮箱是否存在(租户内,可排除指定用户)。 /// @@ -136,7 +228,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde var query = dbContext.IdentityUsers .IgnoreQueryFilters() .AsNoTracking() - .Where(x => x.TenantId == tenantId && x.Email == normalized); + .Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId && x.Email == normalized); if (excludeUserId.HasValue) { @@ -147,6 +239,47 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde return query.AnyAsync(cancellationToken); } + /// + /// 判断邮箱是否存在(按 Portal 与租户范围精确匹配,可排除指定用户)。 + /// + /// Portal 类型。 + /// 租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。 + /// 邮箱。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + public Task 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); + } + /// /// 根据 ID 获取后台用户。 /// @@ -188,7 +321,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde // 1. 构建基础查询 var query = dbContext.IdentityUsers .AsNoTracking() - .Where(x => x.TenantId == tenantId); + .Where(x => x.Portal == PortalType.Tenant && x.TenantId == tenantId); // 2. 关键字过滤 if (!string.IsNullOrWhiteSpace(keyword)) @@ -211,21 +344,20 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde IdentityUserSearchFilter filter, CancellationToken cancellationToken = default) { - // 1. 构建基础查询 - var query = dbContext.IdentityUsers.AsNoTracking(); + // 1. 校验 Portal 与 tenantId 组合 + 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) { query = query.IgnoreQueryFilters(); } - // 3. (空行后) 可选租户过滤 - if (filter.TenantId.HasValue) - { - query = query.Where(x => x.TenantId == filter.TenantId.Value); - } - // 4. (空行后) 关键字筛选 if (!string.IsNullOrWhiteSpace(filter.Keyword)) { @@ -248,7 +380,9 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde if (filter.RoleId.HasValue) { 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 包含软删除数据时忽略全局过滤 if (filter.IncludeDeleted) @@ -256,13 +390,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde userRoles = userRoles.IgnoreQueryFilters(); } - // 6.2 (空行后) 可选租户过滤 - if (filter.TenantId.HasValue) - { - userRoles = userRoles.Where(x => x.TenantId == filter.TenantId.Value); - } - - // 6.3 (空行后) 用户角色关联过滤 + // 6.2 (空行后) 用户角色关联过滤 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. (空行后) 查询并返回列表 return await dbContext.IdentityUsers .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); } @@ -365,7 +493,7 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde // 2. (空行后) 构建查询 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. (空行后) 包含软删除数据时忽略全局过滤 if (includeDeleted) @@ -377,6 +505,46 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde return await query.ToListAsync(cancellationToken); } + /// + /// 批量获取后台用户(可用于更新,按 Portal 与租户范围精确匹配,支持包含已删除数据)。 + /// + /// Portal 类型。 + /// 租户 ID(Portal=Tenant 时必填;Portal=Admin 时必须为空)。 + /// 用户 ID 集合。 + /// 是否包含已删除数据。 + /// 取消标记。 + /// 后台用户列表。 + public async Task> GetForUpdateByIdsAsync( + PortalType portal, + long? tenantId, + IEnumerable userIds, + bool includeDeleted, + CancellationToken cancellationToken = default) + { + // 1. 去重并快速返回空集合 + var ids = userIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return Array.Empty(); + } + + // 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); + } + /// /// 新增后台用户。 /// @@ -405,6 +573,25 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde 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 类型。"); + } + } + /// /// 持久化仓储变更。 ///