feat: 用户管理后端与日志库迁移
This commit is contained in:
212
src/Api/TakeoutSaaS.AdminApi/Controllers/UsersController.cs
Normal file
212
src/Api/TakeoutSaaS.AdminApi/Controllers/UsersController.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 用户管理。
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
[Authorize]
|
||||
[Route("api/admin/v{version:apiVersion}/users")]
|
||||
public sealed class UsersController(IMediator mediator) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户分页列表。
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[PermissionAuthorize("identity:user:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<PagedResult<UserListItemDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<PagedResult<UserListItemDto>>> List(
|
||||
[FromQuery] SearchIdentityUsersQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询用户分页
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
// 2. 返回分页数据
|
||||
return ApiResponse<PagedResult<UserListItemDto>>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用户详情。
|
||||
/// </summary>
|
||||
[HttpGet("{userId:long}")]
|
||||
[PermissionAuthorize("identity:user:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<UserDetailDto>> 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<UserDetailDto>.Error(ErrorCodes.NotFound, "用户不存在")
|
||||
: ApiResponse<UserDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用户权限明细。
|
||||
/// </summary>
|
||||
[HttpGet("{userId:long}/permissions")]
|
||||
[PermissionAuthorize("identity:user:read")]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<string>>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<IReadOnlyList<string>>> Permissions(long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 查询用户详情并提取权限
|
||||
var detail = await mediator.Send(new GetIdentityUserDetailQuery { UserId = userId }, cancellationToken);
|
||||
if (detail == null)
|
||||
{
|
||||
return ApiResponse<IReadOnlyList<string>>.Error(ErrorCodes.NotFound, "用户不存在");
|
||||
}
|
||||
|
||||
// 2. 返回权限编码列表
|
||||
return ApiResponse<IReadOnlyList<string>>.Ok(detail.Permissions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建用户。
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[PermissionAuthorize("identity:user:create")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserDetailDto>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<UserDetailDto>> Create([FromBody, Required] CreateIdentityUserCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建用户
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回创建结果
|
||||
return ApiResponse<UserDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户。
|
||||
/// </summary>
|
||||
[HttpPut("{userId:long}")]
|
||||
[PermissionAuthorize("identity:user:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserDetailDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserDetailDto>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<UserDetailDto>> 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<UserDetailDto>.Error(ErrorCodes.NotFound, "用户不存在")
|
||||
: ApiResponse<UserDetailDto>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除用户。
|
||||
/// </summary>
|
||||
[HttpDelete("{userId:long}")]
|
||||
[PermissionAuthorize("identity:user:delete")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Delete(long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行删除
|
||||
var result = await mediator.Send(new DeleteIdentityUserCommand { UserId = userId }, cancellationToken);
|
||||
|
||||
// 2. 返回结果或 404
|
||||
return result
|
||||
? ApiResponse.Success()
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "用户不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 恢复用户。
|
||||
/// </summary>
|
||||
[HttpPost("{userId:long}/restore")]
|
||||
[PermissionAuthorize("identity:user:update")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> Restore(long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行恢复
|
||||
var result = await mediator.Send(new RestoreIdentityUserCommand { UserId = userId }, cancellationToken);
|
||||
|
||||
// 2. 返回结果或 404
|
||||
return result
|
||||
? ApiResponse.Success()
|
||||
: ApiResponse<object>.Error(ErrorCodes.NotFound, "用户不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户状态。
|
||||
/// </summary>
|
||||
[HttpPut("{userId:long}/status")]
|
||||
[PermissionAuthorize("identity:user:status")]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
public async Task<ApiResponse<object>> 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<object>.Error(ErrorCodes.NotFound, "用户不存在");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成重置密码链接。
|
||||
/// </summary>
|
||||
[HttpPost("{userId:long}/password-reset")]
|
||||
[PermissionAuthorize("identity:user:reset-password")]
|
||||
[ProducesResponseType(typeof(ApiResponse<ResetIdentityUserPasswordResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<ResetIdentityUserPasswordResult>> ResetPassword(long userId, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 生成重置令牌
|
||||
var result = await mediator.Send(new ResetIdentityUserPasswordCommand { UserId = userId }, cancellationToken);
|
||||
|
||||
// 2. 返回令牌信息
|
||||
return ApiResponse<ResetIdentityUserPasswordResult>.Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户操作。
|
||||
/// </summary>
|
||||
[HttpPost("batch")]
|
||||
[PermissionAuthorize("identity:user:batch")]
|
||||
[ProducesResponseType(typeof(ApiResponse<BatchIdentityUserOperationResult>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<BatchIdentityUserOperationResult>> Batch(
|
||||
[FromBody, Required] BatchIdentityUserOperationCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 执行批量操作
|
||||
var result = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// 2. 返回操作结果
|
||||
return ApiResponse<BatchIdentityUserOperationResult>.Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户操作命令。
|
||||
/// </summary>
|
||||
public sealed record BatchIdentityUserOperationCommand : IRequest<BatchIdentityUserOperationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标租户 ID(超级管理员可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 操作类型。
|
||||
/// </summary>
|
||||
public IdentityUserBatchOperation Operation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户 ID 列表(字符串)。
|
||||
/// </summary>
|
||||
public string[] UserIds { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户状态命令。
|
||||
/// </summary>
|
||||
public sealed record ChangeIdentityUserStatusCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标租户 ID(超级管理员可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标状态。
|
||||
/// </summary>
|
||||
public IdentityUserStatus Status { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 创建用户命令。
|
||||
/// </summary>
|
||||
public sealed record CreateIdentityUserCommand : IRequest<UserDetailDto>
|
||||
{
|
||||
/// <summary>
|
||||
/// 目标租户 ID(超级管理员可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录账号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string Account { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 初始密码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(32, MinimumLength = 6)]
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Phone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
[StringLength(128)]
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
[StringLength(512)]
|
||||
public string? Avatar { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色 ID 列表(字符串)。
|
||||
/// </summary>
|
||||
public string[] RoleIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 初始状态。
|
||||
/// </summary>
|
||||
public IdentityUserStatus? Status { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 删除用户命令。
|
||||
/// </summary>
|
||||
public sealed record DeleteIdentityUserCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标租户 ID(超级管理员可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 生成用户重置密码链接命令。
|
||||
/// </summary>
|
||||
public sealed record ResetIdentityUserPasswordCommand : IRequest<ResetIdentityUserPasswordResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标租户 ID(超级管理员可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 恢复用户命令。
|
||||
/// </summary>
|
||||
public sealed record RestoreIdentityUserCommand : IRequest<bool>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标租户 ID(超级管理员可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using MediatR;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户命令。
|
||||
/// </summary>
|
||||
public sealed record UpdateIdentityUserCommand : IRequest<UserDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 目标租户 ID(超级管理员可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[StringLength(64)]
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
[StringLength(32)]
|
||||
public string? Phone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
[StringLength(128)]
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
[StringLength(512)]
|
||||
public string? Avatar { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色 ID 列表(字符串)。
|
||||
/// </summary>
|
||||
public string[]? RoleIds { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 批量操作失败项。
|
||||
/// </summary>
|
||||
public sealed record BatchIdentityUserFailureItem
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败原因。
|
||||
/// </summary>
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户操作结果。
|
||||
/// </summary>
|
||||
public sealed record BatchIdentityUserOperationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 成功数量。
|
||||
/// </summary>
|
||||
public int SuccessCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败数量。
|
||||
/// </summary>
|
||||
public int FailureCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 失败明细。
|
||||
/// </summary>
|
||||
public IReadOnlyList<BatchIdentityUserFailureItem> Failures { get; init; } = Array.Empty<BatchIdentityUserFailureItem>();
|
||||
|
||||
/// <summary>
|
||||
/// 导出数据(仅导出操作返回)。
|
||||
/// </summary>
|
||||
public IReadOnlyList<UserListItemDto> ExportItems { get; init; } = Array.Empty<UserListItemDto>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 管理员重置密码结果。
|
||||
/// </summary>
|
||||
public sealed record ResetIdentityUserPasswordResult
|
||||
{
|
||||
/// <summary>
|
||||
/// 重置令牌。
|
||||
/// </summary>
|
||||
public string Token { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 过期时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime ExpiresAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 用户详情 DTO。
|
||||
/// </summary>
|
||||
public sealed record UserDetailDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 商户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(NullableSnowflakeIdJsonConverter))]
|
||||
public long? MerchantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录账号。
|
||||
/// </summary>
|
||||
public string Account { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string? Phone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户状态。
|
||||
/// </summary>
|
||||
public IdentityUserStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于锁定状态。
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色编码列表。
|
||||
/// </summary>
|
||||
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 角色 ID 列表(字符串)。
|
||||
/// </summary>
|
||||
public string[] RoleIds { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 权限编码列表。
|
||||
/// </summary>
|
||||
public string[] Permissions { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近登录时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastLoginAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
public string? Avatar { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制版本。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; init; } = Array.Empty<byte>();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Serialization;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// 用户列表项 DTO。
|
||||
/// </summary>
|
||||
public sealed record UserListItemDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(SnowflakeIdJsonConverter))]
|
||||
public long TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 登录账号。
|
||||
/// </summary>
|
||||
public string Account { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号。
|
||||
/// </summary>
|
||||
public string? Phone { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱。
|
||||
/// </summary>
|
||||
public string? Email { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户状态。
|
||||
/// </summary>
|
||||
public IdentityUserStatus Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于锁定状态。
|
||||
/// </summary>
|
||||
public bool IsLocked { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否已删除。
|
||||
/// </summary>
|
||||
public bool IsDeleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色编码列表。
|
||||
/// </summary>
|
||||
public string[] Roles { get; init; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近登录时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastLoginAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户操作处理器。
|
||||
/// </summary>
|
||||
public sealed class BatchIdentityUserOperationCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<BatchIdentityUserOperationCommand, BatchIdentityUserOperationResult>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<BatchIdentityUserOperationResult> Handle(BatchIdentityUserOperationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户批量操作用户");
|
||||
}
|
||||
|
||||
if (isSuperAdmin && !request.TenantId.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "批量操作必须指定租户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 解析用户 ID 列表
|
||||
var tenantId = request.TenantId ?? currentTenantId;
|
||||
var userIds = ParseIds(request.UserIds, "用户");
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
return new BatchIdentityUserOperationResult
|
||||
{
|
||||
SuccessCount = 0,
|
||||
FailureCount = 0,
|
||||
Failures = Array.Empty<BatchIdentityUserFailureItem>(),
|
||||
ExportItems = Array.Empty<UserListItemDto>()
|
||||
};
|
||||
}
|
||||
|
||||
// 4. (空行后) 查询目标用户集合
|
||||
var includeDeleted = request.Operation == IdentityUserBatchOperation.Restore;
|
||||
var users = await identityUserRepository.GetForUpdateByIdsAsync(tenantId, userIds, includeDeleted, isSuperAdmin, cancellationToken);
|
||||
var usersById = users.ToDictionary(user => user.Id, user => user, EqualityComparer<long>.Default);
|
||||
|
||||
// 5. (空行后) 预计算租户管理员约束
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
||||
var tenantAdminUserIds = tenantAdminRole == null
|
||||
? Array.Empty<long>()
|
||||
: (await userRoleRepository.GetByUserIdsAsync(tenantId, usersById.Keys, cancellationToken))
|
||||
.Where(x => x.RoleId == tenantAdminRole.Id)
|
||||
.Select(x => x.UserId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var activeAdminCount = tenantAdminRole == null
|
||||
? 0
|
||||
: (await identityUserRepository.SearchPagedAsync(new IdentityUserSearchFilter
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = tenantAdminRole.Id,
|
||||
Status = IdentityUserStatus.Active,
|
||||
IncludeDeleted = false,
|
||||
Page = 1,
|
||||
PageSize = 1
|
||||
}, isSuperAdmin, cancellationToken)).Total;
|
||||
var remainingActiveAdmins = activeAdminCount;
|
||||
|
||||
// 6. (空行后) 执行批量操作
|
||||
var failures = new List<BatchIdentityUserFailureItem>();
|
||||
var successCount = 0;
|
||||
var exportItems = new List<UserListItemDto>();
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
if (!usersById.TryGetValue(userId, out var user))
|
||||
{
|
||||
failures.Add(new BatchIdentityUserFailureItem
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "用户不存在"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (request.Operation)
|
||||
{
|
||||
case IdentityUserBatchOperation.Enable:
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
user.LockedUntil = null;
|
||||
user.FailedLoginCount = 0;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
successCount++;
|
||||
break;
|
||||
case IdentityUserBatchOperation.Disable:
|
||||
if (user.Status == IdentityUserStatus.Active
|
||||
&& tenantAdminUserIds.Contains(user.Id)
|
||||
&& remainingActiveAdmins <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
}
|
||||
|
||||
if (user.Status == IdentityUserStatus.Active && tenantAdminUserIds.Contains(user.Id))
|
||||
{
|
||||
remainingActiveAdmins--;
|
||||
}
|
||||
|
||||
user.Status = IdentityUserStatus.Disabled;
|
||||
user.LockedUntil = null;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
successCount++;
|
||||
break;
|
||||
case IdentityUserBatchOperation.Delete:
|
||||
if (user.Status == IdentityUserStatus.Active
|
||||
&& tenantAdminUserIds.Contains(user.Id)
|
||||
&& remainingActiveAdmins <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
}
|
||||
|
||||
if (user.Status == IdentityUserStatus.Active && tenantAdminUserIds.Contains(user.Id))
|
||||
{
|
||||
remainingActiveAdmins--;
|
||||
}
|
||||
|
||||
await identityUserRepository.RemoveAsync(user, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
successCount++;
|
||||
break;
|
||||
case IdentityUserBatchOperation.Restore:
|
||||
if (!user.DeletedAt.HasValue)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "用户未删除");
|
||||
}
|
||||
|
||||
user.DeletedAt = null;
|
||||
user.DeletedBy = null;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
successCount++;
|
||||
break;
|
||||
case IdentityUserBatchOperation.Export:
|
||||
successCount++;
|
||||
break;
|
||||
default:
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "无效的批量操作类型");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (IsConcurrencyException(ex))
|
||||
{
|
||||
failures.Add(new BatchIdentityUserFailureItem
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = "用户数据已被修改,请刷新后重试"
|
||||
});
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
failures.Add(new BatchIdentityUserFailureItem
|
||||
{
|
||||
UserId = userId,
|
||||
Reason = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
// 6.1 (空行后) 处理导出数据
|
||||
if (request.Operation == IdentityUserBatchOperation.Export)
|
||||
{
|
||||
var roleCodesLookup = await ResolveRoleCodesAsync(users, userRoleRepository, roleRepository, cancellationToken);
|
||||
var now = DateTime.UtcNow;
|
||||
exportItems.AddRange(users.Select(user => new UserListItemDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Phone = user.Phone,
|
||||
Email = user.Email,
|
||||
Status = user.Status,
|
||||
IsLocked = user.Status == IdentityUserStatus.Locked || (user.LockedUntil.HasValue && user.LockedUntil.Value > now),
|
||||
IsDeleted = user.DeletedAt.HasValue,
|
||||
Roles = roleCodesLookup.TryGetValue(user.Id, out var codes) ? codes : Array.Empty<string>(),
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt
|
||||
}));
|
||||
}
|
||||
|
||||
// 7. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:batch",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(userIds),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new { tenantId, operation = request.Operation.ToString() }),
|
||||
Result = JsonSerializer.Serialize(new { successCount, failureCount = failures.Count }),
|
||||
Success = failures.Count == 0
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new BatchIdentityUserOperationResult
|
||||
{
|
||||
SuccessCount = successCount,
|
||||
FailureCount = failures.Count,
|
||||
Failures = failures,
|
||||
ExportItems = exportItems
|
||||
};
|
||||
}
|
||||
|
||||
private static long[] ParseIds(string[] values, string name)
|
||||
{
|
||||
// 1. 空数组直接返回
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
|
||||
// 2. (空行后) 解析并去重
|
||||
var ids = new List<long>(values.Length);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!long.TryParse(value, out var id) || id <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{name} ID 无效");
|
||||
}
|
||||
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// 3. (空行后) 返回去重结果
|
||||
return ids.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
||||
IReadOnlyList<IdentityUser> users,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 预分配字典容量
|
||||
var result = new Dictionary<long, string[]>(users.Count);
|
||||
|
||||
// 2. (空行后) 按租户分组,降低角色查询次数
|
||||
foreach (var group in users.GroupBy(user => user.TenantId))
|
||||
{
|
||||
var tenantId = group.Key;
|
||||
var userIds = group.Select(user => user.Id).Distinct().ToArray();
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户角色映射
|
||||
var relations = await userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
|
||||
if (relations.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. (空行后) 查询角色并构建映射
|
||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Role>()
|
||||
: await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
|
||||
|
||||
// 5. (空行后) 组装用户角色编码列表
|
||||
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
|
||||
{
|
||||
var codes = relationGroup
|
||||
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
|
||||
.OfType<string>()
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户状态处理器。
|
||||
/// </summary>
|
||||
public sealed class ChangeIdentityUserStatusCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<ChangeIdentityUserStatusCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(ChangeIdentityUserStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户修改用户状态");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. (空行后) 校验租户管理员保留规则
|
||||
if (request.Status == IdentityUserStatus.Disabled && user.Status == IdentityUserStatus.Active)
|
||||
{
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, isSuperAdmin, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. (空行后) 更新状态
|
||||
var previousStatus = user.Status;
|
||||
switch (request.Status)
|
||||
{
|
||||
case IdentityUserStatus.Active:
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
user.LockedUntil = null;
|
||||
user.FailedLoginCount = 0;
|
||||
break;
|
||||
case IdentityUserStatus.Disabled:
|
||||
user.Status = IdentityUserStatus.Disabled;
|
||||
user.LockedUntil = null;
|
||||
break;
|
||||
case IdentityUserStatus.Locked:
|
||||
user.Status = IdentityUserStatus.Locked;
|
||||
user.LockedUntil = null;
|
||||
break;
|
||||
default:
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "无效的用户状态");
|
||||
}
|
||||
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:status-change",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new
|
||||
{
|
||||
userId = user.Id,
|
||||
tenantId = user.TenantId,
|
||||
previousStatus = previousStatus.ToString(),
|
||||
currentStatus = user.Status.ToString()
|
||||
}),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, bool ignoreTenantFilter, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
||||
if (tenantAdminRole == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. (空行后) 判断用户是否为租户管理员
|
||||
var relations = await userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
|
||||
if (!relations.Any(x => x.RoleId == tenantAdminRole.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. (空行后) 统计活跃管理员数量
|
||||
var filter = new IdentityUserSearchFilter
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = tenantAdminRole.Id,
|
||||
Status = IdentityUserStatus.Active,
|
||||
IncludeDeleted = false,
|
||||
Page = 1,
|
||||
PageSize = 1
|
||||
};
|
||||
var result = await identityUserRepository.SearchPagedAsync(filter, ignoreTenantFilter, cancellationToken);
|
||||
if (result.Total <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 创建用户处理器。
|
||||
/// </summary>
|
||||
public sealed class CreateIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher<IdentityUser> passwordHasher,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<CreateIdentityUserCommand, UserDetailDto>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto> Handle(CreateIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户创建用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 规范化输入并准备校验
|
||||
var tenantId = isSuperAdmin ? request.TenantId ?? currentTenantId : currentTenantId;
|
||||
var account = request.Account.Trim();
|
||||
var displayName = request.DisplayName.Trim();
|
||||
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
||||
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
||||
var roleIds = ParseIds(request.RoleIds, "角色");
|
||||
|
||||
// 4. (空行后) 唯一性校验
|
||||
if (await identityUserRepository.ExistsByAccountAsync(tenantId, account, null, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "账号已存在");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(phone)
|
||||
&& await identityUserRepository.ExistsByPhoneAsync(tenantId, phone, null, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email)
|
||||
&& await identityUserRepository.ExistsByEmailAsync(tenantId, email, null, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
|
||||
}
|
||||
|
||||
// 5. (空行后) 校验角色合法性
|
||||
if (roleIds.Length > 0)
|
||||
{
|
||||
var roles = await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
if (roles.Count != roleIds.Length)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
|
||||
}
|
||||
}
|
||||
|
||||
// 6. (空行后) 创建用户实体
|
||||
var user = new IdentityUser
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Account = account,
|
||||
DisplayName = displayName,
|
||||
Phone = phone,
|
||||
Email = email,
|
||||
Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim(),
|
||||
Status = request.Status ?? IdentityUserStatus.Active,
|
||||
FailedLoginCount = 0,
|
||||
LockedUntil = null,
|
||||
LastLoginAt = null,
|
||||
MustChangePassword = false,
|
||||
PasswordHash = string.Empty
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, request.Password);
|
||||
|
||||
// 7. (空行后) 持久化用户
|
||||
await identityUserRepository.AddAsync(user, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 8. (空行后) 绑定角色
|
||||
if (roleIds.Length > 0)
|
||||
{
|
||||
await userRoleRepository.ReplaceUserRolesAsync(tenantId, user.Id, roleIds, cancellationToken);
|
||||
}
|
||||
|
||||
// 9. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:create",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new
|
||||
{
|
||||
tenantId,
|
||||
account,
|
||||
displayName,
|
||||
phone,
|
||||
email,
|
||||
roleIds
|
||||
}),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 10. (空行后) 返回用户详情
|
||||
var detail = await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
|
||||
return detail ?? new UserDetailDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Phone = user.Phone,
|
||||
Email = user.Email,
|
||||
Status = user.Status,
|
||||
IsLocked = false,
|
||||
Roles = Array.Empty<string>(),
|
||||
RoleIds = Array.Empty<string>(),
|
||||
Permissions = Array.Empty<string>(),
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
Avatar = user.Avatar,
|
||||
RowVersion = user.RowVersion
|
||||
};
|
||||
}
|
||||
|
||||
private static long[] ParseIds(string[] values, string name)
|
||||
{
|
||||
// 1. 空数组直接返回
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
|
||||
// 2. (空行后) 解析并去重
|
||||
var ids = new List<long>(values.Length);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!long.TryParse(value, out var id) || id <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{name} ID 无效");
|
||||
}
|
||||
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// 3. (空行后) 返回去重结果
|
||||
return ids.Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 删除用户处理器。
|
||||
/// </summary>
|
||||
public sealed class DeleteIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<DeleteIdentityUserCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(DeleteIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户删除用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. (空行后) 校验租户管理员保留规则
|
||||
if (user.Status == IdentityUserStatus.Active)
|
||||
{
|
||||
await EnsureNotLastActiveTenantAdminAsync(user.TenantId, user.Id, isSuperAdmin, cancellationToken);
|
||||
}
|
||||
|
||||
// 5. (空行后) 软删除用户
|
||||
await identityUserRepository.RemoveAsync(user, cancellationToken);
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:delete",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task EnsureNotLastActiveTenantAdminAsync(long tenantId, long userId, bool ignoreTenantFilter, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取租户管理员角色
|
||||
var tenantAdminRole = await roleRepository.FindByCodeAsync("tenant-admin", tenantId, cancellationToken);
|
||||
if (tenantAdminRole == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. (空行后) 判断用户是否为租户管理员
|
||||
var relations = await userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
|
||||
if (!relations.Any(x => x.RoleId == tenantAdminRole.Id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. (空行后) 统计活跃管理员数量
|
||||
var filter = new IdentityUserSearchFilter
|
||||
{
|
||||
TenantId = tenantId,
|
||||
RoleId = tenantAdminRole.Id,
|
||||
Status = IdentityUserStatus.Active,
|
||||
IncludeDeleted = false,
|
||||
Page = 1,
|
||||
PageSize = 1
|
||||
};
|
||||
var result = await identityUserRepository.SearchPagedAsync(filter, ignoreTenantFilter, cancellationToken);
|
||||
if (result.Total <= 1)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "至少保留一个管理员");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户详情处理器。
|
||||
/// </summary>
|
||||
public sealed class GetIdentityUserDetailQueryHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
IRolePermissionRepository rolePermissionRepository,
|
||||
IPermissionRepository permissionRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<GetIdentityUserDetailQuery, UserDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto?> Handle(GetIdentityUserDetailQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 查询用户实体
|
||||
IdentityUser? user;
|
||||
if (request.IncludeDeleted)
|
||||
{
|
||||
user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(currentTenantId, request.UserId, isSuperAdmin, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. (空行后) 加载角色与权限
|
||||
var roleRelations = await userRoleRepository.GetByUserIdAsync(user.TenantId, user.Id, cancellationToken);
|
||||
var roleIds = roleRelations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Role>()
|
||||
: await roleRepository.GetByIdsAsync(user.TenantId, roleIds, cancellationToken);
|
||||
var roleCodes = roles.Select(x => x.Code)
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
var permissionIds = roleIds.Length == 0
|
||||
? Array.Empty<long>()
|
||||
: (await rolePermissionRepository.GetByRoleIdsAsync(user.TenantId, roleIds, cancellationToken))
|
||||
.Select(x => x.PermissionId)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
var permissions = permissionIds.Length == 0
|
||||
? Array.Empty<string>()
|
||||
: (await permissionRepository.GetByIdsAsync(user.TenantId, permissionIds, cancellationToken))
|
||||
.Select(x => x.Code)
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 4. (空行后) 组装详情 DTO
|
||||
var now = DateTime.UtcNow;
|
||||
return new UserDetailDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
MerchantId = user.MerchantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Phone = user.Phone,
|
||||
Email = user.Email,
|
||||
Status = user.Status,
|
||||
IsLocked = user.Status == IdentityUserStatus.Locked || (user.LockedUntil.HasValue && user.LockedUntil.Value > now),
|
||||
Roles = roleCodes,
|
||||
RoleIds = roleIds.Select(id => id.ToString()).ToArray(),
|
||||
Permissions = permissions,
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt,
|
||||
Avatar = user.Avatar,
|
||||
RowVersion = user.RowVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Identity;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
@@ -51,6 +52,13 @@ public sealed class ResetAdminPasswordByTokenCommandHandler(
|
||||
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||
user.MustChangePassword = false;
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockedUntil = null;
|
||||
if (user.Status == IdentityUserStatus.Locked)
|
||||
{
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
}
|
||||
await userRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 生成用户重置密码链接处理器。
|
||||
/// </summary>
|
||||
public sealed class ResetIdentityUserPasswordCommandHandler(
|
||||
IAdminPasswordResetTokenStore tokenStore,
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<ResetIdentityUserPasswordCommand, ResetIdentityUserPasswordResult>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<ResetIdentityUserPasswordResult> Handle(ResetIdentityUserPasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户重置密码");
|
||||
}
|
||||
|
||||
// 4. (空行后) 签发重置令牌(1 小时有效)
|
||||
var expiresAt = DateTime.UtcNow.AddHours(1);
|
||||
var token = await tokenStore.IssueAsync(user.Id, expiresAt, cancellationToken);
|
||||
|
||||
// 5. (空行后) 标记用户需重置密码
|
||||
user.MustChangePassword = true;
|
||||
user.FailedLoginCount = 0;
|
||||
user.LockedUntil = null;
|
||||
if (user.Status == IdentityUserStatus.Locked)
|
||||
{
|
||||
user.Status = IdentityUserStatus.Active;
|
||||
}
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:password-reset",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id, expiresAt }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new ResetIdentityUserPasswordResult
|
||||
{
|
||||
Token = token,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 恢复用户处理器。
|
||||
/// </summary>
|
||||
public sealed class RestoreIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository)
|
||||
: IRequestHandler<RestoreIdentityUserCommand, bool>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> Handle(RestoreIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户恢复用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户实体(包含已删除)
|
||||
var user = await identityUserRepository.GetForUpdateIncludingDeletedAsync(currentTenantId, request.UserId, isSuperAdmin, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.DeletedAt.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. (空行后) 恢复软删除状态
|
||||
user.DeletedAt = null;
|
||||
user.DeletedBy = null;
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 5. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:restore",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new { userId = user.Id, tenantId = user.TenantId }),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 用户分页查询处理器。
|
||||
/// </summary>
|
||||
public sealed class SearchIdentityUsersQueryHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService)
|
||||
: IRequestHandler<SearchIdentityUsersQuery, PagedResult<UserListItemDto>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<UserListItemDto>> Handle(SearchIdentityUsersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户查询用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 组装查询过滤条件
|
||||
var filter = new IdentityUserSearchFilter
|
||||
{
|
||||
TenantId = isSuperAdmin ? request.TenantId : currentTenantId,
|
||||
Keyword = request.Keyword,
|
||||
Status = request.Status,
|
||||
RoleId = request.RoleId,
|
||||
CreatedAtFrom = request.CreatedAtFrom,
|
||||
CreatedAtTo = request.CreatedAtTo,
|
||||
LastLoginFrom = request.LastLoginFrom,
|
||||
LastLoginTo = request.LastLoginTo,
|
||||
IncludeDeleted = request.IncludeDeleted,
|
||||
Page = request.Page,
|
||||
PageSize = request.PageSize,
|
||||
SortBy = request.SortBy,
|
||||
SortDescending = request.SortDescending
|
||||
};
|
||||
|
||||
// 4. (空行后) 执行分页查询
|
||||
var (items, total) = await identityUserRepository.SearchPagedAsync(filter, isSuperAdmin, cancellationToken);
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return new PagedResult<UserListItemDto>(Array.Empty<UserListItemDto>(), request.Page, request.PageSize, total);
|
||||
}
|
||||
|
||||
// 5. (空行后) 加载角色编码映射
|
||||
var roleCodesLookup = await ResolveRoleCodesAsync(items, userRoleRepository, roleRepository, cancellationToken);
|
||||
|
||||
// 6. (空行后) 组装 DTO
|
||||
var now = DateTime.UtcNow;
|
||||
var dtos = items.Select(user => new UserListItemDto
|
||||
{
|
||||
UserId = user.Id,
|
||||
TenantId = user.TenantId,
|
||||
Account = user.Account,
|
||||
DisplayName = user.DisplayName,
|
||||
Phone = user.Phone,
|
||||
Email = user.Email,
|
||||
Status = user.Status,
|
||||
IsLocked = IsLocked(user, now),
|
||||
IsDeleted = user.DeletedAt.HasValue,
|
||||
Roles = roleCodesLookup.TryGetValue(user.Id, out var codes) ? codes : Array.Empty<string>(),
|
||||
CreatedAt = user.CreatedAt,
|
||||
LastLoginAt = user.LastLoginAt
|
||||
}).ToList();
|
||||
|
||||
// 7. (空行后) 返回分页结果
|
||||
return new PagedResult<UserListItemDto>(dtos, request.Page, request.PageSize, total);
|
||||
}
|
||||
|
||||
private static bool IsLocked(IdentityUser user, DateTime now)
|
||||
=> user.Status == IdentityUserStatus.Locked
|
||||
|| (user.LockedUntil.HasValue && user.LockedUntil.Value > now);
|
||||
|
||||
private static async Task<Dictionary<long, string[]>> ResolveRoleCodesAsync(
|
||||
IReadOnlyList<IdentityUser> users,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 预分配字典容量
|
||||
var result = new Dictionary<long, string[]>(users.Count);
|
||||
|
||||
// 2. (空行后) 按租户分组,降低角色查询次数
|
||||
foreach (var group in users.GroupBy(user => user.TenantId))
|
||||
{
|
||||
var tenantId = group.Key;
|
||||
var userIds = group.Select(user => user.Id).Distinct().ToArray();
|
||||
if (userIds.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. (空行后) 查询用户角色映射
|
||||
var relations = await userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
|
||||
if (relations.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. (空行后) 查询角色并构建映射
|
||||
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
|
||||
var roles = roleIds.Length == 0
|
||||
? Array.Empty<Role>()
|
||||
: await roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
|
||||
var roleCodeMap = roles.ToDictionary(role => role.Id, role => role.Code, EqualityComparer<long>.Default);
|
||||
|
||||
// 5. (空行后) 组装用户角色编码列表
|
||||
foreach (var relationGroup in relations.GroupBy(x => x.UserId))
|
||||
{
|
||||
var codes = relationGroup
|
||||
.Select(x => roleCodeMap.TryGetValue(x.RoleId, out var code) ? code : null)
|
||||
.OfType<string>()
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
result[relationGroup.Key] = codes.Length == 0 ? Array.Empty<string>() : codes;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using MediatR;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Handlers;
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户处理器。
|
||||
/// </summary>
|
||||
public sealed class UpdateIdentityUserCommandHandler(
|
||||
IIdentityUserRepository identityUserRepository,
|
||||
IUserRoleRepository userRoleRepository,
|
||||
IRoleRepository roleRepository,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor,
|
||||
IAdminAuthService adminAuthService,
|
||||
IOperationLogRepository operationLogRepository,
|
||||
IMediator mediator)
|
||||
: IRequestHandler<UpdateIdentityUserCommand, UserDetailDto?>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDetailDto?> Handle(UpdateIdentityUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 获取操作者档案并判断权限
|
||||
var currentTenantId = tenantProvider.GetCurrentTenantId();
|
||||
var operatorProfile = await adminAuthService.GetProfileAsync(currentUserAccessor.UserId, cancellationToken);
|
||||
var isSuperAdmin = IdentityUserAccess.IsSuperAdmin(operatorProfile);
|
||||
|
||||
// 2. (空行后) 校验跨租户访问权限
|
||||
if (!isSuperAdmin && request.TenantId.HasValue && request.TenantId.Value != currentTenantId)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "禁止跨租户更新用户");
|
||||
}
|
||||
|
||||
// 3. (空行后) 获取用户实体
|
||||
var user = isSuperAdmin
|
||||
? await identityUserRepository.GetForUpdateIgnoringTenantAsync(request.UserId, cancellationToken)
|
||||
: await identityUserRepository.GetForUpdateAsync(request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin && user.TenantId != currentTenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. (空行后) 规范化输入并校验唯一性
|
||||
var displayName = request.DisplayName.Trim();
|
||||
var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim();
|
||||
var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim();
|
||||
var roleIds = request.RoleIds == null ? null : ParseIds(request.RoleIds, "角色");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(phone)
|
||||
&& !string.Equals(phone, user.Phone, StringComparison.OrdinalIgnoreCase)
|
||||
&& await identityUserRepository.ExistsByPhoneAsync(user.TenantId, phone, user.Id, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "手机号已存在");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email)
|
||||
&& !string.Equals(email, user.Email, StringComparison.OrdinalIgnoreCase)
|
||||
&& await identityUserRepository.ExistsByEmailAsync(user.TenantId, email, user.Id, cancellationToken))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "邮箱已存在");
|
||||
}
|
||||
|
||||
if (roleIds is { Length: > 0 })
|
||||
{
|
||||
var roles = await roleRepository.GetByIdsAsync(user.TenantId, roleIds, cancellationToken);
|
||||
if (roles.Count != roleIds.Length)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, "角色列表包含无效项");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. (空行后) 更新用户字段
|
||||
user.DisplayName = displayName;
|
||||
user.Phone = phone;
|
||||
user.Email = email;
|
||||
user.Avatar = string.IsNullOrWhiteSpace(request.Avatar) ? null : request.Avatar.Trim();
|
||||
user.RowVersion = request.RowVersion;
|
||||
|
||||
// 6. (空行后) 持久化用户更新
|
||||
try
|
||||
{
|
||||
await identityUserRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (IsConcurrencyException(ex))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Conflict, "用户数据已被修改,请刷新后重试");
|
||||
}
|
||||
|
||||
// 7. (空行后) 覆盖角色绑定(仅当显式传入时)
|
||||
if (roleIds != null)
|
||||
{
|
||||
await userRoleRepository.ReplaceUserRolesAsync(user.TenantId, user.Id, roleIds, cancellationToken);
|
||||
}
|
||||
|
||||
// 8. (空行后) 写入操作日志
|
||||
var operatorName = string.IsNullOrWhiteSpace(operatorProfile.DisplayName)
|
||||
? operatorProfile.Account
|
||||
: operatorProfile.DisplayName;
|
||||
if (string.IsNullOrWhiteSpace(operatorName))
|
||||
{
|
||||
operatorName = $"user:{currentUserAccessor.UserId}";
|
||||
}
|
||||
|
||||
var log = new OperationLog
|
||||
{
|
||||
OperationType = "identity-user:update",
|
||||
TargetType = "identity_user",
|
||||
TargetIds = JsonSerializer.Serialize(new[] { user.Id }),
|
||||
OperatorId = currentUserAccessor.UserId.ToString(),
|
||||
OperatorName = operatorName,
|
||||
Parameters = JsonSerializer.Serialize(new
|
||||
{
|
||||
userId = user.Id,
|
||||
displayName,
|
||||
phone,
|
||||
email,
|
||||
roleIds
|
||||
}),
|
||||
Result = JsonSerializer.Serialize(new { userId = user.Id }),
|
||||
Success = true
|
||||
};
|
||||
await operationLogRepository.AddAsync(log, cancellationToken);
|
||||
await operationLogRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 9. (空行后) 返回用户详情
|
||||
return await mediator.Send(new GetIdentityUserDetailQuery { UserId = user.Id }, cancellationToken);
|
||||
}
|
||||
|
||||
private static long[] ParseIds(string[] values, string name)
|
||||
{
|
||||
// 1. 空数组直接返回
|
||||
if (values.Length == 0)
|
||||
{
|
||||
return Array.Empty<long>();
|
||||
}
|
||||
|
||||
// 2. (空行后) 解析并去重
|
||||
var ids = new List<long>(values.Length);
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!long.TryParse(value, out var id) || id <= 0)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.BadRequest, $"{name} ID 无效");
|
||||
}
|
||||
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// 3. (空行后) 返回去重结果
|
||||
return ids.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static bool IsConcurrencyException(Exception exception)
|
||||
=> string.Equals(exception.GetType().Name, "DbUpdateConcurrencyException", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Linq;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity;
|
||||
|
||||
internal static class IdentityUserAccess
|
||||
{
|
||||
private static readonly FrozenSet<string> SuperAdminRoleCodes = new[]
|
||||
{
|
||||
"super-admin",
|
||||
"SUPER_ADMIN",
|
||||
"PlatformAdmin",
|
||||
"platform-admin"
|
||||
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
internal static bool IsSuperAdmin(CurrentUserProfile profile)
|
||||
=> profile.Roles.Any(role => SuperAdminRoleCodes.Contains(role));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace TakeoutSaaS.Application.Identity.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户操作类型。
|
||||
/// </summary>
|
||||
public enum IdentityUserBatchOperation
|
||||
{
|
||||
/// <summary>
|
||||
/// 启用。
|
||||
/// </summary>
|
||||
Enable = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 禁用。
|
||||
/// </summary>
|
||||
Disable = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 删除。
|
||||
/// </summary>
|
||||
Delete = 3,
|
||||
|
||||
/// <summary>
|
||||
/// 恢复。
|
||||
/// </summary>
|
||||
Restore = 4,
|
||||
|
||||
/// <summary>
|
||||
/// 导出。
|
||||
/// </summary>
|
||||
Export = 5
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户详情。
|
||||
/// </summary>
|
||||
public sealed record GetIdentityUserDetailQuery : IRequest<UserDetailDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户 ID。
|
||||
/// </summary>
|
||||
public long UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含已删除用户。
|
||||
/// </summary>
|
||||
public bool IncludeDeleted { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using MediatR;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Shared.Abstractions.Results;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// 用户列表查询。
|
||||
/// </summary>
|
||||
public sealed record SearchIdentityUsersQuery : IRequest<PagedResult<UserListItemDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID(超级管理员可选)。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(账号/姓名/手机号/邮箱)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户状态。
|
||||
/// </summary>
|
||||
public IdentityUserStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色 ID。
|
||||
/// </summary>
|
||||
public long? RoleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? CreatedAtFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? CreatedAtTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近登录开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastLoginFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近登录结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastLoginTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含已删除用户。
|
||||
/// </summary>
|
||||
public bool IncludeDeleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段。
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否降序。
|
||||
/// </summary>
|
||||
public bool SortDescending { get; init; } = true;
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 批量用户操作命令验证器。
|
||||
/// </summary>
|
||||
public sealed class BatchIdentityUserOperationCommandValidator : AbstractValidator<BatchIdentityUserOperationCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public BatchIdentityUserOperationCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Operation).IsInEnum();
|
||||
RuleFor(x => x.UserIds).NotEmpty().Must(ids => ids.Length <= 100)
|
||||
.WithMessage("单次最多只能选择 100 个用户");
|
||||
RuleForEach(x => x.UserIds)
|
||||
.Must(value => long.TryParse(value, out _))
|
||||
.WithMessage("用户 ID 必须为有效的数字字符串");
|
||||
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 创建用户命令验证器。
|
||||
/// </summary>
|
||||
public sealed class CreateIdentityUserCommandValidator : AbstractValidator<CreateIdentityUserCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public CreateIdentityUserCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Account).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Password).NotEmpty().Length(6, 32);
|
||||
RuleFor(x => x.Avatar).MaximumLength(512);
|
||||
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
|
||||
RuleForEach(x => x.RoleIds)
|
||||
.Must(value => long.TryParse(value, out _))
|
||||
.WithMessage("角色 ID 必须为有效的数字字符串");
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Phone), () =>
|
||||
{
|
||||
RuleFor(x => x.Phone!)
|
||||
.Matches("^1[3-9]\\d{9}$")
|
||||
.WithMessage("手机号格式不正确");
|
||||
});
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Email), () =>
|
||||
{
|
||||
RuleFor(x => x.Email!)
|
||||
.EmailAddress()
|
||||
.WithMessage("邮箱格式不正确");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.Identity.Queries;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 用户列表查询验证器。
|
||||
/// </summary>
|
||||
public sealed class SearchIdentityUsersQueryValidator : AbstractValidator<SearchIdentityUsersQuery>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public SearchIdentityUsersQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.Page).GreaterThan(0);
|
||||
RuleFor(x => x.PageSize).InclusiveBetween(1, 200);
|
||||
RuleFor(x => x.Keyword).MaximumLength(128);
|
||||
RuleFor(x => x.SortBy).MaximumLength(64);
|
||||
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
|
||||
RuleFor(x => x.CreatedAtTo)
|
||||
.GreaterThanOrEqualTo(x => x.CreatedAtFrom)
|
||||
.When(x => x.CreatedAtFrom.HasValue && x.CreatedAtTo.HasValue);
|
||||
RuleFor(x => x.LastLoginTo)
|
||||
.GreaterThanOrEqualTo(x => x.LastLoginFrom)
|
||||
.When(x => x.LastLoginFrom.HasValue && x.LastLoginTo.HasValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using FluentValidation;
|
||||
using TakeoutSaaS.Application.Identity.Commands;
|
||||
|
||||
namespace TakeoutSaaS.Application.Identity.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// 更新用户命令验证器。
|
||||
/// </summary>
|
||||
public sealed class UpdateIdentityUserCommandValidator : AbstractValidator<UpdateIdentityUserCommand>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化验证规则。
|
||||
/// </summary>
|
||||
public UpdateIdentityUserCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.UserId).GreaterThan(0);
|
||||
RuleFor(x => x.DisplayName).NotEmpty().MaximumLength(64);
|
||||
RuleFor(x => x.Avatar).MaximumLength(512);
|
||||
RuleFor(x => x.RowVersion).NotEmpty();
|
||||
RuleFor(x => x.TenantId).GreaterThan(0).When(x => x.TenantId.HasValue);
|
||||
RuleForEach(x => x.RoleIds)
|
||||
.Must(value => long.TryParse(value, out _))
|
||||
.WithMessage("角色 ID 必须为有效的数字字符串")
|
||||
.When(x => x.RoleIds != null);
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Phone), () =>
|
||||
{
|
||||
RuleFor(x => x.Phone!)
|
||||
.Matches("^1[3-9]\\d{9}$")
|
||||
.WithMessage("手机号格式不正确");
|
||||
});
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Email), () =>
|
||||
{
|
||||
RuleFor(x => x.Email!)
|
||||
.EmailAddress()
|
||||
.WithMessage("邮箱格式不正确");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 手机号(租户内唯一)。
|
||||
/// </summary>
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 邮箱(租户内唯一)。
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账号状态。
|
||||
/// </summary>
|
||||
public IdentityUserStatus Status { get; set; } = IdentityUserStatus.Active;
|
||||
|
||||
/// <summary>
|
||||
/// 登录失败次数。
|
||||
/// </summary>
|
||||
public int FailedLoginCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 锁定截止时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LockedUntil { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近登录时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否强制修改密码。
|
||||
/// </summary>
|
||||
public bool MustChangePassword { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属商户(平台管理员为空)。
|
||||
/// </summary>
|
||||
@@ -31,4 +67,9 @@ public sealed class IdentityUser : MultiTenantEntityBase
|
||||
/// 头像地址。
|
||||
/// </summary>
|
||||
public string? Avatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 并发控制字段。
|
||||
/// </summary>
|
||||
public byte[] RowVersion { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace TakeoutSaaS.Domain.Identity.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 后台账号状态。
|
||||
/// </summary>
|
||||
public enum IdentityUserStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// 正常启用。
|
||||
/// </summary>
|
||||
Active = 1,
|
||||
|
||||
/// <summary>
|
||||
/// 已禁用。
|
||||
/// </summary>
|
||||
Disabled = 2,
|
||||
|
||||
/// <summary>
|
||||
/// 已锁定。
|
||||
/// </summary>
|
||||
Locked = 3
|
||||
}
|
||||
@@ -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
|
||||
/// <returns>存在返回 true。</returns>
|
||||
Task<bool> ExistsByAccountAsync(string account, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 判断账号是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
Task<bool> ExistsByAccountAsync(
|
||||
long tenantId,
|
||||
string account,
|
||||
long? excludeUserId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 判断手机号是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="phone">手机号。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
Task<bool> ExistsByPhoneAsync(
|
||||
long tenantId,
|
||||
string phone,
|
||||
long? excludeUserId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 判断邮箱是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="email">邮箱。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
Task<bool> ExistsByEmailAsync(
|
||||
long tenantId,
|
||||
string email,
|
||||
long? excludeUserId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户。
|
||||
/// </summary>
|
||||
@@ -31,6 +74,14 @@ public interface IIdentityUserRepository
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(忽略租户过滤器,仅用于只读查询)。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
Task<IdentityUser?> FindByIdIgnoringTenantAsync(long userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。
|
||||
/// </summary>
|
||||
@@ -48,6 +99,20 @@ public interface IIdentityUserRepository
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
Task<IdentityUser?> GetForUpdateIgnoringTenantAsync(long userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(用于更新,包含已删除数据)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
|
||||
long tenantId,
|
||||
long userId,
|
||||
bool ignoreTenantFilter = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 按租户与关键字查询后台用户列表(仅读)。
|
||||
/// </summary>
|
||||
@@ -57,6 +122,18 @@ public interface IIdentityUserRepository
|
||||
/// <returns>后台用户列表。</returns>
|
||||
Task<IReadOnlyList<IdentityUser>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询后台用户列表。
|
||||
/// </summary>
|
||||
/// <param name="filter">查询过滤条件。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤器。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果。</returns>
|
||||
Task<(IReadOnlyList<IdentityUser> Items, int Total)> SearchPagedAsync(
|
||||
IdentityUserSearchFilter filter,
|
||||
bool ignoreTenantFilter = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定租户、用户集合对应的用户(只读)。
|
||||
/// </summary>
|
||||
@@ -66,6 +143,22 @@ public interface IIdentityUserRepository
|
||||
/// <returns>后台用户列表。</returns>
|
||||
Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取后台用户(可用于更新,支持包含已删除数据)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="includeDeleted">是否包含已删除数据。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤器。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户列表。</returns>
|
||||
Task<IReadOnlyList<IdentityUser>> GetForUpdateByIdsAsync(
|
||||
long tenantId,
|
||||
IEnumerable<long> userIds,
|
||||
bool includeDeleted,
|
||||
bool ignoreTenantFilter = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 新增后台用户。
|
||||
/// </summary>
|
||||
@@ -74,6 +167,14 @@ public interface IIdentityUserRepository
|
||||
/// <returns>异步操作任务。</returns>
|
||||
Task AddAsync(IdentityUser user, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 删除后台用户(软删除)。
|
||||
/// </summary>
|
||||
/// <param name="user">后台用户实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步操作任务。</returns>
|
||||
Task RemoveAsync(IdentityUser user, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 持久化仓储变更。
|
||||
/// </summary>
|
||||
@@ -81,3 +182,74 @@ public interface IIdentityUserRepository
|
||||
/// <returns>异步操作任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 后台用户查询过滤条件。
|
||||
/// </summary>
|
||||
public sealed record IdentityUserSearchFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// 租户 ID。
|
||||
/// </summary>
|
||||
public long? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 关键字(账号/姓名/手机号/邮箱)。
|
||||
/// </summary>
|
||||
public string? Keyword { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户状态。
|
||||
/// </summary>
|
||||
public IdentityUserStatus? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 角色 ID。
|
||||
/// </summary>
|
||||
public long? RoleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? CreatedAtFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? CreatedAtTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近登录开始时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastLoginFrom { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 最近登录结束时间(UTC)。
|
||||
/// </summary>
|
||||
public DateTime? LastLoginTo { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否包含已删除数据。
|
||||
/// </summary>
|
||||
public bool IncludeDeleted { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 页码(从 1 开始)。
|
||||
/// </summary>
|
||||
public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// 每页条数。
|
||||
/// </summary>
|
||||
public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// 排序字段。
|
||||
/// </summary>
|
||||
public string? SortBy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否降序。
|
||||
/// </summary>
|
||||
public bool SortDescending { get; init; } = true;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ public interface IUserRoleRepository
|
||||
/// <returns>异步操作任务。</returns>
|
||||
Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 统计指定角色下的用户数量。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户数量。</returns>
|
||||
Task<int> CountUsersByRoleAsync(long tenantId, long roleId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 提交持久化变更。
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
|
||||
namespace TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 运营操作日志仓储。
|
||||
/// </summary>
|
||||
public interface IOperationLogRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 新增操作日志。
|
||||
/// </summary>
|
||||
/// <param name="log">操作日志。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task AddAsync(OperationLog log, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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<IQuotaPackageRepository, EfQuotaPackageRepository>();
|
||||
services.AddScoped<IStatisticsRepository, EfStatisticsRepository>();
|
||||
services.AddScoped<ISubscriptionRepository, EfSubscriptionRepository>();
|
||||
services.AddScoped<IOperationLogRepository, EfOperationLogRepository>();
|
||||
|
||||
// 1. 账单领域/导出服务
|
||||
services.AddScoped<IBillingDomainService, BillingDomainService>();
|
||||
|
||||
@@ -32,6 +32,90 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
return dbContext.IdentityUsers.AnyAsync(x => x.Account == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断账号是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public Task<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断手机号是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="phone">手机号。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public Task<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断邮箱是否存在(租户内,可排除指定用户)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="email">邮箱。</param>
|
||||
/// <param name="excludeUserId">排除的用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public Task<bool> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户。
|
||||
/// </summary>
|
||||
@@ -41,6 +125,19 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
public Task<IdentityUser?> FindByIdAsync(long userId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.IdentityUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(忽略租户过滤器,仅用于只读查询)。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> FindByIdIgnoringTenantAsync(long userId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.IdentityUsers
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null)
|
||||
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(用于更新,返回可跟踪实体)。
|
||||
/// </summary>
|
||||
@@ -63,6 +160,31 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
.Where(x => x.DeletedAt == null)
|
||||
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(用于更新,包含已删除数据)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按租户与关键字搜索后台用户(只读)。
|
||||
/// </summary>
|
||||
@@ -88,6 +210,144 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询后台用户列表。
|
||||
/// </summary>
|
||||
/// <param name="filter">查询过滤条件。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤器。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果。</returns>
|
||||
public async Task<(IReadOnlyList<IdentityUser> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 集合批量获取后台用户(只读)。
|
||||
/// </summary>
|
||||
@@ -101,6 +361,50 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<IdentityUser>)t.Result, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取后台用户(可用于更新,支持包含已删除数据)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="includeDeleted">是否包含已删除数据。</param>
|
||||
/// <param name="ignoreTenantFilter">是否忽略租户过滤器。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户列表。</returns>
|
||||
public Task<IReadOnlyList<IdentityUser>> GetForUpdateByIdsAsync(
|
||||
long tenantId,
|
||||
IEnumerable<long> userIds,
|
||||
bool includeDeleted,
|
||||
bool ignoreTenantFilter = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var ids = userIds.Distinct().ToArray();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<IdentityUser>>(Array.Empty<IdentityUser>());
|
||||
}
|
||||
|
||||
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<IdentityUser>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增后台用户。
|
||||
/// </summary>
|
||||
@@ -115,6 +419,20 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除后台用户(软删除)。
|
||||
/// </summary>
|
||||
/// <param name="user">后台用户实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task RemoveAsync(IdentityUser user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标记删除
|
||||
dbContext.IdentityUsers.Remove(user);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 持久化仓储变更。
|
||||
/// </summary>
|
||||
|
||||
@@ -17,7 +17,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限实体或 null。</returns>
|
||||
public Task<Permission?> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限编码获取权限。
|
||||
@@ -27,7 +30,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限实体或 null。</returns>
|
||||
public Task<Permission?> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限编码集合批量获取权限。
|
||||
@@ -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<Permission>)t.Result, cancellationToken);
|
||||
}
|
||||
@@ -60,8 +68,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限列表。</returns>
|
||||
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> 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<Permission>)t.Result, cancellationToken);
|
||||
|
||||
@@ -75,7 +85,10 @@ public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermi
|
||||
public Task<IReadOnlyList<Permission>> 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. 追加关键字过滤
|
||||
|
||||
@@ -17,8 +17,10 @@ public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IR
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色权限映射列表。</returns>
|
||||
public Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> 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<RolePermission>)t.Result, cancellationToken);
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色实体或 null。</returns>
|
||||
public Task<Role?> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 根据角色编码获取角色。
|
||||
@@ -27,7 +30,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色实体或 null。</returns>
|
||||
public Task<Role?> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 根据角色 ID 集合获取角色列表。
|
||||
@@ -37,7 +43,9 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色列表。</returns>
|
||||
public Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> 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<Role>)t.Result, cancellationToken);
|
||||
@@ -52,7 +60,10 @@ public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleReposit
|
||||
public Task<IReadOnlyList<Role>> 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. 追加关键字过滤
|
||||
|
||||
@@ -17,8 +17,10 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户角色映射列表。</returns>
|
||||
public Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> 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<UserRole>)t.Result, cancellationToken);
|
||||
|
||||
@@ -30,8 +32,10 @@ public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRol
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户角色列表。</returns>
|
||||
public Task<IReadOnlyList<UserRole>> 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<UserRole>)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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 统计指定角色下的用户数量。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户数量。</returns>
|
||||
public Task<int> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
|
||||
@@ -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<int>();
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using TakeoutSaaS.Domain.Tenants.Entities;
|
||||
using TakeoutSaaS.Domain.Tenants.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Logs.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 运营操作日志仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfOperationLogRepository(TakeoutLogsDbContext logsContext) : IOperationLogRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(OperationLog log, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加日志实体
|
||||
logsContext.OperationLogs.Add(log);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> logsContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("登录账号。");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasComment("头像地址。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("展示名称。");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("邮箱(租户内唯一)。");
|
||||
|
||||
b.Property<int>("FailedLoginCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("登录失败次数。");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近登录时间(UTC)。");
|
||||
|
||||
b.Property<DateTime?>("LockedUntil")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("锁定截止时间(UTC)。");
|
||||
|
||||
b.Property<long?>("MerchantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属商户(平台管理员为空)。");
|
||||
|
||||
b.Property<bool>("MustChangePassword")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否强制修改密码。");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("密码哈希。");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasComment("手机号(租户内唯一)。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("账号状态。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AuthListJson")
|
||||
.HasColumnType("text")
|
||||
.HasComment("按钮权限列表 JSON(存储 MenuAuthItemDto 数组)。");
|
||||
|
||||
b.Property<string>("Component")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("组件路径(不含 .vue)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("图标标识。");
|
||||
|
||||
b.Property<bool>("IsIframe")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否 iframe。");
|
||||
|
||||
b.Property<bool>("KeepAlive")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否缓存。");
|
||||
|
||||
b.Property<string>("Link")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)")
|
||||
.HasComment("外链或 iframe 地址。");
|
||||
|
||||
b.Property<string>("MetaPermissions")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("Meta.permissions(逗号分隔)。");
|
||||
|
||||
b.Property<string>("MetaRoles")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("Meta.roles(逗号分隔)。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("菜单名称(前端路由 name)。");
|
||||
|
||||
b.Property<long>("ParentId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("父级菜单 ID,根节点为 0。");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("路由路径。");
|
||||
|
||||
b.Property<string>("RequiredPermissions")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)")
|
||||
.HasComment("访问该菜单所需的权限集合(逗号分隔)。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("排序。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("标题。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasComment("头像地址。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Nickname")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("昵称。");
|
||||
|
||||
b.Property<string>("OpenId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("微信 OpenId。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<string>("UnionId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("微信 UnionId,可能为空。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("权限编码(租户内唯一)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("描述。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("权限名称。");
|
||||
|
||||
b.Property<long>("ParentId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("父级权限 ID,根节点为 0。");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("排序值,值越小越靠前。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)")
|
||||
.HasComment("权限类型(group/leaf)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("角色编码(租户内唯一)。");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("描述。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("角色名称。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<long>("PermissionId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("权限 ID。");
|
||||
|
||||
b.Property<long>("RoleId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("角色 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("模板描述。");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否启用。");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("模板名称。");
|
||||
|
||||
b.Property<string>("TemplateCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("模板编码(唯一)。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<string>("PermissionCode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("权限编码。");
|
||||
|
||||
b.Property<long>("RoleTemplateId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("模板 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("实体唯一标识。");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("创建时间(UTC)。");
|
||||
|
||||
b.Property<long?>("CreatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("创建人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("软删除时间(UTC),未删除时为 null。");
|
||||
|
||||
b.Property<long?>("DeletedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("删除人用户标识(软删除),未删除时为 null。");
|
||||
|
||||
b.Property<long>("RoleId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("角色 ID。");
|
||||
|
||||
b.Property<long>("TenantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属租户 ID。");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近一次更新时间(UTC),从未更新时为 null。");
|
||||
|
||||
b.Property<long?>("UpdatedBy")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("最后更新人用户标识,匿名或系统操作时为 null。");
|
||||
|
||||
b.Property<long>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddIdentityUserManagementFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Email",
|
||||
table: "identity_users",
|
||||
type: "character varying(128)",
|
||||
maxLength: 128,
|
||||
nullable: true,
|
||||
comment: "邮箱(租户内唯一)。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FailedLoginCount",
|
||||
table: "identity_users",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "登录失败次数。");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastLoginAt",
|
||||
table: "identity_users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true,
|
||||
comment: "最近登录时间(UTC)。");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LockedUntil",
|
||||
table: "identity_users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true,
|
||||
comment: "锁定截止时间(UTC)。");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "MustChangePassword",
|
||||
table: "identity_users",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false,
|
||||
comment: "是否强制修改密码。");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Phone",
|
||||
table: "identity_users",
|
||||
type: "character varying(32)",
|
||||
maxLength: 32,
|
||||
nullable: true,
|
||||
comment: "手机号(租户内唯一)。");
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "RowVersion",
|
||||
table: "identity_users",
|
||||
type: "bytea",
|
||||
rowVersion: true,
|
||||
nullable: false,
|
||||
defaultValue: new byte[0],
|
||||
comment: "并发控制字段。");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,16 +63,53 @@ namespace TakeoutSaaS.Infrastructure.Migrations.IdentityDb
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasComment("展示名称。");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)")
|
||||
.HasComment("邮箱(租户内唯一)。");
|
||||
|
||||
b.Property<int>("FailedLoginCount")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("登录失败次数。");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("最近登录时间(UTC)。");
|
||||
|
||||
b.Property<DateTime?>("LockedUntil")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasComment("锁定截止时间(UTC)。");
|
||||
|
||||
b.Property<long?>("MerchantId")
|
||||
.HasColumnType("bigint")
|
||||
.HasComment("所属商户(平台管理员为空)。");
|
||||
|
||||
b.Property<bool>("MustChangePassword")
|
||||
.HasColumnType("boolean")
|
||||
.HasComment("是否强制修改密码。");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)")
|
||||
.HasComment("密码哈希。");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasComment("手机号(租户内唯一)。");
|
||||
|
||||
b.Property<byte[]>("RowVersion")
|
||||
.IsConcurrencyToken()
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("bytea")
|
||||
.HasComment("并发控制字段。");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasComment("账号状态。");
|
||||
|
||||
b.Property<long>("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("管理后台账户实体(平台管理员、租户管理员或商户员工)。");
|
||||
|
||||
Reference in New Issue
Block a user