diff --git a/src/Api/TakeoutSaaS.AdminApi/Controllers/UsersController.cs b/src/Api/TakeoutSaaS.AdminApi/Controllers/UsersController.cs new file mode 100644 index 0000000..412e8e9 --- /dev/null +++ b/src/Api/TakeoutSaaS.AdminApi/Controllers/UsersController.cs @@ -0,0 +1,212 @@ +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.Identity.Commands; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Queries; +using TakeoutSaaS.Module.Authorization.Attributes; +using TakeoutSaaS.Shared.Abstractions.Constants; +using TakeoutSaaS.Shared.Abstractions.Results; +using TakeoutSaaS.Shared.Web.Api; + +namespace TakeoutSaaS.AdminApi.Controllers; + +/// +/// 用户管理。 +/// +[ApiVersion("1.0")] +[Authorize] +[Route("api/admin/v{version:apiVersion}/users")] +public sealed class UsersController(IMediator mediator) : BaseApiController +{ + /// + /// 用户分页列表。 + /// + [HttpGet] + [PermissionAuthorize("identity:user:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] SearchIdentityUsersQuery query, + CancellationToken cancellationToken) + { + // 1. 查询用户分页 + var result = await mediator.Send(query, cancellationToken); + + // 2. 返回分页数据 + return ApiResponse>.Ok(result); + } + + /// + /// 用户详情。 + /// + [HttpGet("{userId:long}")] + [PermissionAuthorize("identity:user:read")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Detail(long userId, [FromQuery] bool includeDeleted, CancellationToken cancellationToken) + { + // 1. 查询用户详情 + var result = await mediator.Send(new GetIdentityUserDetailQuery + { + UserId = userId, + IncludeDeleted = includeDeleted + }, cancellationToken); + + // 2. 返回详情或 404 + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "用户不存在") + : ApiResponse.Ok(result); + } + + /// + /// 用户权限明细。 + /// + [HttpGet("{userId:long}/permissions")] + [PermissionAuthorize("identity:user:read")] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse>), StatusCodes.Status404NotFound)] + public async Task>> Permissions(long userId, CancellationToken cancellationToken) + { + // 1. 查询用户详情并提取权限 + var detail = await mediator.Send(new GetIdentityUserDetailQuery { UserId = userId }, cancellationToken); + if (detail == null) + { + return ApiResponse>.Error(ErrorCodes.NotFound, "用户不存在"); + } + + // 2. 返回权限编码列表 + return ApiResponse>.Ok(detail.Permissions); + } + + /// + /// 创建用户。 + /// + [HttpPost] + [PermissionAuthorize("identity:user:create")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Create([FromBody, Required] CreateIdentityUserCommand command, CancellationToken cancellationToken) + { + // 1. 创建用户 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回创建结果 + return ApiResponse.Ok(result); + } + + /// + /// 更新用户。 + /// + [HttpPut("{userId:long}")] + [PermissionAuthorize("identity:user:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Update(long userId, [FromBody, Required] UpdateIdentityUserCommand command, CancellationToken cancellationToken) + { + // 1. 绑定用户 ID + if (command.UserId == 0) + { + command = command with { UserId = userId }; + } + + // 2. (空行后) 执行更新 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果或 404 + return result == null + ? ApiResponse.Error(ErrorCodes.NotFound, "用户不存在") + : ApiResponse.Ok(result); + } + + /// + /// 删除用户。 + /// + [HttpDelete("{userId:long}")] + [PermissionAuthorize("identity:user:delete")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Delete(long userId, CancellationToken cancellationToken) + { + // 1. 执行删除 + var result = await mediator.Send(new DeleteIdentityUserCommand { UserId = userId }, cancellationToken); + + // 2. 返回结果或 404 + return result + ? ApiResponse.Success() + : ApiResponse.Error(ErrorCodes.NotFound, "用户不存在"); + } + + /// + /// 恢复用户。 + /// + [HttpPost("{userId:long}/restore")] + [PermissionAuthorize("identity:user:update")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> Restore(long userId, CancellationToken cancellationToken) + { + // 1. 执行恢复 + var result = await mediator.Send(new RestoreIdentityUserCommand { UserId = userId }, cancellationToken); + + // 2. 返回结果或 404 + return result + ? ApiResponse.Success() + : ApiResponse.Error(ErrorCodes.NotFound, "用户不存在"); + } + + /// + /// 更新用户状态。 + /// + [HttpPut("{userId:long}/status")] + [PermissionAuthorize("identity:user:status")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)] + public async Task> ChangeStatus(long userId, [FromBody, Required] ChangeIdentityUserStatusCommand command, CancellationToken cancellationToken) + { + // 1. 绑定用户 ID + if (command.UserId == 0) + { + command = command with { UserId = userId }; + } + + // 2. (空行后) 执行状态变更 + var result = await mediator.Send(command, cancellationToken); + + // 3. (空行后) 返回结果或 404 + return result + ? ApiResponse.Success() + : ApiResponse.Error(ErrorCodes.NotFound, "用户不存在"); + } + + /// + /// 生成重置密码链接。 + /// + [HttpPost("{userId:long}/password-reset")] + [PermissionAuthorize("identity:user:reset-password")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> ResetPassword(long userId, CancellationToken cancellationToken) + { + // 1. 生成重置令牌 + var result = await mediator.Send(new ResetIdentityUserPasswordCommand { UserId = userId }, cancellationToken); + + // 2. 返回令牌信息 + return ApiResponse.Ok(result); + } + + /// + /// 批量用户操作。 + /// + [HttpPost("batch")] + [PermissionAuthorize("identity:user:batch")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + public async Task> Batch( + [FromBody, Required] BatchIdentityUserOperationCommand command, + CancellationToken cancellationToken) + { + // 1. 执行批量操作 + var result = await mediator.Send(command, cancellationToken); + + // 2. 返回操作结果 + return ApiResponse.Ok(result); + } +} diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json index 882ab41..ad8b9b8 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Development.json @@ -32,9 +32,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, @@ -183,3 +183,4 @@ "UseConsoleExporter": true } } + diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json index 725c21f..a9ccc27 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Production.json @@ -32,9 +32,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, @@ -183,3 +183,4 @@ "UseConsoleExporter": true } } + diff --git a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json index b3ddd82..5a797c0 100644 --- a/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json +++ b/src/Api/TakeoutSaaS.AdminApi/appsettings.Seed.Development.json @@ -57,6 +57,13 @@ "identity:permission:create", "identity:permission:update", "identity:permission:delete", + "identity:user:read", + "identity:user:create", + "identity:user:update", + "identity:user:delete", + "identity:user:status", + "identity:user:reset-password", + "identity:user:batch", "role-template:read", "role-template:create", "role-template:update", @@ -171,6 +178,13 @@ "identity:permission:create", "identity:permission:update", "identity:permission:delete", + "identity:user:read", + "identity:user:create", + "identity:user:update", + "identity:user:delete", + "identity:user:status", + "identity:user:reset-password", + "identity:user:batch", "tenant-bill:read", "tenant-bill:create", "tenant-bill:pay", diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json index 9f6e1ca..6fa3d7f 100644 --- a/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Development.json @@ -29,9 +29,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, @@ -172,3 +172,4 @@ "UseConsoleExporter": true } } + diff --git a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json index 9f6e1ca..6fa3d7f 100644 --- a/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.MiniApi/appsettings.Production.json @@ -29,9 +29,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, @@ -172,3 +172,4 @@ "UseConsoleExporter": true } } + diff --git a/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json b/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json index 17378b5..8e4e77e 100644 --- a/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json +++ b/src/Api/TakeoutSaaS.UserApi/appsettings.Development.json @@ -29,9 +29,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, @@ -75,3 +75,4 @@ "UseConsoleExporter": true } } + diff --git a/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json b/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json index 17378b5..8e4e77e 100644 --- a/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json +++ b/src/Api/TakeoutSaaS.UserApi/appsettings.Production.json @@ -29,9 +29,9 @@ "MaxRetryDelaySeconds": 5 }, "LogsDatabase": { - "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", + "Write": "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50", "Reads": [ - "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=logs_user;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" + "Host=120.53.222.17;Port=5432;Database=takeout_logs_db;Username=pg_roles;Password=Logs112233;Pooling=true;Minimum Pool Size=5;Maximum Pool Size=50" ], "CommandTimeoutSeconds": 30, "MaxRetryCount": 3, @@ -75,3 +75,4 @@ "UseConsoleExporter": true } } + diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs index 510f904..15d5451 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/CreateTenantManuallyCommandHandler.cs @@ -219,6 +219,8 @@ public sealed class CreateTenantManuallyCommandHandler( Account = normalizedAccount, DisplayName = request.AdminDisplayName.Trim(), PasswordHash = string.Empty, + Phone = string.IsNullOrWhiteSpace(request.ContactPhone) ? null : request.ContactPhone.Trim(), + Email = string.IsNullOrWhiteSpace(request.ContactEmail) ? null : request.ContactEmail.Trim(), MerchantId = request.AdminMerchantId, Avatar = request.AdminAvatar }; @@ -265,4 +267,3 @@ public sealed class CreateTenantManuallyCommandHandler( }; } } - diff --git a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs index e088811..2b045a4 100644 --- a/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/App/Tenants/Handlers/SelfRegisterTenantCommandHandler.cs @@ -89,7 +89,9 @@ public sealed class SelfRegisterTenantCommandHandler( TenantId = tenant.Id, Account = normalizedAccount, DisplayName = string.IsNullOrWhiteSpace(request.AdminDisplayName) ? normalizedAccount : request.AdminDisplayName!.Trim(), - PasswordHash = string.Empty + PasswordHash = string.Empty, + Phone = normalizedPhone, + Email = string.IsNullOrWhiteSpace(request.AdminEmail) ? null : request.AdminEmail.Trim() }; adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, request.AdminPassword); await identityUserRepository.AddAsync(adminUser, cancellationToken); diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/BatchIdentityUserOperationCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/BatchIdentityUserOperationCommand.cs new file mode 100644 index 0000000..a627339 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/BatchIdentityUserOperationCommand.cs @@ -0,0 +1,26 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; +using TakeoutSaaS.Application.Identity.Models; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 批量用户操作命令。 +/// +public sealed record BatchIdentityUserOperationCommand : IRequest +{ + /// + /// 目标租户 ID(超级管理员可选)。 + /// + public long? TenantId { get; init; } + + /// + /// 操作类型。 + /// + public IdentityUserBatchOperation Operation { get; init; } + + /// + /// 用户 ID 列表(字符串)。 + /// + public string[] UserIds { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/ChangeIdentityUserStatusCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/ChangeIdentityUserStatusCommand.cs new file mode 100644 index 0000000..3220376 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/ChangeIdentityUserStatusCommand.cs @@ -0,0 +1,25 @@ +using MediatR; +using TakeoutSaaS.Domain.Identity.Enums; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新用户状态命令。 +/// +public sealed record ChangeIdentityUserStatusCommand : IRequest +{ + /// + /// 用户 ID。 + /// + public long UserId { get; init; } + + /// + /// 目标租户 ID(超级管理员可选)。 + /// + public long? TenantId { get; init; } + + /// + /// 目标状态。 + /// + public IdentityUserStatus Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateIdentityUserCommand.cs new file mode 100644 index 0000000..c9f7c3d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/CreateIdentityUserCommand.cs @@ -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; + +/// +/// 创建用户命令。 +/// +public sealed record CreateIdentityUserCommand : IRequest +{ + /// + /// 目标租户 ID(超级管理员可选)。 + /// + public long? TenantId { get; init; } + + /// + /// 登录账号。 + /// + [Required] + [StringLength(64)] + public string Account { get; init; } = string.Empty; + + /// + /// 展示名称。 + /// + [Required] + [StringLength(64)] + public string DisplayName { get; init; } = string.Empty; + + /// + /// 初始密码。 + /// + [Required] + [StringLength(32, MinimumLength = 6)] + public string Password { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + [StringLength(32)] + public string? Phone { get; init; } + + /// + /// 邮箱。 + /// + [StringLength(128)] + public string? Email { get; init; } + + /// + /// 头像地址。 + /// + [StringLength(512)] + public string? Avatar { get; init; } + + /// + /// 角色 ID 列表(字符串)。 + /// + public string[] RoleIds { get; init; } = Array.Empty(); + + /// + /// 初始状态。 + /// + public IdentityUserStatus? Status { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteIdentityUserCommand.cs new file mode 100644 index 0000000..5fc1ce5 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/DeleteIdentityUserCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 删除用户命令。 +/// +public sealed record DeleteIdentityUserCommand : IRequest +{ + /// + /// 用户 ID。 + /// + public long UserId { get; init; } + + /// + /// 目标租户 ID(超级管理员可选)。 + /// + public long? TenantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetIdentityUserPasswordCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetIdentityUserPasswordCommand.cs new file mode 100644 index 0000000..124f1e6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/ResetIdentityUserPasswordCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 生成用户重置密码链接命令。 +/// +public sealed record ResetIdentityUserPasswordCommand : IRequest +{ + /// + /// 用户 ID。 + /// + public long UserId { get; init; } + + /// + /// 目标租户 ID(超级管理员可选)。 + /// + public long? TenantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/RestoreIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/RestoreIdentityUserCommand.cs new file mode 100644 index 0000000..6d6515a --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/RestoreIdentityUserCommand.cs @@ -0,0 +1,19 @@ +using MediatR; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 恢复用户命令。 +/// +public sealed record RestoreIdentityUserCommand : IRequest +{ + /// + /// 用户 ID。 + /// + public long UserId { get; init; } + + /// + /// 目标租户 ID(超级管理员可选)。 + /// + public long? TenantId { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs new file mode 100644 index 0000000..99025bd --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Commands/UpdateIdentityUserCommand.cs @@ -0,0 +1,58 @@ +using MediatR; +using System.ComponentModel.DataAnnotations; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Commands; + +/// +/// 更新用户命令。 +/// +public sealed record UpdateIdentityUserCommand : IRequest +{ + /// + /// 用户 ID。 + /// + public long UserId { get; init; } + + /// + /// 目标租户 ID(超级管理员可选)。 + /// + public long? TenantId { get; init; } + + /// + /// 展示名称。 + /// + [Required] + [StringLength(64)] + public string DisplayName { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + [StringLength(32)] + public string? Phone { get; init; } + + /// + /// 邮箱。 + /// + [StringLength(128)] + public string? Email { get; init; } + + /// + /// 头像地址。 + /// + [StringLength(512)] + public string? Avatar { get; init; } + + /// + /// 角色 ID 列表(字符串)。 + /// + public string[]? RoleIds { get; init; } + + /// + /// 并发控制版本。 + /// + [Required] + [MinLength(1)] + public byte[] RowVersion { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/BatchIdentityUserFailureItem.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/BatchIdentityUserFailureItem.cs new file mode 100644 index 0000000..a8034b4 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/BatchIdentityUserFailureItem.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 批量操作失败项。 +/// +public sealed record BatchIdentityUserFailureItem +{ + /// + /// 用户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long UserId { get; init; } + + /// + /// 失败原因。 + /// + public string Reason { get; init; } = string.Empty; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/BatchIdentityUserOperationResult.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/BatchIdentityUserOperationResult.cs new file mode 100644 index 0000000..6bca47f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/BatchIdentityUserOperationResult.cs @@ -0,0 +1,27 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 批量用户操作结果。 +/// +public sealed record BatchIdentityUserOperationResult +{ + /// + /// 成功数量。 + /// + public int SuccessCount { get; init; } + + /// + /// 失败数量。 + /// + public int FailureCount { get; init; } + + /// + /// 失败明细。 + /// + public IReadOnlyList Failures { get; init; } = Array.Empty(); + + /// + /// 导出数据(仅导出操作返回)。 + /// + public IReadOnlyList ExportItems { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/ResetIdentityUserPasswordResult.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/ResetIdentityUserPasswordResult.cs new file mode 100644 index 0000000..57c75d1 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/ResetIdentityUserPasswordResult.cs @@ -0,0 +1,17 @@ +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 管理员重置密码结果。 +/// +public sealed record ResetIdentityUserPasswordResult +{ + /// + /// 重置令牌。 + /// + public string Token { get; init; } = string.Empty; + + /// + /// 过期时间(UTC)。 + /// + public DateTime ExpiresAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserDetailDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserDetailDto.cs new file mode 100644 index 0000000..8fbe0ae --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserDetailDto.cs @@ -0,0 +1,94 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 用户详情 DTO。 +/// +public sealed record UserDetailDto +{ + /// + /// 用户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long UserId { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 商户 ID。 + /// + [JsonConverter(typeof(NullableSnowflakeIdJsonConverter))] + public long? MerchantId { get; init; } + + /// + /// 登录账号。 + /// + public string Account { get; init; } = string.Empty; + + /// + /// 展示名称。 + /// + public string DisplayName { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + public string? Phone { get; init; } + + /// + /// 邮箱。 + /// + public string? Email { get; init; } + + /// + /// 用户状态。 + /// + public IdentityUserStatus Status { get; init; } + + /// + /// 是否处于锁定状态。 + /// + public bool IsLocked { get; init; } + + /// + /// 角色编码列表。 + /// + public string[] Roles { get; init; } = Array.Empty(); + + /// + /// 角色 ID 列表(字符串)。 + /// + public string[] RoleIds { get; init; } = Array.Empty(); + + /// + /// 权限编码列表。 + /// + public string[] Permissions { get; init; } = Array.Empty(); + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 最近登录时间(UTC)。 + /// + public DateTime? LastLoginAt { get; init; } + + /// + /// 头像地址。 + /// + public string? Avatar { get; init; } + + /// + /// 并发控制版本。 + /// + public byte[] RowVersion { get; init; } = Array.Empty(); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserListItemDto.cs b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserListItemDto.cs new file mode 100644 index 0000000..fb818b6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Contracts/UserListItemDto.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; +using TakeoutSaaS.Domain.Identity.Enums; +using TakeoutSaaS.Shared.Abstractions.Serialization; + +namespace TakeoutSaaS.Application.Identity.Contracts; + +/// +/// 用户列表项 DTO。 +/// +public sealed record UserListItemDto +{ + /// + /// 用户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long UserId { get; init; } + + /// + /// 租户 ID。 + /// + [JsonConverter(typeof(SnowflakeIdJsonConverter))] + public long TenantId { get; init; } + + /// + /// 登录账号。 + /// + public string Account { get; init; } = string.Empty; + + /// + /// 展示名称。 + /// + public string DisplayName { get; init; } = string.Empty; + + /// + /// 手机号。 + /// + public string? Phone { get; init; } + + /// + /// 邮箱。 + /// + public string? Email { get; init; } + + /// + /// 用户状态。 + /// + public IdentityUserStatus Status { get; init; } + + /// + /// 是否处于锁定状态。 + /// + public bool IsLocked { get; init; } + + /// + /// 是否已删除。 + /// + public bool IsDeleted { get; init; } + + /// + /// 角色编码列表。 + /// + public string[] Roles { get; init; } = Array.Empty(); + + /// + /// 创建时间(UTC)。 + /// + public DateTime CreatedAt { get; init; } + + /// + /// 最近登录时间(UTC)。 + /// + public DateTime? LastLoginAt { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs new file mode 100644 index 0000000..11dae75 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/BatchIdentityUserOperationCommandHandler.cs @@ -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; + +/// +/// 批量用户操作处理器。 +/// +public sealed class BatchIdentityUserOperationCommandHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IOperationLogRepository operationLogRepository) + : IRequestHandler +{ + /// + public async Task 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(), + ExportItems = Array.Empty() + }; + } + + // 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.Default); + + // 5. (空行后) 预计算租户管理员约束 + var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken); + var tenantAdminUserIds = tenantAdminRole == null + ? Array.Empty() + : (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(); + var successCount = 0; + var exportItems = new List(); + 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(), + 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(); + } + + // 2. (空行后) 解析并去重 + var ids = new List(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> ResolveRoleCodesAsync( + IReadOnlyList users, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + CancellationToken cancellationToken) + { + // 1. 预分配字典容量 + var result = new Dictionary(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() + : await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer.Default); + + // 5. (空行后) 组装用户角色编码列表 + foreach (var relationGroup in relations.GroupBy(x => x.UserId)) + { + var codes = relationGroup + .Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null) + .OfType() + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + result[relationGroup.Key] = codes.Length == 0 ? Array.Empty() : codes; + } + } + + return result; + } + + private static bool IsConcurrencyException(Exception exception) + => string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs new file mode 100644 index 0000000..be0534d --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ChangeIdentityUserStatusCommandHandler.cs @@ -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; + +/// +/// 更新用户状态处理器。 +/// +public sealed class ChangeIdentityUserStatusCommandHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IOperationLogRepository operationLogRepository) + : IRequestHandler +{ + /// + public async Task 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, "至少保留一个管理员"); + } + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs new file mode 100644 index 0000000..68a1064 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/CreateIdentityUserCommandHandler.cs @@ -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; + +/// +/// 创建用户处理器。 +/// +public sealed class CreateIdentityUserCommandHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + IPasswordHasher passwordHasher, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IOperationLogRepository operationLogRepository, + IMediator mediator) + : IRequestHandler +{ + /// + public async Task 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(), + RoleIds = Array.Empty(), + Permissions = Array.Empty(), + 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(); + } + + // 2. (空行后) 解析并去重 + var ids = new List(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(); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs new file mode 100644 index 0000000..728aab8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/DeleteIdentityUserCommandHandler.cs @@ -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; + +/// +/// 删除用户处理器。 +/// +public sealed class DeleteIdentityUserCommandHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IOperationLogRepository operationLogRepository) + : IRequestHandler +{ + /// + public async Task 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, "至少保留一个管理员"); + } + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetIdentityUserDetailQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetIdentityUserDetailQueryHandler.cs new file mode 100644 index 0000000..b874124 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/GetIdentityUserDetailQueryHandler.cs @@ -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; + +/// +/// 获取用户详情处理器。 +/// +public sealed class GetIdentityUserDetailQueryHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + IRolePermissionRepository rolePermissionRepository, + IPermissionRepository permissionRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService) + : IRequestHandler +{ + /// + public async Task 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() + : 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() + : (await rolePermissionRepository.GetByRoleIdsAsync(user.TenantId, roleIds, cancellationToken)) + .Select(x => x.PermissionId) + .Distinct() + .ToArray(); + var permissions = permissionIds.Length == 0 + ? Array.Empty() + : (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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetAdminPasswordByTokenCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetAdminPasswordByTokenCommandHandler.cs index 1d1ace2..1280999 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetAdminPasswordByTokenCommandHandler.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetAdminPasswordByTokenCommandHandler.cs @@ -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); } } diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs new file mode 100644 index 0000000..1e010e2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/ResetIdentityUserPasswordCommandHandler.cs @@ -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; + +/// +/// 生成用户重置密码链接处理器。 +/// +public sealed class ResetIdentityUserPasswordCommandHandler( + IAdminPasswordResetTokenStore tokenStore, + IIdentityUserRepository identityUserRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IOperationLogRepository operationLogRepository) + : IRequestHandler +{ + /// + public async Task 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 + }; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs new file mode 100644 index 0000000..eeb8da2 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/RestoreIdentityUserCommandHandler.cs @@ -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; + +/// +/// 恢复用户处理器。 +/// +public sealed class RestoreIdentityUserCommandHandler( + IIdentityUserRepository identityUserRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IOperationLogRepository operationLogRepository) + : IRequestHandler +{ + /// + public async Task 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; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchIdentityUsersQueryHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchIdentityUsersQueryHandler.cs new file mode 100644 index 0000000..3a16d80 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/SearchIdentityUsersQueryHandler.cs @@ -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; + +/// +/// 用户分页查询处理器。 +/// +public sealed class SearchIdentityUsersQueryHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService) + : IRequestHandler> +{ + /// + public async Task> 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(Array.Empty(), 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(), + CreatedAt = user.CreatedAt, + LastLoginAt = user.LastLoginAt + }).ToList(); + + // 7. (空行后) 返回分页结果 + return new PagedResult(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> ResolveRoleCodesAsync( + IReadOnlyList users, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + CancellationToken cancellationToken) + { + // 1. 预分配字典容量 + var result = new Dictionary(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() + : await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken); + var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer.Default); + + // 5. (空行后) 组装用户角色编码列表 + foreach (var relationGroup in relations.GroupBy(x => x.UserId)) + { + var codes = relationGroup + .Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null) + .OfType() + .Where(code => !string.IsNullOrWhiteSpace(code)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + result[relationGroup.Key] = codes.Length == 0 ? Array.Empty() : codes; + } + } + + return result; + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs new file mode 100644 index 0000000..30d2827 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Handlers/UpdateIdentityUserCommandHandler.cs @@ -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; + +/// +/// 更新用户处理器。 +/// +public sealed class UpdateIdentityUserCommandHandler( + IIdentityUserRepository identityUserRepository, + IUserRoleRepository userRoleRepository, + IRoleRepository roleRepository, + ITenantProvider tenantProvider, + ICurrentUserAccessor currentUserAccessor, + IAdminAuthService adminAuthService, + IOperationLogRepository operationLogRepository, + IMediator mediator) + : IRequestHandler +{ + /// + public async Task 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(); + } + + // 2. (空行后) 解析并去重 + var ids = new List(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); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/IdentityUserAccess.cs b/src/Application/TakeoutSaaS.Application/Identity/IdentityUserAccess.cs new file mode 100644 index 0000000..1d64fc6 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/IdentityUserAccess.cs @@ -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 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)); +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Models/IdentityUserBatchOperation.cs b/src/Application/TakeoutSaaS.Application/Identity/Models/IdentityUserBatchOperation.cs new file mode 100644 index 0000000..017d489 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Models/IdentityUserBatchOperation.cs @@ -0,0 +1,32 @@ +namespace TakeoutSaaS.Application.Identity.Models; + +/// +/// 批量用户操作类型。 +/// +public enum IdentityUserBatchOperation +{ + /// + /// 启用。 + /// + Enable = 1, + + /// + /// 禁用。 + /// + Disable = 2, + + /// + /// 删除。 + /// + Delete = 3, + + /// + /// 恢复。 + /// + Restore = 4, + + /// + /// 导出。 + /// + Export = 5 +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/GetIdentityUserDetailQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetIdentityUserDetailQuery.cs new file mode 100644 index 0000000..f812bc8 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/GetIdentityUserDetailQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using TakeoutSaaS.Application.Identity.Contracts; + +namespace TakeoutSaaS.Application.Identity.Queries; + +/// +/// 获取用户详情。 +/// +public sealed record GetIdentityUserDetailQuery : IRequest +{ + /// + /// 用户 ID。 + /// + public long UserId { get; init; } + + /// + /// 是否包含已删除用户。 + /// + public bool IncludeDeleted { get; init; } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchIdentityUsersQuery.cs b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchIdentityUsersQuery.cs new file mode 100644 index 0000000..6067610 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Queries/SearchIdentityUsersQuery.cs @@ -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; + +/// +/// 用户列表查询。 +/// +public sealed record SearchIdentityUsersQuery : IRequest> +{ + /// + /// 租户 ID(超级管理员可选)。 + /// + public long? TenantId { get; init; } + + /// + /// 关键字(账号/姓名/手机号/邮箱)。 + /// + public string? Keyword { get; init; } + + /// + /// 用户状态。 + /// + public IdentityUserStatus? Status { get; init; } + + /// + /// 角色 ID。 + /// + public long? RoleId { get; init; } + + /// + /// 创建开始时间(UTC)。 + /// + public DateTime? CreatedAtFrom { get; init; } + + /// + /// 创建结束时间(UTC)。 + /// + public DateTime? CreatedAtTo { get; init; } + + /// + /// 最近登录开始时间(UTC)。 + /// + public DateTime? LastLoginFrom { get; init; } + + /// + /// 最近登录结束时间(UTC)。 + /// + public DateTime? LastLoginTo { get; init; } + + /// + /// 是否包含已删除用户。 + /// + public bool IncludeDeleted { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段。 + /// + public string? SortBy { get; init; } + + /// + /// 是否降序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs index 845fd7d..aeee4e7 100644 --- a/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs +++ b/src/Application/TakeoutSaaS.Application/Identity/Services/AdminAuthService.cs @@ -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)) diff --git a/src/Application/TakeoutSaaS.Application/Identity/Validators/BatchIdentityUserOperationCommandValidator.cs b/src/Application/TakeoutSaaS.Application/Identity/Validators/BatchIdentityUserOperationCommandValidator.cs new file mode 100644 index 0000000..675e213 --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Validators/BatchIdentityUserOperationCommandValidator.cs @@ -0,0 +1,24 @@ +using FluentValidation; +using TakeoutSaaS.Application.Identity.Commands; + +namespace TakeoutSaaS.Application.Identity.Validators; + +/// +/// 批量用户操作命令验证器。 +/// +public sealed class BatchIdentityUserOperationCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Validators/CreateIdentityUserCommandValidator.cs b/src/Application/TakeoutSaaS.Application/Identity/Validators/CreateIdentityUserCommandValidator.cs new file mode 100644 index 0000000..9c5451b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Validators/CreateIdentityUserCommandValidator.cs @@ -0,0 +1,37 @@ +using FluentValidation; +using TakeoutSaaS.Application.Identity.Commands; + +namespace TakeoutSaaS.Application.Identity.Validators; + +/// +/// 创建用户命令验证器。 +/// +public sealed class CreateIdentityUserCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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("邮箱格式不正确"); + }); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Validators/SearchIdentityUsersQueryValidator.cs b/src/Application/TakeoutSaaS.Application/Identity/Validators/SearchIdentityUsersQueryValidator.cs new file mode 100644 index 0000000..409a51f --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Validators/SearchIdentityUsersQueryValidator.cs @@ -0,0 +1,28 @@ +using FluentValidation; +using TakeoutSaaS.Application.Identity.Queries; + +namespace TakeoutSaaS.Application.Identity.Validators; + +/// +/// 用户列表查询验证器。 +/// +public sealed class SearchIdentityUsersQueryValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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); + } +} diff --git a/src/Application/TakeoutSaaS.Application/Identity/Validators/UpdateIdentityUserCommandValidator.cs b/src/Application/TakeoutSaaS.Application/Identity/Validators/UpdateIdentityUserCommandValidator.cs new file mode 100644 index 0000000..cfa600b --- /dev/null +++ b/src/Application/TakeoutSaaS.Application/Identity/Validators/UpdateIdentityUserCommandValidator.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using TakeoutSaaS.Application.Identity.Commands; + +namespace TakeoutSaaS.Application.Identity.Validators; + +/// +/// 更新用户命令验证器。 +/// +public sealed class UpdateIdentityUserCommandValidator : AbstractValidator +{ + /// + /// 初始化验证规则。 + /// + 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("邮箱格式不正确"); + }); + } +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs index 2bed359..70a46ed 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Entities/IdentityUser.cs @@ -1,3 +1,4 @@ +using TakeoutSaaS.Domain.Identity.Enums; using TakeoutSaaS.Shared.Abstractions.Entities; namespace TakeoutSaaS.Domain.Identity.Entities; @@ -22,6 +23,41 @@ public sealed class IdentityUser : MultiTenantEntityBase /// public string PasswordHash { get; set; } = string.Empty; + /// + /// 手机号(租户内唯一)。 + /// + public string? Phone { get; set; } + + /// + /// 邮箱(租户内唯一)。 + /// + public string? Email { get; set; } + + /// + /// 账号状态。 + /// + public IdentityUserStatus Status { get; set; } = IdentityUserStatus.Active; + + /// + /// 登录失败次数。 + /// + public int FailedLoginCount { get; set; } + + /// + /// 锁定截止时间(UTC)。 + /// + public DateTime? LockedUntil { get; set; } + + /// + /// 最近登录时间(UTC)。 + /// + public DateTime? LastLoginAt { get; set; } + + /// + /// 是否强制修改密码。 + /// + public bool MustChangePassword { get; set; } + /// /// 所属商户(平台管理员为空)。 /// @@ -31,4 +67,9 @@ public sealed class IdentityUser : MultiTenantEntityBase /// 头像地址。 /// public string? Avatar { get; set; } + + /// + /// 并发控制字段。 + /// + public byte[] RowVersion { get; set; } = Array.Empty(); } diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Enums/IdentityUserStatus.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Enums/IdentityUserStatus.cs new file mode 100644 index 0000000..1af3a27 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Enums/IdentityUserStatus.cs @@ -0,0 +1,22 @@ +namespace TakeoutSaaS.Domain.Identity.Enums; + +/// +/// 后台账号状态。 +/// +public enum IdentityUserStatus +{ + /// + /// 正常启用。 + /// + Active = 1, + + /// + /// 已禁用。 + /// + Disabled = 2, + + /// + /// 已锁定。 + /// + Locked = 3 +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs index 3f10ffb..ecac706 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IIdentityUserRepository.cs @@ -1,4 +1,5 @@ using TakeoutSaaS.Domain.Identity.Entities; +using TakeoutSaaS.Domain.Identity.Enums; namespace TakeoutSaaS.Domain.Identity.Repositories; @@ -23,6 +24,48 @@ public interface IIdentityUserRepository /// 存在返回 true。 Task ExistsByAccountAsync(string account, CancellationToken cancellationToken = default); + /// + /// 判断账号是否存在(租户内,可排除指定用户)。 + /// + /// 租户 ID。 + /// 账号。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsByAccountAsync( + long tenantId, + string account, + long? excludeUserId = null, + CancellationToken cancellationToken = default); + + /// + /// 判断手机号是否存在(租户内,可排除指定用户)。 + /// + /// 租户 ID。 + /// 手机号。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsByPhoneAsync( + long tenantId, + string phone, + long? excludeUserId = null, + CancellationToken cancellationToken = default); + + /// + /// 判断邮箱是否存在(租户内,可排除指定用户)。 + /// + /// 租户 ID。 + /// 邮箱。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + Task ExistsByEmailAsync( + long tenantId, + string email, + long? excludeUserId = null, + CancellationToken cancellationToken = default); + /// /// 根据 ID 获取后台用户。 /// @@ -31,6 +74,14 @@ public interface IIdentityUserRepository /// 后台用户或 null。 Task FindByIdAsync(long userId, CancellationToken cancellationToken = default); + /// + /// 根据 ID 获取后台用户(忽略租户过滤器,仅用于只读查询)。 + /// + /// 用户 ID。 + /// 取消标记。 + /// 后台用户或 null。 + Task FindByIdIgnoringTenantAsync(long userId, CancellationToken cancellationToken = default); + /// /// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。 /// @@ -48,6 +99,20 @@ public interface IIdentityUserRepository /// 后台用户或 null。 Task GetForUpdateIgnoringTenantAsync(long userId, CancellationToken cancellationToken = default); + /// + /// 根据 ID 获取后台用户(用于更新,包含已删除数据)。 + /// + /// 租户 ID。 + /// 用户 ID。 + /// 是否忽略租户过滤。 + /// 取消标记。 + /// 后台用户或 null。 + Task GetForUpdateIncludingDeletedAsync( + long tenantId, + long userId, + bool ignoreTenantFilter = false, + CancellationToken cancellationToken = default); + /// /// 按租户与关键字查询后台用户列表(仅读)。 /// @@ -57,6 +122,18 @@ public interface IIdentityUserRepository /// 后台用户列表。 Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default); + /// + /// 分页查询后台用户列表。 + /// + /// 查询过滤条件。 + /// 是否忽略租户过滤器。 + /// 取消标记。 + /// 分页结果。 + Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( + IdentityUserSearchFilter filter, + bool ignoreTenantFilter = false, + CancellationToken cancellationToken = default); + /// /// 获取指定租户、用户集合对应的用户(只读)。 /// @@ -66,6 +143,22 @@ public interface IIdentityUserRepository /// 后台用户列表。 Task> GetByIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default); + /// + /// 批量获取后台用户(可用于更新,支持包含已删除数据)。 + /// + /// 租户 ID。 + /// 用户 ID 集合。 + /// 是否包含已删除数据。 + /// 是否忽略租户过滤器。 + /// 取消标记。 + /// 后台用户列表。 + Task> GetForUpdateByIdsAsync( + long tenantId, + IEnumerable userIds, + bool includeDeleted, + bool ignoreTenantFilter = false, + CancellationToken cancellationToken = default); + /// /// 新增后台用户。 /// @@ -74,6 +167,14 @@ public interface IIdentityUserRepository /// 异步操作任务。 Task AddAsync(IdentityUser user, CancellationToken cancellationToken = default); + /// + /// 删除后台用户(软删除)。 + /// + /// 后台用户实体。 + /// 取消标记。 + /// 异步操作任务。 + Task RemoveAsync(IdentityUser user, CancellationToken cancellationToken = default); + /// /// 持久化仓储变更。 /// @@ -81,3 +182,74 @@ public interface IIdentityUserRepository /// 异步操作任务。 Task SaveChangesAsync(CancellationToken cancellationToken = default); } + +/// +/// 后台用户查询过滤条件。 +/// +public sealed record IdentityUserSearchFilter +{ + /// + /// 租户 ID。 + /// + public long? TenantId { get; init; } + + /// + /// 关键字(账号/姓名/手机号/邮箱)。 + /// + public string? Keyword { get; init; } + + /// + /// 用户状态。 + /// + public IdentityUserStatus? Status { get; init; } + + /// + /// 角色 ID。 + /// + public long? RoleId { get; init; } + + /// + /// 创建开始时间(UTC)。 + /// + public DateTime? CreatedAtFrom { get; init; } + + /// + /// 创建结束时间(UTC)。 + /// + public DateTime? CreatedAtTo { get; init; } + + /// + /// 最近登录开始时间(UTC)。 + /// + public DateTime? LastLoginFrom { get; init; } + + /// + /// 最近登录结束时间(UTC)。 + /// + public DateTime? LastLoginTo { get; init; } + + /// + /// 是否包含已删除数据。 + /// + public bool IncludeDeleted { get; init; } + + /// + /// 页码(从 1 开始)。 + /// + public int Page { get; init; } = 1; + + /// + /// 每页条数。 + /// + public int PageSize { get; init; } = 20; + + /// + /// 排序字段。 + /// + public string? SortBy { get; init; } + + /// + /// 是否降序。 + /// + public bool SortDescending { get; init; } = true; +} diff --git a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs index 6759d08..8345e2b 100644 --- a/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs +++ b/src/Domain/TakeoutSaaS.Domain/Identity/Repositories/IUserRoleRepository.cs @@ -35,6 +35,15 @@ public interface IUserRoleRepository /// 异步操作任务。 Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable roleIds, CancellationToken cancellationToken = default); + /// + /// 统计指定角色下的用户数量。 + /// + /// 租户 ID。 + /// 角色 ID。 + /// 取消标记。 + /// 用户数量。 + Task CountUsersByRoleAsync(long tenantId, long roleId, CancellationToken cancellationToken = default); + /// /// 提交持久化变更。 /// diff --git a/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IOperationLogRepository.cs b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IOperationLogRepository.cs new file mode 100644 index 0000000..f8b0333 --- /dev/null +++ b/src/Domain/TakeoutSaaS.Domain/Tenants/Repositories/IOperationLogRepository.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Domain.Tenants.Entities; + +namespace TakeoutSaaS.Domain.Tenants.Repositories; + +/// +/// 运营操作日志仓储。 +/// +public interface IOperationLogRepository +{ + /// + /// 新增操作日志。 + /// + /// 操作日志。 + /// 取消标记。 + /// 异步任务。 + Task AddAsync(OperationLog log, CancellationToken cancellationToken = default); + + /// + /// 保存仓储变更。 + /// + /// 取消标记。 + /// 异步任务。 + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs index 86d6d8c..4265143 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/App/Extensions/AppServiceCollectionExtensions.cs @@ -12,6 +12,7 @@ using TakeoutSaaS.Domain.Tenants.Services; using TakeoutSaaS.Infrastructure.App.Options; using TakeoutSaaS.Infrastructure.App.Persistence; using TakeoutSaaS.Infrastructure.Logs.Persistence; +using TakeoutSaaS.Infrastructure.Logs.Repositories; using TakeoutSaaS.Infrastructure.App.Persistence.Repositories; using TakeoutSaaS.Infrastructure.App.Repositories; using TakeoutSaaS.Infrastructure.App.Services; @@ -57,6 +58,7 @@ public static class AppServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // 1. 账单领域/导出服务 services.AddScoped(); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs index 2cf7e38..a1a9aa5 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfIdentityUserRepository.cs @@ -32,6 +32,90 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde return dbContext.IdentityUsers.AnyAsync(x => x.Account == normalized, cancellationToken); } + /// + /// 判断账号是否存在(租户内,可排除指定用户)。 + /// + /// 租户 ID。 + /// 账号。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + public Task ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default) + { + // 1. 标准化账号 + var normalized = account.Trim(); + + // 2. 构建查询(包含已删除数据) + var query = dbContext.IdentityUsers + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Account == normalized); + + if (excludeUserId.HasValue) + { + query = query.Where(x => x.Id != excludeUserId.Value); + } + + // 3. 返回是否存在 + return query.AnyAsync(cancellationToken); + } + + /// + /// 判断手机号是否存在(租户内,可排除指定用户)。 + /// + /// 租户 ID。 + /// 手机号。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + public Task ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default) + { + // 1. 标准化手机号 + var normalized = phone.Trim(); + + // 2. 构建查询(包含已删除数据) + var query = dbContext.IdentityUsers + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Phone == normalized); + + if (excludeUserId.HasValue) + { + query = query.Where(x => x.Id != excludeUserId.Value); + } + + // 3. 返回是否存在 + return query.AnyAsync(cancellationToken); + } + + /// + /// 判断邮箱是否存在(租户内,可排除指定用户)。 + /// + /// 租户 ID。 + /// 邮箱。 + /// 排除的用户 ID。 + /// 取消标记。 + /// 存在返回 true。 + public Task ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default) + { + // 1. 标准化邮箱 + var normalized = email.Trim(); + + // 2. 构建查询(包含已删除数据) + var query = dbContext.IdentityUsers + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.Email == normalized); + + if (excludeUserId.HasValue) + { + query = query.Where(x => x.Id != excludeUserId.Value); + } + + // 3. 返回是否存在 + return query.AnyAsync(cancellationToken); + } + /// /// 根据 ID 获取后台用户。 /// @@ -41,6 +125,19 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde public Task FindByIdAsync(long userId, CancellationToken cancellationToken = default) => dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + /// + /// 根据 ID 获取后台用户(忽略租户过滤器,仅用于只读查询)。 + /// + /// 用户 ID。 + /// 取消标记。 + /// 后台用户或 null。 + public Task FindByIdIgnoringTenantAsync(long userId, CancellationToken cancellationToken = default) + => dbContext.IdentityUsers + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.DeletedAt == null) + .FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + /// /// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。 /// @@ -63,6 +160,31 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde .Where(x => x.DeletedAt == null) .FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + /// + /// 根据 ID 获取后台用户(用于更新,包含已删除数据)。 + /// + /// 租户 ID。 + /// 用户 ID。 + /// 是否忽略租户过滤。 + /// 取消标记。 + /// 后台用户或 null。 + public Task GetForUpdateIncludingDeletedAsync( + long tenantId, + long userId, + bool ignoreTenantFilter = false, + CancellationToken cancellationToken = default) + { + // 1. 构建查询(包含已删除数据) + var query = dbContext.IdentityUsers.IgnoreQueryFilters(); + if (!ignoreTenantFilter) + { + query = query.Where(x => x.TenantId == tenantId); + } + + // 2. 返回实体 + return query.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + } + /// /// 按租户与关键字搜索后台用户(只读)。 /// @@ -88,6 +210,144 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde return await query.ToListAsync(cancellationToken); } + /// + /// 分页查询后台用户列表。 + /// + /// 查询过滤条件。 + /// 是否忽略租户过滤器。 + /// 取消标记。 + /// 分页结果。 + public async Task<(IReadOnlyList Items, int Total)> SearchPagedAsync( + IdentityUserSearchFilter filter, + bool ignoreTenantFilter = false, + CancellationToken cancellationToken = default) + { + // 1. 构建基础查询 + var query = dbContext.IdentityUsers.AsNoTracking(); + if (ignoreTenantFilter || filter.IncludeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + // 2. 租户过滤 + if (!ignoreTenantFilter) + { + if (filter.TenantId.HasValue && filter.TenantId.Value != 0) + { + query = query.Where(x => x.TenantId == filter.TenantId.Value); + } + } + else if (filter.TenantId.HasValue && filter.TenantId.Value != 0) + { + query = query.Where(x => x.TenantId == filter.TenantId.Value); + } + + if (!filter.IncludeDeleted) + { + query = query.Where(x => x.DeletedAt == null); + } + + // 3. 关键字筛选 + if (!string.IsNullOrWhiteSpace(filter.Keyword)) + { + var normalized = filter.Keyword.Trim(); + var likeValue = $"%{normalized}%"; + query = query.Where(x => + EF.Functions.ILike(x.Account, likeValue) + || EF.Functions.ILike(x.DisplayName, likeValue) + || (x.Phone != null && EF.Functions.ILike(x.Phone, likeValue)) + || (x.Email != null && EF.Functions.ILike(x.Email, likeValue))); + } + + // 4. 状态过滤 + if (filter.Status.HasValue) + { + query = query.Where(x => x.Status == filter.Status.Value); + } + + // 5. 角色过滤 + if (filter.RoleId.HasValue) + { + var roleId = filter.RoleId.Value; + var userRoles = dbContext.UserRoles.AsNoTracking(); + if (ignoreTenantFilter || filter.IncludeDeleted) + { + userRoles = userRoles.IgnoreQueryFilters(); + } + + if (!ignoreTenantFilter) + { + if (filter.TenantId.HasValue && filter.TenantId.Value != 0) + { + userRoles = userRoles.Where(x => x.TenantId == filter.TenantId.Value); + } + } + else if (filter.TenantId.HasValue && filter.TenantId.Value != 0) + { + userRoles = userRoles.Where(x => x.TenantId == filter.TenantId.Value); + } + + if (!filter.IncludeDeleted) + { + userRoles = userRoles.Where(x => x.DeletedAt == null); + } + + query = query.Where(user => userRoles.Any(role => role.UserId == user.Id && role.RoleId == roleId)); + } + + // 6. 时间范围过滤 + if (filter.CreatedAtFrom.HasValue) + { + query = query.Where(x => x.CreatedAt >= filter.CreatedAtFrom.Value); + } + + if (filter.CreatedAtTo.HasValue) + { + query = query.Where(x => x.CreatedAt <= filter.CreatedAtTo.Value); + } + + if (filter.LastLoginFrom.HasValue) + { + query = query.Where(x => x.LastLoginAt >= filter.LastLoginFrom.Value); + } + + if (filter.LastLoginTo.HasValue) + { + query = query.Where(x => x.LastLoginAt <= filter.LastLoginTo.Value); + } + + // 7. 排序 + var sorted = filter.SortBy?.ToLowerInvariant() switch + { + "account" => filter.SortDescending + ? query.OrderByDescending(x => x.Account) + : query.OrderBy(x => x.Account), + "displayname" => filter.SortDescending + ? query.OrderByDescending(x => x.DisplayName) + : query.OrderBy(x => x.DisplayName), + "status" => filter.SortDescending + ? query.OrderByDescending(x => x.Status) + : query.OrderBy(x => x.Status), + "lastloginat" => filter.SortDescending + ? query.OrderByDescending(x => x.LastLoginAt) + : query.OrderBy(x => x.LastLoginAt), + _ => filter.SortDescending + ? query.OrderByDescending(x => x.CreatedAt) + : query.OrderBy(x => x.CreatedAt) + }; + + // 8. 分页 + var page = filter.Page <= 0 ? 1 : filter.Page; + var pageSize = filter.PageSize <= 0 ? 20 : filter.PageSize; + var total = await sorted.CountAsync(cancellationToken); + var items = await sorted + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, total); + } + /// /// 根据 ID 集合批量获取后台用户(只读)。 /// @@ -101,6 +361,50 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + /// + /// 批量获取后台用户(可用于更新,支持包含已删除数据)。 + /// + /// 租户 ID。 + /// 用户 ID 集合。 + /// 是否包含已删除数据。 + /// 是否忽略租户过滤器。 + /// 取消标记。 + /// 后台用户列表。 + public Task> GetForUpdateByIdsAsync( + long tenantId, + IEnumerable userIds, + bool includeDeleted, + bool ignoreTenantFilter = false, + CancellationToken cancellationToken = default) + { + // 1. 构建基础查询 + var ids = userIds.Distinct().ToArray(); + if (ids.Length == 0) + { + return Task.FromResult>(Array.Empty()); + } + + var query = dbContext.IdentityUsers.Where(x => ids.Contains(x.Id)); + if (ignoreTenantFilter || includeDeleted) + { + query = query.IgnoreQueryFilters(); + } + + if (!ignoreTenantFilter) + { + query = query.Where(x => x.TenantId == tenantId); + } + + if (!includeDeleted) + { + query = query.Where(x => x.DeletedAt == null); + } + + // 2. 返回列表 + return query.ToListAsync(cancellationToken) + .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); + } + /// /// 新增后台用户。 /// @@ -115,6 +419,20 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde return Task.CompletedTask; } + /// + /// 删除后台用户(软删除)。 + /// + /// 后台用户实体。 + /// 取消标记。 + /// 异步任务。 + public Task RemoveAsync(IdentityUser user, CancellationToken cancellationToken = default) + { + // 1. 标记删除 + dbContext.IdentityUsers.Remove(user); + // 2. 返回完成任务 + return Task.CompletedTask; + } + /// /// 持久化仓储变更。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs index e8b0683..4f15b94 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfPermissionRepository.cs @@ -17,7 +17,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi /// 取消标记。 /// 权限实体或 null。 public Task FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default) - => dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken); + => dbContext.Permissions + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken); /// /// 根据权限编码获取权限。 @@ -27,7 +30,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi /// 取消标记。 /// 权限实体或 null。 public Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default) - => dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId, cancellationToken); + => dbContext.Permissions + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken); /// /// 根据权限编码集合批量获取权限。 @@ -46,8 +52,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi .ToArray(); // 2. 按租户筛选权限 - return dbContext.Permissions.AsNoTracking() - .Where(x => x.TenantId == tenantId && normalizedCodes.Contains(x.Code)) + return dbContext.Permissions + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeletedAt == null && normalizedCodes.Contains(x.Code)) .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); } @@ -60,8 +68,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi /// 取消标记。 /// 权限列表。 public Task> GetByIdsAsync(long tenantId, IEnumerable permissionIds, CancellationToken cancellationToken = default) - => dbContext.Permissions.AsNoTracking() - .Where(x => x.TenantId == tenantId && permissionIds.Contains(x.Id)) + => dbContext.Permissions + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeletedAt == null && permissionIds.Contains(x.Id)) .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); @@ -75,7 +85,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi public Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default) { // 1. 构建基础查询 - var query = dbContext.Permissions.AsNoTracking().Where(x => x.TenantId == tenantId); + var query = dbContext.Permissions + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeletedAt == null); if (!string.IsNullOrWhiteSpace(keyword)) { // 2. 追加关键字过滤 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs index ae13a45..8edaa7b 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRolePermissionRepository.cs @@ -17,8 +17,10 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR /// 取消标记。 /// 角色权限映射列表。 public Task> GetByRoleIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default) - => dbContext.RolePermissions.AsNoTracking() - .Where(x => x.TenantId == tenantId && roleIds.Contains(x.RoleId)) + => dbContext.RolePermissions + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeletedAt == null && roleIds.Contains(x.RoleId)) .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs index aa7241f..0916a90 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfRoleRepository.cs @@ -17,7 +17,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit /// 取消标记。 /// 角色实体或 null。 public Task FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default) - => dbContext.Roles.AsNoTracking().FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken); + => dbContext.Roles + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken); /// /// 根据角色编码获取角色。 @@ -27,7 +30,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit /// 取消标记。 /// 角色实体或 null。 public Task FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default) - => dbContext.Roles.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken); + => dbContext.Roles + .IgnoreQueryFilters() + .AsNoTracking() + .FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId && x.DeletedAt == null, cancellationToken); /// /// 根据角色 ID 集合获取角色列表。 @@ -37,7 +43,9 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit /// 取消标记。 /// 角色列表。 public Task> GetByIdsAsync(long tenantId, IEnumerable roleIds, CancellationToken cancellationToken = default) - => dbContext.Roles.AsNoTracking() + => dbContext.Roles + .IgnoreQueryFilters() + .AsNoTracking() .Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id) && x.DeletedAt == null) .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); @@ -52,7 +60,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit public Task> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default) { // 1. 构建基础查询 - var query = dbContext.Roles.AsNoTracking().Where(x => x.TenantId == tenantId && x.DeletedAt == null); + var query = dbContext.Roles + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeletedAt == null); if (!string.IsNullOrWhiteSpace(keyword)) { // 2. 追加关键字过滤 diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs index 95c120b..c82e62e 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/EfUserRoleRepository.cs @@ -17,8 +17,10 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol /// 取消标记。 /// 用户角色映射列表。 public Task> GetByUserIdsAsync(long tenantId, IEnumerable userIds, CancellationToken cancellationToken = default) - => dbContext.UserRoles.AsNoTracking() - .Where(x => x.TenantId == tenantId && userIds.Contains(x.UserId)) + => dbContext.UserRoles + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeletedAt == null && userIds.Contains(x.UserId)) .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); @@ -30,8 +32,10 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol /// 取消标记。 /// 用户角色列表。 public Task> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default) - => dbContext.UserRoles.AsNoTracking() - .Where(x => x.TenantId == tenantId && x.UserId == userId) + => dbContext.UserRoles + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.UserId == userId) .ToListAsync(cancellationToken) .ContinueWith(t => (IReadOnlyList)t.Result, cancellationToken); @@ -53,6 +57,7 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol // 2. 读取当前角色映射 var existing = await dbContext.UserRoles + .IgnoreQueryFilters() .Where(x => x.TenantId == tenantId && x.UserId == userId) .ToListAsync(cancellationToken); @@ -76,6 +81,20 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol }); } + /// + /// 统计指定角色下的用户数量。 + /// + /// 租户 ID。 + /// 角色 ID。 + /// 取消标记。 + /// 用户数量。 + public Task CountUsersByRoleAsync(long tenantId, long roleId, CancellationToken cancellationToken = default) + => dbContext.UserRoles + .IgnoreQueryFilters() + .AsNoTracking() + .Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.RoleId == roleId) + .CountAsync(cancellationToken); + /// /// 保存仓储变更。 /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs index cb4212e..792fc14 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Identity/Persistence/IdentityDbContext.cs @@ -93,13 +93,30 @@ public sealed class IdentityDbContext( builder.Property(x => x.Account).HasMaxLength(64).IsRequired(); builder.Property(x => x.DisplayName).HasMaxLength(64).IsRequired(); builder.Property(x => x.PasswordHash).HasMaxLength(256).IsRequired(); + builder.Property(x => x.Phone).HasMaxLength(32); + builder.Property(x => x.Email).HasMaxLength(128); + builder.Property(x => x.Status).HasConversion(); + builder.Property(x => x.FailedLoginCount).IsRequired(); + builder.Property(x => x.LockedUntil); + builder.Property(x => x.LastLoginAt); + builder.Property(x => x.MustChangePassword).IsRequired(); builder.Property(x => x.Avatar).HasColumnType("text"); + builder.Property(x => x.RowVersion) + .IsRowVersion() + .IsConcurrencyToken() + .HasColumnType("bytea"); builder.Property(x => x.TenantId).IsRequired(); ConfigureAuditableEntity(builder); ConfigureSoftDeleteEntity(builder); builder.HasIndex(x => x.TenantId); builder.HasIndex(x => new { x.TenantId, x.Account }).IsUnique(); + builder.HasIndex(x => new { x.TenantId, x.Phone }) + .IsUnique() + .HasFilter("\"Phone\" IS NOT NULL"); + builder.HasIndex(x => new { x.TenantId, x.Email }) + .IsUnique() + .HasFilter("\"Email\" IS NOT NULL"); } /// diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Repositories/EfOperationLogRepository.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Repositories/EfOperationLogRepository.cs new file mode 100644 index 0000000..21a567a --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Logs/Repositories/EfOperationLogRepository.cs @@ -0,0 +1,24 @@ +using TakeoutSaaS.Domain.Tenants.Entities; +using TakeoutSaaS.Domain.Tenants.Repositories; +using TakeoutSaaS.Infrastructure.Logs.Persistence; + +namespace TakeoutSaaS.Infrastructure.Logs.Repositories; + +/// +/// 运营操作日志仓储实现。 +/// +public sealed class EfOperationLogRepository(TakeoutLogsDbContext logsContext) : IOperationLogRepository +{ + /// + public Task AddAsync(OperationLog log, CancellationToken cancellationToken = default) + { + // 1. 添加日志实体 + logsContext.OperationLogs.Add(log); + // 2. 返回完成任务 + return Task.CompletedTask; + } + + /// + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => logsContext.SaveChangesAsync(cancellationToken); +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251226174411_AddIdentityUserManagementFields.Designer.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251226174411_AddIdentityUserManagementFields.Designer.cs new file mode 100644 index 0000000..f4bfb9c --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251226174411_AddIdentityUserManagementFields.Designer.cs @@ -0,0 +1,726 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TakeoutSaaS.Infrastructure.Identity.Persistence; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20251226174411_AddIdentityUserManagementFields")] + partial class AddIdentityUserManagementFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.IdentityUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Account") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("登录账号。"); + + b.Property("Avatar") + .HasColumnType("text") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("展示名称。"); + + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("邮箱(租户内唯一)。"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasComment("登录失败次数。"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近登录时间(UTC)。"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasComment("锁定截止时间(UTC)。"); + + b.Property("MerchantId") + .HasColumnType("bigint") + .HasComment("所属商户(平台管理员为空)。"); + + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasComment("是否强制修改密码。"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("密码哈希。"); + + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号(租户内唯一)。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("账号状态。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Account") + .IsUnique(); + + b.HasIndex("TenantId", "Email") + .IsUnique() + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("TenantId", "Phone") + .IsUnique() + .HasFilter("\"Phone\" IS NOT NULL"); + + b.ToTable("identity_users", null, t => + { + t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MenuDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthListJson") + .HasColumnType("text") + .HasComment("按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。"); + + b.Property("Component") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("组件路径(不含 .vue)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Icon") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("图标标识。"); + + b.Property("IsIframe") + .HasColumnType("boolean") + .HasComment("是否 iframe。"); + + b.Property("KeepAlive") + .HasColumnType("boolean") + .HasComment("是否缓存。"); + + b.Property("Link") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasComment("外链或 iframe 地址。"); + + b.Property("MetaPermissions") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Meta.permissions(逗号分隔)。"); + + b.Property("MetaRoles") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("Meta.roles(逗号分隔)。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("菜单名称(前端路由 name)。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级菜单 ID,根节点为 0。"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("路由路径。"); + + b.Property("RequiredPermissions") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasComment("访问该菜单所需的权限集合(逗号分隔)。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("标题。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId", "ParentId", "SortOrder"); + + b.ToTable("menu_definitions", null, t => + { + t.HasComment("管理端菜单定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.MiniUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Avatar") + .HasColumnType("text") + .HasComment("头像地址。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Nickname") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("昵称。"); + + b.Property("OpenId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 OpenId。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UnionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("微信 UnionId,可能为空。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "OpenId") + .IsUnique(); + + b.ToTable("mini_users", null, t => + { + t.HasComment("小程序用户实体。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("权限编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("权限名称。"); + + b.Property("ParentId") + .HasColumnType("bigint") + .HasComment("父级权限 ID,根节点为 0。"); + + b.Property("SortOrder") + .HasColumnType("integer") + .HasComment("排序值,值越小越靠前。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)") + .HasComment("权限类型(group/leaf)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.HasIndex("TenantId", "ParentId", "SortOrder"); + + b.ToTable("permissions", null, t => + { + t.HasComment("权限定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色编码(租户内唯一)。"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("描述。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("角色名称。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Code") + .IsUnique(); + + b.ToTable("roles", null, t => + { + t.HasComment("角色定义。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PermissionId") + .HasColumnType("bigint") + .HasComment("权限 ID。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "RoleId", "PermissionId") + .IsUnique(); + + b.ToTable("role_permissions", null, t => + { + t.HasComment("角色-权限关系。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasComment("模板描述。"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasComment("是否启用。"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("模板名称。"); + + b.Property("TemplateCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasComment("模板编码(唯一)。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("TemplateCode") + .IsUnique(); + + b.ToTable("role_templates", null, t => + { + t.HasComment("角色模板定义(平台级)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("PermissionCode") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("权限编码。"); + + b.Property("RoleTemplateId") + .HasColumnType("bigint") + .HasComment("模板 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.HasKey("Id"); + + b.HasIndex("RoleTemplateId", "PermissionCode") + .IsUnique(); + + b.ToTable("role_template_permissions", null, t => + { + t.HasComment("角色模板-权限关系(平台级)。"); + }); + }); + + modelBuilder.Entity("TakeoutSaaS.Domain.Identity.Entities.UserRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasComment("实体唯一标识。"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("创建时间(UTC)。"); + + b.Property("CreatedBy") + .HasColumnType("bigint") + .HasComment("创建人用户标识,匿名或系统操作时为 null。"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasComment("软删除时间(UTC),未删除时为 null。"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasComment("删除人用户标识(软删除),未删除时为 null。"); + + b.Property("RoleId") + .HasColumnType("bigint") + .HasComment("角色 ID。"); + + b.Property("TenantId") + .HasColumnType("bigint") + .HasComment("所属租户 ID。"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近一次更新时间(UTC),从未更新时为 null。"); + + b.Property("UpdatedBy") + .HasColumnType("bigint") + .HasComment("最后更新人用户标识,匿名或系统操作时为 null。"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasComment("用户 ID。"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "UserId", "RoleId") + .IsUnique(); + + b.ToTable("user_roles", null, t => + { + t.HasComment("用户-角色关系。"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251226174411_AddIdentityUserManagementFields.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251226174411_AddIdentityUserManagementFields.cs new file mode 100644 index 0000000..a7a6f13 --- /dev/null +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/20251226174411_AddIdentityUserManagementFields.cs @@ -0,0 +1,178 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb +{ + /// + public partial class AddIdentityUserManagementFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Email", + table: "identity_users", + type: "character varying(128)", + maxLength: 128, + nullable: true, + comment: "邮箱(租户内唯一)。"); + + migrationBuilder.AddColumn( + name: "FailedLoginCount", + table: "identity_users", + type: "integer", + nullable: false, + defaultValue: 0, + comment: "登录失败次数。"); + + migrationBuilder.AddColumn( + name: "LastLoginAt", + table: "identity_users", + type: "timestamp with time zone", + nullable: true, + comment: "最近登录时间(UTC)。"); + + migrationBuilder.AddColumn( + name: "LockedUntil", + table: "identity_users", + type: "timestamp with time zone", + nullable: true, + comment: "锁定截止时间(UTC)。"); + + migrationBuilder.AddColumn( + name: "MustChangePassword", + table: "identity_users", + type: "boolean", + nullable: false, + defaultValue: false, + comment: "是否强制修改密码。"); + + migrationBuilder.AddColumn( + name: "Phone", + table: "identity_users", + type: "character varying(32)", + maxLength: 32, + nullable: true, + comment: "手机号(租户内唯一)。"); + + migrationBuilder.AddColumn( + name: "RowVersion", + table: "identity_users", + type: "bytea", + rowVersion: true, + nullable: false, + defaultValue: new byte[0], + comment: "并发控制字段。"); + + migrationBuilder.AddColumn( + name: "Status", + table: "identity_users", + type: "integer", + nullable: false, + defaultValue: 1, + comment: "账号状态。"); + + // 1. 修复历史用户默认状态为启用 + migrationBuilder.Sql( + """ + UPDATE identity_users + SET "Status" = 1 + WHERE "Status" = 0; + """); + + // 2. 创建 RowVersion 触发器,确保并发字段自动更新 + migrationBuilder.Sql( + """ + CREATE OR REPLACE FUNCTION public.set_identity_user_row_version() + RETURNS trigger AS $$ + BEGIN + NEW."RowVersion" = decode(md5(random()::text || clock_timestamp()::text), 'hex'); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + """); + + migrationBuilder.Sql( + """ + DROP TRIGGER IF EXISTS trg_identity_users_row_version ON identity_users; + CREATE TRIGGER trg_identity_users_row_version + BEFORE INSERT OR UPDATE ON identity_users + FOR EACH ROW EXECUTE FUNCTION public.set_identity_user_row_version(); + """); + + // 3. 回填已有数据的 RowVersion + migrationBuilder.Sql( + """ + UPDATE identity_users + SET "RowVersion" = decode(md5(random()::text || clock_timestamp()::text), 'hex') + WHERE "RowVersion" IS NULL OR octet_length("RowVersion") = 0; + """); + + migrationBuilder.CreateIndex( + name: "IX_identity_users_TenantId_Email", + table: "identity_users", + columns: new[] { "TenantId", "Email" }, + unique: true, + filter: "\"Email\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_identity_users_TenantId_Phone", + table: "identity_users", + columns: new[] { "TenantId", "Phone" }, + unique: true, + filter: "\"Phone\" IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + DROP TRIGGER IF EXISTS trg_identity_users_row_version ON identity_users; + DROP FUNCTION IF EXISTS public.set_identity_user_row_version(); + """); + + migrationBuilder.DropIndex( + name: "IX_identity_users_TenantId_Email", + table: "identity_users"); + + migrationBuilder.DropIndex( + name: "IX_identity_users_TenantId_Phone", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "Email", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "FailedLoginCount", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "LastLoginAt", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "LockedUntil", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "MustChangePassword", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "Phone", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "RowVersion", + table: "identity_users"); + + migrationBuilder.DropColumn( + name: "Status", + table: "identity_users"); + } + } +} diff --git a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs index e9b454c..e74d9ce 100644 --- a/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs +++ b/src/Infrastructure/TakeoutSaaS.Infrastructure/Migrations/IdentityDb/IdentityDbContextModelSnapshot.cs @@ -63,16 +63,53 @@ namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb .HasColumnType("character varying(64)") .HasComment("展示名称。"); + b.Property("Email") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasComment("邮箱(租户内唯一)。"); + + b.Property("FailedLoginCount") + .HasColumnType("integer") + .HasComment("登录失败次数。"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone") + .HasComment("最近登录时间(UTC)。"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone") + .HasComment("锁定截止时间(UTC)。"); + b.Property("MerchantId") .HasColumnType("bigint") .HasComment("所属商户(平台管理员为空)。"); + b.Property("MustChangePassword") + .HasColumnType("boolean") + .HasComment("是否强制修改密码。"); + b.Property("PasswordHash") .IsRequired() .HasMaxLength(256) .HasColumnType("character varying(256)") .HasComment("密码哈希。"); + b.Property("Phone") + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasComment("手机号(租户内唯一)。"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasComment("并发控制字段。"); + + b.Property("Status") + .HasColumnType("integer") + .HasComment("账号状态。"); + b.Property("TenantId") .HasColumnType("bigint") .HasComment("所属租户 ID。"); @@ -92,6 +129,14 @@ namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb b.HasIndex("TenantId", "Account") .IsUnique(); + b.HasIndex("TenantId", "Email") + .IsUnique() + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("TenantId", "Phone") + .IsUnique() + .HasFilter("\"Phone\" IS NOT NULL"); + b.ToTable("identity_users", null, t => { t.HasComment("管理后台账户实体(平台管理员、租户管理员或商户员工)。");