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

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

View File

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

View File

@@ -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. 追加关键字过滤

View File

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

View File

@@ -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. 追加关键字过滤

View File

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

View File

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