feat: 用户管理后端与日志库迁移

This commit is contained in:
2025-12-27 06:23:03 +08:00
parent 0ff2794667
commit b2a90cf8af
57 changed files with 4117 additions and 33 deletions

View File

@@ -0,0 +1,26 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Models;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 批量用户操作命令。
/// </summary>
public sealed record BatchIdentityUserOperationCommand : IRequest<BatchIdentityUserOperationResult>
{
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 操作类型。
/// </summary>
public IdentityUserBatchOperation Operation { get; init; }
/// <summary>
/// 用户 ID 列表(字符串)。
/// </summary>
public string[] UserIds { get; init; } = Array.Empty<string>();
}

View File

@@ -0,0 +1,25 @@
using MediatR;
using TakeoutSaaS.Domain.Identity.Enums;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 更新用户状态命令。
/// </summary>
public sealed record ChangeIdentityUserStatusCommand : IRequest<bool>
{
/// <summary>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 目标状态。
/// </summary>
public IdentityUserStatus Status { get; init; }
}

View File

@@ -0,0 +1,66 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Enums;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 创建用户命令。
/// </summary>
public sealed record CreateIdentityUserCommand : IRequest<UserDetailDto>
{
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 登录账号。
/// </summary>
[Required]
[StringLength(64)]
public string Account { get; init; } = string.Empty;
/// <summary>
/// 展示名称。
/// </summary>
[Required]
[StringLength(64)]
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// 初始密码。
/// </summary>
[Required]
[StringLength(32, MinimumLength = 6)]
public string Password { get; init; } = string.Empty;
/// <summary>
/// 手机号。
/// </summary>
[StringLength(32)]
public string? Phone { get; init; }
/// <summary>
/// 邮箱。
/// </summary>
[StringLength(128)]
public string? Email { get; init; }
/// <summary>
/// 头像地址。
/// </summary>
[StringLength(512)]
public string? Avatar { get; init; }
/// <summary>
/// 角色 ID 列表(字符串)。
/// </summary>
public string[] RoleIds { get; init; } = Array.Empty<string>();
/// <summary>
/// 初始状态。
/// </summary>
public IdentityUserStatus? Status { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 删除用户命令。
/// </summary>
public sealed record DeleteIdentityUserCommand : IRequest<bool>
{
/// <summary>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 生成用户重置密码链接命令。
/// </summary>
public sealed record ResetIdentityUserPasswordCommand : IRequest<ResetIdentityUserPasswordResult>
{
/// <summary>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
}

View File

@@ -0,0 +1,19 @@
using MediatR;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 恢复用户命令。
/// </summary>
public sealed record RestoreIdentityUserCommand : IRequest<bool>
{
/// <summary>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
}

View File

@@ -0,0 +1,58 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 更新用户命令。
/// </summary>
public sealed record UpdateIdentityUserCommand : IRequest<UserDetailDto?>
{
/// <summary>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 目标租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 展示名称。
/// </summary>
[Required]
[StringLength(64)]
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// 手机号。
/// </summary>
[StringLength(32)]
public string? Phone { get; init; }
/// <summary>
/// 邮箱。
/// </summary>
[StringLength(128)]
public string? Email { get; init; }
/// <summary>
/// 头像地址。
/// </summary>
[StringLength(512)]
public string? Avatar { get; init; }
/// <summary>
/// 角色 ID 列表(字符串)。
/// </summary>
public string[]? RoleIds { get; init; }
/// <summary>
/// 并发控制版本。
/// </summary>
[Required]
[MinLength(1)]
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
/// 批量操作失败项。
/// </summary>
public sealed record BatchIdentityUserFailureItem
{
/// <summary>
/// 用户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long UserId { get; init; }
/// <summary>
/// 失败原因。
/// </summary>
public string Reason { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,27 @@
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
/// 批量用户操作结果。
/// </summary>
public sealed record BatchIdentityUserOperationResult
{
/// <summary>
/// 成功数量。
/// </summary>
public int SuccessCount { get; init; }
/// <summary>
/// 失败数量。
/// </summary>
public int FailureCount { get; init; }
/// <summary>
/// 失败明细。
/// </summary>
public IReadOnlyList<BatchIdentityUserFailureItem> Failures { get; init; } = Array.Empty<BatchIdentityUserFailureItem>();
/// <summary>
/// 导出数据(仅导出操作返回)。
/// </summary>
public IReadOnlyList<UserListItemDto> ExportItems { get; init; } = Array.Empty<UserListItemDto>();
}

View File

@@ -0,0 +1,17 @@
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
/// 管理员重置密码结果。
/// </summary>
public sealed record ResetIdentityUserPasswordResult
{
/// <summary>
/// 重置令牌。
/// </summary>
public string Token { get; init; } = string.Empty;
/// <summary>
/// 过期时间UTC
/// </summary>
public DateTime ExpiresAt { get; init; }
}

View File

@@ -0,0 +1,94 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
/// 用户详情 DTO。
/// </summary>
public sealed record UserDetailDto
{
/// <summary>
/// 用户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long UserId { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 商户 ID。
/// </summary>
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
public long? MerchantId { get; init; }
/// <summary>
/// 登录账号。
/// </summary>
public string Account { get; init; } = string.Empty;
/// <summary>
/// 展示名称。
/// </summary>
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// 手机号。
/// </summary>
public string? Phone { get; init; }
/// <summary>
/// 邮箱。
/// </summary>
public string? Email { get; init; }
/// <summary>
/// 用户状态。
/// </summary>
public IdentityUserStatus Status { get; init; }
/// <summary>
/// 是否处于锁定状态。
/// </summary>
public bool IsLocked { get; init; }
/// <summary>
/// 角色编码列表。
/// </summary>
public string[] Roles { get; init; } = Array.Empty<string>();
/// <summary>
/// 角色 ID 列表(字符串)。
/// </summary>
public string[] RoleIds { get; init; } = Array.Empty<string>();
/// <summary>
/// 权限编码列表。
/// </summary>
public string[] Permissions { get; init; } = Array.Empty<string>();
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 最近登录时间UTC
/// </summary>
public DateTime? LastLoginAt { get; init; }
/// <summary>
/// 头像地址。
/// </summary>
public string? Avatar { get; init; }
/// <summary>
/// 并发控制版本。
/// </summary>
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
}

View File

@@ -0,0 +1,73 @@
using System.Text.Json.Serialization;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Shared.Abstractions.Serialization;
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
/// 用户列表项 DTO。
/// </summary>
public sealed record UserListItemDto
{
/// <summary>
/// 用户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long UserId { get; init; }
/// <summary>
/// 租户 ID。
/// </summary>
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
public long TenantId { get; init; }
/// <summary>
/// 登录账号。
/// </summary>
public string Account { get; init; } = string.Empty;
/// <summary>
/// 展示名称。
/// </summary>
public string DisplayName { get; init; } = string.Empty;
/// <summary>
/// 手机号。
/// </summary>
public string? Phone { get; init; }
/// <summary>
/// 邮箱。
/// </summary>
public string? Email { get; init; }
/// <summary>
/// 用户状态。
/// </summary>
public IdentityUserStatus Status { get; init; }
/// <summary>
/// 是否处于锁定状态。
/// </summary>
public bool IsLocked { get; init; }
/// <summary>
/// 是否已删除。
/// </summary>
public bool IsDeleted { get; init; }
/// <summary>
/// 角色编码列表。
/// </summary>
public string[] Roles { get; init; } = Array.Empty<string>();
/// <summary>
/// 创建时间UTC
/// </summary>
public DateTime CreatedAt { get; init; }
/// <summary>
/// 最近登录时间UTC
/// </summary>
public DateTime? LastLoginAt { get; init; }
}

View File

@@ -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);
}

View File

@@ -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, "至少保留一个管理员");
}
}
}

View File

@@ -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();
}
}

View File

@@ -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, "至少保留一个管理员");
}
}
}

View File

@@ -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
};
}
}

View File

@@ -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);
}
}

View File

@@ -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
};
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,19 @@
using System.Collections.Frozen;
using System.Linq;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity;
internal static class IdentityUserAccess
{
private static readonly FrozenSet<string> SuperAdminRoleCodes = new[]
{
"super-admin",
"SUPER_ADMIN",
"PlatformAdmin",
"platform-admin"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
internal static bool IsSuperAdmin(CurrentUserProfile profile)
=> profile.Roles.Any(role => SuperAdminRoleCodes.Contains(role));
}

View File

@@ -0,0 +1,32 @@
namespace TakeoutSaaS.Application.Identity.Models;
/// <summary>
/// 批量用户操作类型。
/// </summary>
public enum IdentityUserBatchOperation
{
/// <summary>
/// 启用。
/// </summary>
Enable = 1,
/// <summary>
/// 禁用。
/// </summary>
Disable = 2,
/// <summary>
/// 删除。
/// </summary>
Delete = 3,
/// <summary>
/// 恢复。
/// </summary>
Restore = 4,
/// <summary>
/// 导出。
/// </summary>
Export = 5
}

View File

@@ -0,0 +1,20 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 获取用户详情。
/// </summary>
public sealed record GetIdentityUserDetailQuery : IRequest<UserDetailDto?>
{
/// <summary>
/// 用户 ID。
/// </summary>
public long UserId { get; init; }
/// <summary>
/// 是否包含已删除用户。
/// </summary>
public bool IncludeDeleted { get; init; }
}

View File

@@ -0,0 +1,77 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Shared.Abstractions.Results;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 用户列表查询。
/// </summary>
public sealed record SearchIdentityUsersQuery : IRequest<PagedResult<UserListItemDto>>
{
/// <summary>
/// 租户 ID超级管理员可选
/// </summary>
public long? TenantId { get; init; }
/// <summary>
/// 关键字(账号/姓名/手机号/邮箱)。
/// </summary>
public string? Keyword { get; init; }
/// <summary>
/// 用户状态。
/// </summary>
public IdentityUserStatus? Status { get; init; }
/// <summary>
/// 角色 ID。
/// </summary>
public long? RoleId { get; init; }
/// <summary>
/// 创建开始时间UTC
/// </summary>
public DateTime? CreatedAtFrom { get; init; }
/// <summary>
/// 创建结束时间UTC
/// </summary>
public DateTime? CreatedAtTo { get; init; }
/// <summary>
/// 最近登录开始时间UTC
/// </summary>
public DateTime? LastLoginFrom { get; init; }
/// <summary>
/// 最近登录结束时间UTC
/// </summary>
public DateTime? LastLoginTo { get; init; }
/// <summary>
/// 是否包含已删除用户。
/// </summary>
public bool IncludeDeleted { get; init; }
/// <summary>
/// 页码(从 1 开始)。
/// </summary>
public int Page { get; init; } = 1;
/// <summary>
/// 每页条数。
/// </summary>
public int PageSize { get; init; } = 20;
/// <summary>
/// 排序字段。
/// </summary>
public string? SortBy { get; init; }
/// <summary>
/// 是否降序。
/// </summary>
public bool SortDescending { get; init; } = true;
}

View File

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Enums;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Domain.Tenants.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
@@ -50,14 +51,40 @@ public sealed class AdminAuthService(
var user = await userRepository.FindByAccountAsync(request.Account, cancellationToken)
?? throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
// 2. 验证密码(使用 ASP.NET Core Identity 的密码哈希器)
// 2. 校验账号状态
var now = DateTime.UtcNow;
if (user.Status == IdentityUserStatus.Disabled)
{
throw new BusinessException(ErrorCodes.Forbidden, "账号已被禁用,请联系管理员");
}
if (user.Status == IdentityUserStatus.Locked)
{
if (user.LockedUntil.HasValue && user.LockedUntil.Value <= now)
{
await ResetLockedUserAsync(user.Id, cancellationToken);
}
else
{
throw new BusinessException(ErrorCodes.Forbidden, "账号已被锁定,请稍后再试");
}
}
if (user.MustChangePassword)
{
throw new BusinessException(ErrorCodes.Forbidden, "账号需要重置密码,请通过重置链接设置新密码");
}
// 3. 验证密码(使用 ASP.NET Core Identity 的密码哈希器)
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, request.Password);
if (result == PasswordVerificationResult.Failed)
{
await IncreaseFailedLoginAsync(user.Id, now, cancellationToken);
throw new BusinessException(ErrorCodes.Unauthorized, "账号或密码错误");
}
// 3. 构建用户档案并生成令牌
// 4. (空行后) 更新登录成功状态
await UpdateLoginSuccessAsync(user.Id, now, cancellationToken);
// 5. (空行后) 构建用户档案并生成令牌
var profile = await BuildProfileAsync(user, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
@@ -273,6 +300,67 @@ public sealed class AdminAuthService(
};
}
private async Task ResetLockedUserAsync(long userId, CancellationToken cancellationToken)
{
// 1. 获取可更新实体
var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken);
if (tracked == null)
{
return;
}
// 2. 解除锁定并清空失败次数
tracked.Status = IdentityUserStatus.Active;
tracked.LockedUntil = null;
tracked.FailedLoginCount = 0;
// 3. 保存变更
await userRepository.SaveChangesAsync(cancellationToken);
}
private async Task IncreaseFailedLoginAsync(long userId, DateTime now, CancellationToken cancellationToken)
{
// 1. 获取可更新实体
var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken);
if (tracked == null)
{
return;
}
// 2. 累计失败次数并判断是否需要锁定
tracked.FailedLoginCount += 1;
if (tracked.FailedLoginCount >= 5)
{
tracked.Status = IdentityUserStatus.Locked;
tracked.LockedUntil = now.AddMinutes(15);
}
// 3. 保存变更
await userRepository.SaveChangesAsync(cancellationToken);
}
private async Task UpdateLoginSuccessAsync(long userId, DateTime now, CancellationToken cancellationToken)
{
// 1. 获取可更新实体
var tracked = await userRepository.GetForUpdateAsync(userId, cancellationToken);
if (tracked == null)
{
return;
}
// 2. 重置失败次数并刷新登录时间
tracked.FailedLoginCount = 0;
tracked.LockedUntil = null;
tracked.LastLoginAt = now;
if (tracked.Status == IdentityUserStatus.Locked)
{
tracked.Status = IdentityUserStatus.Active;
}
// 3. 保存变更
await userRepository.SaveChangesAsync(cancellationToken);
}
private static bool IsLikelyPhone(string value)
{
if (string.IsNullOrWhiteSpace(value))

View File

@@ -0,0 +1,24 @@
using FluentValidation;
using TakeoutSaaS.Application.Identity.Commands;
namespace TakeoutSaaS.Application.Identity.Validators;
/// <summary>
/// 批量用户操作命令验证器。
/// </summary>
public sealed class BatchIdentityUserOperationCommandValidator : AbstractValidator<BatchIdentityUserOperationCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public BatchIdentityUserOperationCommandValidator()
{
RuleFor(x => x.Operation).IsInEnum();
RuleFor(x => x.UserIds).NotEmpty().Must(ids => ids.Length <= 100)
.WithMessage("单次最多只能选择 100 个用户");
RuleForEach(x => x.UserIds)
.Must(value => long.TryParse(value, out _))
.WithMessage("用户 ID 必须为有效的数字字符串");
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
}
}

View File

@@ -0,0 +1,37 @@
using FluentValidation;
using TakeoutSaaS.Application.Identity.Commands;
namespace TakeoutSaaS.Application.Identity.Validators;
/// <summary>
/// 创建用户命令验证器。
/// </summary>
public sealed class CreateIdentityUserCommandValidator : AbstractValidator<CreateIdentityUserCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public CreateIdentityUserCommandValidator()
{
RuleFor(x => x.Account).NotEmpty().MaximumLength(64);
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
RuleFor(x => x.Password).NotEmpty().Length(6, 32);
RuleFor(x => x.Avatar).MaximumLength(512);
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
RuleForEach(x => x.RoleIds)
.Must(value => long.TryParse(value, out _))
.WithMessage("角色 ID 必须为有效的数字字符串");
When(x => !string.IsNullOrWhiteSpace(x.Phone), () =>
{
RuleFor(x => x.Phone!)
.Matches("^1[3-9]\\d{9}$")
.WithMessage("手机号格式不正确");
});
When(x => !string.IsNullOrWhiteSpace(x.Email), () =>
{
RuleFor(x => x.Email!)
.EmailAddress()
.WithMessage("邮箱格式不正确");
});
}
}

View File

@@ -0,0 +1,28 @@
using FluentValidation;
using TakeoutSaaS.Application.Identity.Queries;
namespace TakeoutSaaS.Application.Identity.Validators;
/// <summary>
/// 用户列表查询验证器。
/// </summary>
public sealed class SearchIdentityUsersQueryValidator : AbstractValidator<SearchIdentityUsersQuery>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public SearchIdentityUsersQueryValidator()
{
RuleFor(x => x.Page).GreaterThan(0);
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
RuleFor(x => x.Keyword).MaximumLength(128);
RuleFor(x => x.SortBy).MaximumLength(64);
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
RuleFor(x => x.CreatedAtTo)
.GreaterThanOrEqualTo(x => x.CreatedAtFrom)
.When(x => x.CreatedAtFrom.HasValue && x.CreatedAtTo.HasValue);
RuleFor(x => x.LastLoginTo)
.GreaterThanOrEqualTo(x => x.LastLoginFrom)
.When(x => x.LastLoginFrom.HasValue && x.LastLoginTo.HasValue);
}
}

View File

@@ -0,0 +1,38 @@
using FluentValidation;
using TakeoutSaaS.Application.Identity.Commands;
namespace TakeoutSaaS.Application.Identity.Validators;
/// <summary>
/// 更新用户命令验证器。
/// </summary>
public sealed class UpdateIdentityUserCommandValidator : AbstractValidator<UpdateIdentityUserCommand>
{
/// <summary>
/// 初始化验证规则。
/// </summary>
public UpdateIdentityUserCommandValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
RuleFor(x => x.Avatar).MaximumLength(512);
RuleFor(x => x.RowVersion).NotEmpty();
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
RuleForEach(x => x.RoleIds)
.Must(value => long.TryParse(value, out _))
.WithMessage("角色 ID 必须为有效的数字字符串")
.When(x => x.RoleIds != null);
When(x => !string.IsNullOrWhiteSpace(x.Phone), () =>
{
RuleFor(x => x.Phone!)
.Matches("^1[3-9]\\d{9}$")
.WithMessage("手机号格式不正确");
});
When(x => !string.IsNullOrWhiteSpace(x.Email), () =>
{
RuleFor(x => x.Email!)
.EmailAddress()
.WithMessage("邮箱格式不正确");
});
}
}