feat: 用户管理后端与日志库迁移
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户操作处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchIdentityUserOperationCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<BatchIdentityUserOperationCommand, BatchIdentityUserOperationResult>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BatchIdentityUserOperationResult> Handle(BatchIdentityUserOperationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户批量操作用户");
|
||||
}
|
||||
|
||||
if (isSuperAdmin && !request.TenantId.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "批量操作必须指定租户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 解析用户 ID 列表
|
||||
var tenantId = request.TenantId ?? currentTenantId;
|
||||
var userIds = ParseIds(request.UserIds, "用户");
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
return new BatchIdentityUserOperationResult
|
||||
{
|
||||
SuccessCount = 0,
|
||||
FailureCount = 0,
|
||||
Failures = Array.Empty<BatchIdentityUserFailureItem>(),
|
||||
ExportItems = Array.Empty<UserListItemDto>()
|
||||
};
|
||||
}
|
||||
|
||||
// 4. (空行后) 查询目标用户集合
|
||||
var includeDeleted = request.Operation == IdentityUserBatchOperation.Restore;
|
||||
var users = await identityUserRepository.GetForUpdateByIdsAsync(tenantId, userIds, includeDeleted, isSuperAdmin, cancellationToken);
|
||||
var usersById = users.ToDictionary(user => user.Id, user => user, EqualityComparer<long>.Default);
|
||||
|
||||
// 5. (空行后) 预计算租户管理员约束
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
||||
var tenantAdminUserIds = tenantAdminRole == null
|
||||
? Array.Empty<long>()
|
||||
: (await userRoleRepository.GetByUserIdsAsync(tenantId, usersById.Keys, cancellationToken))
|
||||
.Where(x => x.RoleId == tenantAdminRole.Id)
|
||||
.Select(x => x.UserId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var activeAdminCount = tenantAdminRole == null
|
||||
? 0
|
||||
: (await identityUserRepository.SearchPagedAsync(new IdentityUserSearchFilter
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = tenantAdminRole.Id,
|
||||
Status = IdentityUserStatus.Active,
|
||||
IncludeDeleted = false,
|
||||
Page = 1,
|
||||
PageSize = 1
|
||||
}, isSuperAdmin, cancellationToken)).Total;
|
||||
var remainingActiveAdmins = activeAdminCount;
|
||||
|
||||
// 6. (空行后) 执行批量操作
|
||||
var failures = new List<BatchIdentityUserFailureItem>();
|
||||
var successCount = 0;
|
||||
var exportItems = new List<UserListItemDto>();
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
if (!usersById.TryGetValue(userId, out var user))
|
||||
{
|
||||
failures.Add(new BatchIdentityUserFailureItem
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "用户不存在"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (request.Operation)
|
||||
{
|
||||
case IdentityUserBatchOperation.Enable:
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
user.LockedUntil = null;
|
||||
user.FailedLoginCount = 0;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
successCount++;
|
||||
break;
|
||||
case IdentityUserBatchOperation.Disable:
|
||||
if (user.Status == IdentityUserStatus.Active
|
||||
&& tenantAdminUserIds.Contains(user.Id)
|
||||
&& remainingActiveAdmins <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
}
|
||||
|
||||
if (user.Status == IdentityUserStatus.Active && tenantAdminUserIds.Contains(user.Id))
|
||||
{
|
||||
remainingActiveAdmins--;
|
||||
}
|
||||
|
||||
user.Status = IdentityUserStatus.Disabled;
|
||||
user.LockedUntil = null;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
successCount++;
|
||||
break;
|
||||
case IdentityUserBatchOperation.Delete:
|
||||
if (user.Status == IdentityUserStatus.Active
|
||||
&& tenantAdminUserIds.Contains(user.Id)
|
||||
&& remainingActiveAdmins <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
}
|
||||
|
||||
if (user.Status == IdentityUserStatus.Active && tenantAdminUserIds.Contains(user.Id))
|
||||
{
|
||||
remainingActiveAdmins--;
|
||||
}
|
||||
|
||||
await identityUserRepository.RemoveAsync(user, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
successCount++;
|
||||
break;
|
||||
case IdentityUserBatchOperation.Restore:
|
||||
if (!user.DeletedAt.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "用户未删除");
|
||||
}
|
||||
|
||||
user.DeletedAt = null;
|
||||
user.DeletedBy = null;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
successCount++;
|
||||
break;
|
||||
case IdentityUserBatchOperation.Export:
|
||||
successCount++;
|
||||
break;
|
||||
default:
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "无效的批量操作类型");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (IsConcurrencyException(ex))
|
||||
{
|
||||
failures.Add(new BatchIdentityUserFailureItem
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "用户数据已被修改,请刷新后重试"
|
||||
});
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
failures.Add(new BatchIdentityUserFailureItem
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
// 6.1 (空行后) 处理导出数据
|
||||
if (request.Operation == IdentityUserBatchOperation.Export)
|
||||
{
|
||||
var roleCodesLookup = await ResolveRoleCodesAsync(users, userRoleRepository, roleRepository, cancellationToken);
|
||||
var now = DateTime.UtcNow;
|
||||
exportItems.AddRange(users.Select(user => new UserListItemDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Phone = user.Phone,
|
||||
Email = user.Email,
|
||||
Status = user.Status,
|
||||
IsLocked = user.Status == IdentityUserStatus.Locked || (user.LockedUntil.HasValue && user.LockedUntil.Value > now),
|
||||
IsDeleted = user.DeletedAt.HasValue,
|
||||
Roles = roleCodesLookup.TryGetValue(user.Id, out var codes) ? codes : Array.Empty<string>(),
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt
|
||||
}));
|
||||
}
|
||||
|
||||
// 7. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:batch",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(userIds),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new { tenantId, operation = request.Operation.ToString() }),
|
||||
Result = JsonSerializer.Serialize(new { successCount, failureCount = failures.Count }),
|
||||
Success = failures.Count == 0
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new BatchIdentityUserOperationResult
|
||||
{
|
||||
SuccessCount = successCount,
|
||||
FailureCount = failures.Count,
|
||||
Failures = failures,
|
||||
ExportItems = exportItems
|
||||
};
|
||||
}
|
||||
|
||||
private static long[] ParseIds(string[] values, string name)
|
||||
{
|
||||
// 1. 空数组直接返回
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
|
||||
// 2. (空行后) 解析并去重
|
||||
var ids = new List<long>(values.Length);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!long.TryParse(value, out var id) || id <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{name} ID 无效");
|
||||
}
|
||||
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// 3. (空行后) 返回去重结果
|
||||
return ids.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
||||
IReadOnlyList<IdentityUser> users,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 预分配字典容量
|
||||
var result = new Dictionary<long, string[]>(users.Count);
|
||||
|
||||
// 2. (空行后) 按租户分组,降低角色查询次数
|
||||
foreach (var group in users.GroupBy(user => user.TenantId))
|
||||
{
|
||||
var tenantId = group.Key;
|
||||
var userIds = group.Select(user => user.Id).Distinct().ToArray();
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户角色映射
|
||||
var relations = await userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
|
||||
if (relations.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. (空行后) 查询角色并构建映射
|
||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Role>()
|
||||
: await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
|
||||
|
||||
// 5. (空行后) 组装用户角色编码列表
|
||||
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
|
||||
{
|
||||
var codes = relationGroup
|
||||
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
|
||||
.OfType<string>()
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户状态处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<ChangeIdentityUserStatusCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(ChangeIdentityUserStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户修改用户状态");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. (空行后) 校验租户管理员保留规则
|
||||
if (request.Status == IdentityUserStatus.Disabled && user.Status == IdentityUserStatus.Active)
|
||||
{
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, isSuperAdmin, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. (空行后) 更新状态
|
||||
var previousStatus = user.Status;
|
||||
switch (request.Status)
|
||||
{
|
||||
case IdentityUserStatus.Active:
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
user.LockedUntil = null;
|
||||
user.FailedLoginCount = 0;
|
||||
break;
|
||||
case IdentityUserStatus.Disabled:
|
||||
user.Status = IdentityUserStatus.Disabled;
|
||||
user.LockedUntil = null;
|
||||
break;
|
||||
case IdentityUserStatus.Locked:
|
||||
user.Status = IdentityUserStatus.Locked;
|
||||
user.LockedUntil = null;
|
||||
break;
|
||||
default:
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "无效的用户状态");
|
||||
}
|
||||
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:status-change",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new
|
||||
{
|
||||
userId = user.Id,
|
||||
tenantId = user.TenantId,
|
||||
previousStatus = previousStatus.ToString(),
|
||||
currentStatus = user.Status.ToString()
|
||||
}),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, bool ignoreTenantFilter, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
||||
if (tenantAdminRole == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. (空行后) 判断用户是否为租户管理员
|
||||
var relations = await userRoleRepository.GetByUserIdAsync(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, ignoreTenantFilter, cancellationToken);
|
||||
if (result.Total <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建用户处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<CreateIdentityUserCommand, UserDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto> Handle(CreateIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户创建用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 规范化输入并准备校验
|
||||
var tenantId = isSuperAdmin ? request.TenantId ?? currentTenantId : currentTenantId;
|
||||
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))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "账号已存在");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(phone)
|
||||
&& await identityUserRepository.ExistsByPhoneAsync(tenantId, phone, null, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email)
|
||||
&& await identityUserRepository.ExistsByEmailAsync(tenantId, email, null, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
|
||||
}
|
||||
|
||||
// 5. (空行后) 校验角色合法性
|
||||
if (roleIds.Length > 0)
|
||||
{
|
||||
var roles = await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
if (roles.Count != roleIds.Length)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. (空行后) 创建用户实体
|
||||
var user = new IdentityUser
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Account = account,
|
||||
DisplayName = displayName,
|
||||
Phone = phone,
|
||||
Email = email,
|
||||
Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim(),
|
||||
Status = request.Status ?? IdentityUserStatus.Active,
|
||||
FailedLoginCount = 0,
|
||||
LockedUntil = null,
|
||||
LastLoginAt = null,
|
||||
MustChangePassword = false,
|
||||
PasswordHash = string.Empty
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, request.Password);
|
||||
|
||||
// 7. (空行后) 持久化用户
|
||||
await identityUserRepository.AddAsync(user, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. (空行后) 绑定角色
|
||||
if (roleIds.Length > 0)
|
||||
{
|
||||
await userRoleRepository.ReplaceUserRolesAsync(tenantId, user.Id, roleIds, cancellationToken);
|
||||
}
|
||||
|
||||
// 9. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:create",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new
|
||||
{
|
||||
tenantId,
|
||||
account,
|
||||
displayName,
|
||||
phone,
|
||||
email,
|
||||
roleIds
|
||||
}),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 10. (空行后) 返回用户详情
|
||||
var detail = await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
|
||||
return detail ?? new UserDetailDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Phone = user.Phone,
|
||||
Email = user.Email,
|
||||
Status = user.Status,
|
||||
IsLocked = false,
|
||||
Roles = Array.Empty<string>(),
|
||||
RoleIds = Array.Empty<string>(),
|
||||
Permissions = Array.Empty<string>(),
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
Avatar = user.Avatar,
|
||||
RowVersion = user.RowVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static long[] ParseIds(string[] values, string name)
|
||||
{
|
||||
// 1. 空数组直接返回
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
|
||||
// 2. (空行后) 解析并去重
|
||||
var ids = new List<long>(values.Length);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!long.TryParse(value, out var id) || id <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{name} ID 无效");
|
||||
}
|
||||
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// 3. (空行后) 返回去重结果
|
||||
return ids.Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除用户处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<DeleteIdentityUserCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(DeleteIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户删除用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. (空行后) 校验租户管理员保留规则
|
||||
if (user.Status == IdentityUserStatus.Active)
|
||||
{
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, isSuperAdmin, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. (空行后) 软删除用户
|
||||
await identityUserRepository.RemoveAsync(user, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:delete",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, bool ignoreTenantFilter, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
||||
if (tenantAdminRole == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. (空行后) 判断用户是否为租户管理员
|
||||
var relations = await userRoleRepository.GetByUserIdAsync(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, ignoreTenantFilter, cancellationToken);
|
||||
if (result.Total <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetIdentityUserDetailQueryHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<GetIdentityUserDetailQuery, UserDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto?> Handle(GetIdentityUserDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 查询用户实体
|
||||
IdentityUser? user;
|
||||
if (request.IncludeDeleted)
|
||||
{
|
||||
user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(currentTenantId, request.UserId, isSuperAdmin, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. (空行后) 加载角色与权限
|
||||
var roleRelations = await userRoleRepository.GetByUserIdAsync(user.TenantId, user.Id, cancellationToken);
|
||||
var roleIds = roleRelations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Role>()
|
||||
: await roleRepository.GetByIdsAsync(user.TenantId, roleIds, cancellationToken);
|
||||
var roleCodes = roles.Select(x => x.Code)
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var permissionIds = roleIds.Length == 0
|
||||
? Array.Empty<long>()
|
||||
: (await rolePermissionRepository.GetByRoleIdsAsync(user.TenantId, roleIds, cancellationToken))
|
||||
.Select(x => x.PermissionId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var permissions = permissionIds.Length == 0
|
||||
? Array.Empty<string>()
|
||||
: (await permissionRepository.GetByIdsAsync(user.TenantId, permissionIds, cancellationToken))
|
||||
.Select(x => x.Code)
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 4. (空行后) 组装详情 DTO
|
||||
var now = DateTime.UtcNow;
|
||||
return new UserDetailDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Phone = user.Phone,
|
||||
Email = user.Email,
|
||||
Status = user.Status,
|
||||
IsLocked = user.Status == IdentityUserStatus.Locked || (user.LockedUntil.HasValue && user.LockedUntil.Value > now),
|
||||
Roles = roleCodes,
|
||||
RoleIds = roleIds.Select(id => id.ToString()).ToArray(),
|
||||
Permissions = permissions,
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
Avatar = user.Avatar,
|
||||
RowVersion = user.RowVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
@@ -51,6 +52,13 @@ public sealed class ResetAdminPasswordByTokenCommandHandler(
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||
user.MustChangePassword = false;
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockedUntil = null;
|
||||
if (user.Status == IdentityUserStatus.Locked)
|
||||
{
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
}
|
||||
await userRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 生成用户重置密码链接处理器。
|
||||
/// </summary>
|
||||
public sealed class ResetIdentityUserPasswordCommandHandler(
|
||||
IAdminPasswordResetTokenStore tokenStore,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<ResetIdentityUserPasswordCommand, ResetIdentityUserPasswordResult>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ResetIdentityUserPasswordResult> Handle(ResetIdentityUserPasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
|
||||
}
|
||||
|
||||
// 4. (空行后) 签发重置令牌(1 小时有效)
|
||||
var expiresAt = DateTime.UtcNow.AddHours(1);
|
||||
var token = await tokenStore.IssueAsync(user.Id, expiresAt, cancellationToken);
|
||||
|
||||
// 5. (空行后) 标记用户需重置密码
|
||||
user.MustChangePassword = true;
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockedUntil = null;
|
||||
if (user.Status == IdentityUserStatus.Locked)
|
||||
{
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
}
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:password-reset",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id, expiresAt }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new ResetIdentityUserPasswordResult
|
||||
{
|
||||
Token = token,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 恢复用户处理器。
|
||||
/// </summary>
|
||||
public sealed class RestoreIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<RestoreIdentityUserCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(RestoreIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户恢复用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户实体(包含已删除)
|
||||
var user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(currentTenantId, request.UserId, isSuperAdmin, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.DeletedAt.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. (空行后) 恢复软删除状态
|
||||
user.DeletedAt = null;
|
||||
user.DeletedBy = null;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:restore",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 用户分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchIdentityUsersQueryHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<SearchIdentityUsersQuery, PagedResult<UserListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<UserListItemDto>> Handle(SearchIdentityUsersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 组装查询过滤条件
|
||||
var filter = new IdentityUserSearchFilter
|
||||
{
|
||||
TenantId = isSuperAdmin ? request.TenantId : currentTenantId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
RoleId = request.RoleId,
|
||||
CreatedAtFrom = request.CreatedAtFrom,
|
||||
CreatedAtTo = request.CreatedAtTo,
|
||||
LastLoginFrom = request.LastLoginFrom,
|
||||
LastLoginTo = request.LastLoginTo,
|
||||
IncludeDeleted = request.IncludeDeleted,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize,
|
||||
SortBy = request.SortBy,
|
||||
SortDescending = request.SortDescending
|
||||
};
|
||||
|
||||
// 4. (空行后) 执行分页查询
|
||||
var (items, total) = await identityUserRepository.SearchPagedAsync(filter, isSuperAdmin, cancellationToken);
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return new PagedResult<UserListItemDto>(Array.Empty<UserListItemDto>(), request.Page, request.PageSize, total);
|
||||
}
|
||||
|
||||
// 5. (空行后) 加载角色编码映射
|
||||
var roleCodesLookup = await ResolveRoleCodesAsync(items, userRoleRepository, roleRepository, cancellationToken);
|
||||
|
||||
// 6. (空行后) 组装 DTO
|
||||
var now = DateTime.UtcNow;
|
||||
var dtos = items.Select(user => new UserListItemDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Phone = user.Phone,
|
||||
Email = user.Email,
|
||||
Status = user.Status,
|
||||
IsLocked = IsLocked(user, now),
|
||||
IsDeleted = user.DeletedAt.HasValue,
|
||||
Roles = roleCodesLookup.TryGetValue(user.Id, out var codes) ? codes : Array.Empty<string>(),
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt
|
||||
}).ToList();
|
||||
|
||||
// 7. (空行后) 返回分页结果
|
||||
return new PagedResult<UserListItemDto>(dtos, request.Page, request.PageSize, total);
|
||||
}
|
||||
|
||||
private static bool IsLocked(IdentityUser user, DateTime now)
|
||||
=> user.Status == IdentityUserStatus.Locked
|
||||
|| (user.LockedUntil.HasValue && user.LockedUntil.Value > now);
|
||||
|
||||
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
||||
IReadOnlyList<IdentityUser> users,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 预分配字典容量
|
||||
var result = new Dictionary<long, string[]>(users.Count);
|
||||
|
||||
// 2. (空行后) 按租户分组,降低角色查询次数
|
||||
foreach (var group in users.GroupBy(user => user.TenantId))
|
||||
{
|
||||
var tenantId = group.Key;
|
||||
var userIds = group.Select(user => user.Id).Distinct().ToArray();
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户角色映射
|
||||
var relations = await userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
|
||||
if (relations.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. (空行后) 查询角色并构建映射
|
||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Role>()
|
||||
: await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
|
||||
|
||||
// 5. (空行后) 组装用户角色编码列表
|
||||
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
|
||||
{
|
||||
var codes = relationGroup
|
||||
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
|
||||
.OfType<string>()
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<UpdateIdentityUserCommand, UserDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto?> Handle(UpdateIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户更新用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 获取用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. (空行后) 规范化输入并校验唯一性
|
||||
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 (!string.IsNullOrWhiteSpace(phone)
|
||||
&& !string.Equals(phone, user.Phone, StringComparison.OrdinalIgnoreCase)
|
||||
&& await identityUserRepository.ExistsByPhoneAsync(user.TenantId, phone, user.Id, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email)
|
||||
&& !string.Equals(email, user.Email, StringComparison.OrdinalIgnoreCase)
|
||||
&& await identityUserRepository.ExistsByEmailAsync(user.TenantId, email, user.Id, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
|
||||
}
|
||||
|
||||
if (roleIds is { Length: > 0 })
|
||||
{
|
||||
var roles = await roleRepository.GetByIdsAsync(user.TenantId, roleIds, cancellationToken);
|
||||
if (roles.Count != roleIds.Length)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. (空行后) 更新用户字段
|
||||
user.DisplayName = displayName;
|
||||
user.Phone = phone;
|
||||
user.Email = email;
|
||||
user.Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim();
|
||||
user.RowVersion = request.RowVersion;
|
||||
|
||||
// 6. (空行后) 持久化用户更新
|
||||
try
|
||||
{
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (IsConcurrencyException(ex))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
// 7. (空行后) 覆盖角色绑定(仅当显式传入时)
|
||||
if (roleIds != null)
|
||||
{
|
||||
await userRoleRepository.ReplaceUserRolesAsync(user.TenantId, user.Id, roleIds, cancellationToken);
|
||||
}
|
||||
|
||||
// 8. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:update",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new
|
||||
{
|
||||
userId = user.Id,
|
||||
displayName,
|
||||
phone,
|
||||
email,
|
||||
roleIds
|
||||
}),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 9. (空行后) 返回用户详情
|
||||
return await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
|
||||
}
|
||||
|
||||
private static long[] ParseIds(string[] values, string name)
|
||||
{
|
||||
// 1. 空数组直接返回
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
|
||||
// 2. (空行后) 解析并去重
|
||||
var ids = new List<long>(values.Length);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!long.TryParse(value, out var id) || id <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{name} ID 无效");
|
||||
}
|
||||
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// 3. (空行后) 返回去重结果
|
||||
return ids.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
Reference in New Issue
Block a user