feat: 重构 RBAC1 角色权限模型

This commit is contained in:
2025-12-02 16:21:46 +08:00
parent 3d69151426
commit b459c7edbe
21 changed files with 780 additions and 49 deletions

View File

@@ -1,3 +1,5 @@
using System;
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
@@ -11,10 +13,18 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// </summary>
public sealed class GetUserPermissionsQueryHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IRolePermissionRepository rolePermissionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<GetUserPermissionsQuery, UserPermissionDto?>
{
private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository;
private readonly IUserRoleRepository _userRoleRepository = userRoleRepository;
private readonly IRoleRepository _roleRepository = roleRepository;
private readonly IPermissionRepository _permissionRepository = permissionRepository;
private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
@@ -27,6 +37,9 @@ public sealed class GetUserPermissionsQueryHandler(
return null;
}
var roleCodes = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken);
var permissionCodes = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken);
return new UserPermissionDto
{
UserId = user.Id,
@@ -34,9 +47,42 @@ public sealed class GetUserPermissionsQueryHandler(
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = user.Roles,
Permissions = user.Permissions,
Roles = roleCodes,
Permissions = permissionCodes,
CreatedAt = user.CreatedAt
};
}
private async Task<string[]> ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken)
{
var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
if (roleIds.Length == 0)
{
return Array.Empty<string>();
}
var roles = await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private async Task<string[]> ResolveUserPermissionsAsync(long tenantId, long userId, CancellationToken cancellationToken)
{
var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
if (roleIds.Length == 0)
{
return Array.Empty<string>();
}
var rolePermissions = await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken);
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
if (permissionIds.Length == 0)
{
return Array.Empty<string>();
}
var permissions = await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
}

View File

@@ -1,3 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
@@ -13,10 +15,18 @@ namespace TakeoutSaaS.Application.Identity.Handlers;
/// </summary>
public sealed class SearchUserPermissionsQueryHandler(
IIdentityUserRepository identityUserRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IRolePermissionRepository rolePermissionRepository,
ITenantProvider tenantProvider)
: IRequestHandler<SearchUserPermissionsQuery, PagedResult<UserPermissionDto>>
{
private readonly IIdentityUserRepository _identityUserRepository = identityUserRepository;
private readonly IUserRoleRepository _userRoleRepository = userRoleRepository;
private readonly IRoleRepository _roleRepository = roleRepository;
private readonly IPermissionRepository _permissionRepository = permissionRepository;
private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository;
private readonly ITenantProvider _tenantProvider = tenantProvider;
/// <inheritdoc />
@@ -31,6 +41,7 @@ public sealed class SearchUserPermissionsQueryHandler(
.Take(request.PageSize)
.ToList();
var resolved = await ResolveRolesAndPermissionsAsync(tenantId, paged, cancellationToken);
var items = paged.Select(user => new UserPermissionDto
{
UserId = user.Id,
@@ -38,8 +49,8 @@ public sealed class SearchUserPermissionsQueryHandler(
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = user.Roles,
Permissions = user.Permissions,
Roles = resolved[user.Id].roles,
Permissions = resolved[user.Id].permissions,
CreatedAt = user.CreatedAt
}).ToList();
@@ -64,4 +75,57 @@ public sealed class SearchUserPermissionsQueryHandler(
: users.OrderBy(x => x.CreatedAt)
};
}
private async Task<Dictionary<long, (string[] roles, string[] permissions)>> ResolveRolesAndPermissionsAsync(
long tenantId,
IReadOnlyCollection<Domain.Identity.Entities.IdentityUser> users,
CancellationToken cancellationToken)
{
var userIds = users.Select(x => x.Id).ToArray();
var userRoleRelations = await _userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray();
var roles = roleIds.Length == 0
? Array.Empty<Domain.Identity.Entities.Role>()
: await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer<long>.Default);
var rolePermissions = roleIds.Length == 0
? Array.Empty<Domain.Identity.Entities.RolePermission>()
: await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken);
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
var permissions = permissionIds.Length == 0
? Array.Empty<Domain.Identity.Entities.Permission>()
: await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer<long>.Default);
var rolePermissionsLookup = rolePermissions
.GroupBy(rp => rp.RoleId)
.ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer<long>.Default);
var result = new Dictionary<long, (string[] roles, string[] permissions)>();
foreach (var userId in userIds)
{
var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray();
var roleCodes = rolesForUser
.Select(rid => roleCodeMap.GetValueOrDefault(rid))
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var permissionCodes = rolesForUser
.SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty<long>())
.Select(pid => permissionCodeMap.GetValueOrDefault(pid))
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
result[userId] = (roleCodes, permissionCodes);
}
return result;
}
}

View File

@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Identity;
using TakeoutSaaS.Application.Identity.Abstractions;
using TakeoutSaaS.Application.Identity.Contracts;
@@ -15,12 +18,20 @@ namespace TakeoutSaaS.Application.Identity.Services;
/// </summary>
public sealed class AdminAuthService(
IIdentityUserRepository userRepository,
IUserRoleRepository userRoleRepository,
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IRolePermissionRepository rolePermissionRepository,
IPasswordHasher<IdentityUser> passwordHasher,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
ITenantProvider tenantProvider) : IAdminAuthService
{
private readonly ITenantProvider _tenantProvider = tenantProvider;
private readonly IUserRoleRepository _userRoleRepository = userRoleRepository;
private readonly IRoleRepository _roleRepository = roleRepository;
private readonly IPermissionRepository _permissionRepository = permissionRepository;
private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository;
/// <summary>
/// 管理后台登录:验证账号密码并生成令牌。
@@ -43,7 +54,7 @@ public sealed class AdminAuthService(
}
// 3. 构建用户档案并生成令牌
var profile = BuildProfile(user);
var profile = await BuildProfileAsync(user, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
@@ -71,7 +82,7 @@ public sealed class AdminAuthService(
await refreshTokenStore.RevokeAsync(descriptor.Token, cancellationToken);
// 4. 生成新的令牌对
var profile = BuildProfile(user);
var profile = await BuildProfileAsync(user, cancellationToken);
return await jwtTokenService.CreateTokensAsync(profile, false, cancellationToken);
}
@@ -87,7 +98,7 @@ public sealed class AdminAuthService(
var user = await userRepository.FindByIdAsync(userId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "用户不存在");
return BuildProfile(user);
return await BuildProfileAsync(user, cancellationToken);
}
/// <summary>
@@ -102,6 +113,9 @@ public sealed class AdminAuthService(
return null;
}
var roleCodes = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken);
var permissionCodes = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken);
return new UserPermissionDto
{
UserId = user.Id,
@@ -109,8 +123,8 @@ public sealed class AdminAuthService(
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = user.Roles,
Permissions = user.Permissions,
Roles = roleCodes,
Permissions = permissionCodes,
CreatedAt = user.CreatedAt
};
}
@@ -147,6 +161,7 @@ public sealed class AdminAuthService(
.Take(pageSize)
.ToList();
var resolved = await ResolveRolesAndPermissionsAsync(tenantId, paged, cancellationToken);
var items = paged.Select(user => new UserPermissionDto
{
UserId = user.Id,
@@ -154,24 +169,116 @@ public sealed class AdminAuthService(
MerchantId = user.MerchantId,
Account = user.Account,
DisplayName = user.DisplayName,
Roles = user.Roles,
Permissions = user.Permissions,
Roles = resolved[user.Id].roles,
Permissions = resolved[user.Id].permissions,
CreatedAt = user.CreatedAt
}).ToList();
return new PagedResult<UserPermissionDto>(items, page, pageSize, users.Count);
}
private static CurrentUserProfile BuildProfile(IdentityUser user)
=> new()
private async Task<CurrentUserProfile> BuildProfileAsync(IdentityUser user, CancellationToken cancellationToken)
{
var tenantId = user.TenantId;
var roles = await ResolveUserRolesAsync(tenantId, user.Id, cancellationToken);
var permissions = await ResolveUserPermissionsAsync(tenantId, user.Id, cancellationToken);
return new CurrentUserProfile
{
UserId = user.Id,
Account = user.Account,
DisplayName = user.DisplayName,
TenantId = user.TenantId,
MerchantId = user.MerchantId,
Roles = user.Roles,
Permissions = user.Permissions,
Roles = roles,
Permissions = permissions,
Avatar = user.Avatar
};
}
private async Task<string[]> ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken)
{
var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
if (roleIds.Length == 0)
{
return Array.Empty<string>();
}
var roles = await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
return roles.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private async Task<string[]> ResolveUserPermissionsAsync(long tenantId, long userId, CancellationToken cancellationToken)
{
var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);
var roleIds = relations.Select(x => x.RoleId).Distinct().ToArray();
if (roleIds.Length == 0)
{
return Array.Empty<string>();
}
var rolePermissions = await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken);
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
if (permissionIds.Length == 0)
{
return Array.Empty<string>();
}
var permissions = await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
return permissions.Select(x => x.Code).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private async Task<Dictionary<long, (string[] roles, string[] permissions)>> ResolveRolesAndPermissionsAsync(
long tenantId,
IReadOnlyCollection<IdentityUser> users,
CancellationToken cancellationToken)
{
var userIds = users.Select(x => x.Id).ToArray();
var userRoleRelations = await _userRoleRepository.GetByUserIdsAsync(tenantId, userIds, cancellationToken);
var roleIds = userRoleRelations.Select(x => x.RoleId).Distinct().ToArray();
var roles = roleIds.Length == 0
? Array.Empty<Role>()
: await _roleRepository.GetByIdsAsync(tenantId, roleIds, cancellationToken);
var roleCodeMap = roles.ToDictionary(r => r.Id, r => r.Code, comparer: EqualityComparer<long>.Default);
var rolePermissions = roleIds.Length == 0
? Array.Empty<RolePermission>()
: await _rolePermissionRepository.GetByRoleIdsAsync(tenantId, roleIds, cancellationToken);
var permissionIds = rolePermissions.Select(x => x.PermissionId).Distinct().ToArray();
var permissions = permissionIds.Length == 0
? Array.Empty<Permission>()
: await _permissionRepository.GetByIdsAsync(tenantId, permissionIds, cancellationToken);
var permissionCodeMap = permissions.ToDictionary(p => p.Id, p => p.Code, comparer: EqualityComparer<long>.Default);
var rolePermissionsLookup = rolePermissions
.GroupBy(rp => rp.RoleId)
.ToDictionary(g => g.Key, g => g.Select(rp => rp.PermissionId).ToArray(), comparer: EqualityComparer<long>.Default);
var result = new Dictionary<long, (string[] roles, string[] permissions)>();
foreach (var userId in userIds)
{
var rolesForUser = userRoleRelations.Where(ur => ur.UserId == userId).Select(ur => ur.RoleId).Distinct().ToArray();
var roleCodes = rolesForUser
.Select(rid => roleCodeMap.GetValueOrDefault(rid))
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var permissionCodes = rolesForUser
.SelectMany(rid => rolePermissionsLookup.GetValueOrDefault(rid) ?? Array.Empty<long>())
.Select(pid => permissionCodeMap.GetValueOrDefault(pid))
.Where(code => !string.IsNullOrWhiteSpace(code))
.Select(code => code!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
result[userId] = (roleCodes, permissionCodes);
}
return result;
}
}

View File

@@ -27,16 +27,6 @@ public sealed class IdentityUser : MultiTenantEntityBase
/// </summary>
public long? MerchantId { get; set; }
/// <summary>
/// 角色集合。
/// </summary>
public string[] Roles { get; set; } = Array.Empty<string>();
/// <summary>
/// 权限集合。
/// </summary>
public string[] Permissions { get; set; } = Array.Empty<string>();
/// <summary>
/// 头像地址。
/// </summary>

View File

@@ -0,0 +1,24 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Identity.Entities;
/// <summary>
/// 权限定义。
/// </summary>
public sealed class Permission : MultiTenantEntityBase
{
/// <summary>
/// 权限名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 权限编码(租户内唯一)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; set; }
}

View File

@@ -0,0 +1,24 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Identity.Entities;
/// <summary>
/// 角色定义。
/// </summary>
public sealed class Role : MultiTenantEntityBase
{
/// <summary>
/// 角色名称。
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// 角色编码(租户内唯一)。
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// 描述。
/// </summary>
public string? Description { get; set; }
}

View File

@@ -0,0 +1,19 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Identity.Entities;
/// <summary>
/// 角色-权限关系。
/// </summary>
public sealed class RolePermission : MultiTenantEntityBase
{
/// <summary>
/// 角色 ID。
/// </summary>
public long RoleId { get; set; }
/// <summary>
/// 权限 ID。
/// </summary>
public long PermissionId { get; set; }
}

View File

@@ -0,0 +1,19 @@
using TakeoutSaaS.Shared.Abstractions.Entities;
namespace TakeoutSaaS.Domain.Identity.Entities;
/// <summary>
/// 用户-角色关系。
/// </summary>
public sealed class UserRole : MultiTenantEntityBase
{
/// <summary>
/// 用户 ID。
/// </summary>
public long UserId { get; set; }
/// <summary>
/// 角色 ID。
/// </summary>
public long RoleId { get; set; }
}

View File

@@ -28,4 +28,9 @@ public interface IIdentityUserRepository
/// <param name="keyword">可选关键字(账号/名称)。</param>
/// <param name="cancellationToken">取消标记。</param>
Task<IReadOnlyList<IdentityUser>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default);
/// <summary>
/// 获取指定租户、用户集合对应的用户(只读)。
/// </summary>
Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Identity.Entities;
namespace TakeoutSaaS.Domain.Identity.Repositories;
/// <summary>
/// 权限仓储。
/// </summary>
public interface IPermissionRepository
{
Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default);
Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default);
Task AddAsync(Permission permission, CancellationToken cancellationToken = default);
Task UpdateAsync(Permission permission, CancellationToken cancellationToken = default);
Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Identity.Entities;
namespace TakeoutSaaS.Domain.Identity.Repositories;
/// <summary>
/// 角色-权限关系仓储。
/// </summary>
public interface IRolePermissionRepository
{
Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default);
Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Identity.Entities;
namespace TakeoutSaaS.Domain.Identity.Repositories;
/// <summary>
/// 角色仓储。
/// </summary>
public interface IRoleRepository
{
Task<Role?> FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default);
Task<Role?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default);
Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default);
Task AddAsync(Role role, CancellationToken cancellationToken = default);
Task UpdateAsync(Role role, CancellationToken cancellationToken = default);
Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using TakeoutSaaS.Domain.Identity.Entities;
namespace TakeoutSaaS.Domain.Identity.Repositories;
/// <summary>
/// 用户-角色关系仓储。
/// </summary>
public interface IUserRoleRepository
{
Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default);
Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default);
Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default);
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

View File

@@ -51,6 +51,10 @@ public static class ServiceCollectionExtensions
services.AddScoped<IIdentityUserRepository, EfIdentityUserRepository>();
services.AddScoped<IMiniUserRepository, EfMiniUserRepository>();
services.AddScoped<IRoleRepository, EfRoleRepository>();
services.AddScoped<IPermissionRepository, EfPermissionRepository>();
services.AddScoped<IUserRoleRepository, EfUserRoleRepository>();
services.AddScoped<IRolePermissionRepository, EfRolePermissionRepository>();
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<IRefreshTokenStore, RedisRefreshTokenStore>();
services.AddScoped<ILoginRateLimiter, RedisLoginRateLimiter>();

View File

@@ -34,4 +34,10 @@ public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIde
return await query.ToListAsync(cancellationToken);
}
public Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
=> dbContext.IdentityUsers.AsNoTracking()
.Where(x => x.TenantId == tenantId && userIds.Contains(x.Id))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<IdentityUser>)t.Result, cancellationToken);
}

View File

@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// EF 权限仓储。
/// </summary>
public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermissionRepository
{
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken);
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Permissions.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId, cancellationToken);
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))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
public Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
{
var query = dbContext.Permissions.AsNoTracking().Where(x => x.TenantId == tenantId);
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => x.Name.Contains(normalized) || x.Code.Contains(normalized));
}
return query.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
}
public Task AddAsync(Permission permission, CancellationToken cancellationToken = default)
{
dbContext.Permissions.Add(permission);
return Task.CompletedTask;
}
public Task UpdateAsync(Permission permission, CancellationToken cancellationToken = default)
{
dbContext.Permissions.Update(permission);
return Task.CompletedTask;
}
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
{
var entity = await dbContext.Permissions.FirstOrDefaultAsync(x => x.Id == permissionId && x.TenantId == tenantId, cancellationToken);
if (entity != null)
{
dbContext.Permissions.Remove(entity);
}
}
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> dbContext.SaveChangesAsync(cancellationToken);
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// EF 角色-权限仓储。
/// </summary>
public sealed class EfRolePermissionRepository(IdentityDbContext dbContext) : IRolePermissionRepository
{
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))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<RolePermission>)t.Result, cancellationToken);
public async Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
{
var existing = await dbContext.RolePermissions
.Where(x => x.TenantId == tenantId && x.RoleId == roleId)
.ToListAsync(cancellationToken);
dbContext.RolePermissions.RemoveRange(existing);
var toAdd = permissionIds.Distinct().Select(permissionId => new RolePermission
{
TenantId = tenantId,
RoleId = roleId,
PermissionId = permissionId
});
await dbContext.RolePermissions.AddRangeAsync(toAdd, cancellationToken);
}
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> dbContext.SaveChangesAsync(cancellationToken);
}

View File

@@ -0,0 +1,60 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// EF 角色仓储。
/// </summary>
public sealed class EfRoleRepository(IdentityDbContext dbContext) : IRoleRepository
{
public Task<Role?> FindByIdAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Roles.AsNoTracking().FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId, cancellationToken);
public Task<Role?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
=> dbContext.Roles.AsNoTracking().FirstOrDefaultAsync(x => x.Code == code && x.TenantId == tenantId, cancellationToken);
public Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
=> dbContext.Roles.AsNoTracking()
.Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
public Task<IReadOnlyList<Role>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
{
var query = dbContext.Roles.AsNoTracking().Where(x => x.TenantId == tenantId);
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(x => x.Name.Contains(normalized) || x.Code.Contains(normalized));
}
return query.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<Role>)t.Result, cancellationToken);
}
public Task AddAsync(Role role, CancellationToken cancellationToken = default)
{
dbContext.Roles.Add(role);
return Task.CompletedTask;
}
public Task UpdateAsync(Role role, CancellationToken cancellationToken = default)
{
dbContext.Roles.Update(role);
return Task.CompletedTask;
}
public async Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
{
var entity = await dbContext.Roles.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId, cancellationToken);
if (entity != null)
{
dbContext.Roles.Remove(entity);
}
}
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> dbContext.SaveChangesAsync(cancellationToken);
}

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
/// <summary>
/// EF 用户-角色仓储。
/// </summary>
public sealed class EfUserRoleRepository(IdentityDbContext dbContext) : IUserRoleRepository
{
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))
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
public Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
=> dbContext.UserRoles.AsNoTracking()
.Where(x => x.TenantId == tenantId && x.UserId == userId)
.ToListAsync(cancellationToken)
.ContinueWith(t => (IReadOnlyList<UserRole>)t.Result, cancellationToken);
public async Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
{
var existing = await dbContext.UserRoles
.Where(x => x.TenantId == tenantId && x.UserId == userId)
.ToListAsync(cancellationToken);
dbContext.UserRoles.RemoveRange(existing);
var toAdd = roleIds.Distinct().Select(roleId => new UserRole
{
TenantId = tenantId,
UserId = userId,
RoleId = roleId
});
await dbContext.UserRoles.AddRangeAsync(toAdd, cancellationToken);
}
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
=> dbContext.SaveChangesAsync(cancellationToken);
}

View File

@@ -1,3 +1,5 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -7,6 +9,10 @@ using Microsoft.Extensions.Options;
using TakeoutSaaS.Infrastructure.Identity.Options;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
using DomainPermission = TakeoutSaaS.Domain.Identity.Entities.Permission;
using DomainRole = TakeoutSaaS.Domain.Identity.Entities.Role;
using DomainRolePermission = TakeoutSaaS.Domain.Identity.Entities.RolePermission;
using DomainUserRole = TakeoutSaaS.Domain.Identity.Entities.UserRole;
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
@@ -47,9 +53,7 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
DisplayName = userOptions.DisplayName,
TenantId = userOptions.TenantId,
MerchantId = userOptions.MerchantId,
Avatar = null,
Roles = roles,
Permissions = permissions,
Avatar = null
};
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
context.IdentityUsers.Add(user);
@@ -60,11 +64,94 @@ public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger
user.DisplayName = userOptions.DisplayName;
user.TenantId = userOptions.TenantId;
user.MerchantId = userOptions.MerchantId;
user.Roles = roles;
user.Permissions = permissions;
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
logger.LogInformation("已更新后台账号 {Account}", user.Account);
}
// 确保角色存在
var existingRoles = await context.Roles
.Where(r => r.TenantId == userOptions.TenantId && roles.Contains(r.Code))
.ToListAsync(cancellationToken);
var existingRoleCodes = existingRoles.Select(r => r.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var code in roles)
{
if (existingRoleCodes.Contains(code))
{
continue;
}
context.Roles.Add(new DomainRole
{
TenantId = userOptions.TenantId,
Code = code,
Name = code,
Description = $"Seed role {code}"
});
}
// 确保权限存在
var existingPermissions = await context.Permissions
.Where(p => p.TenantId == userOptions.TenantId && permissions.Contains(p.Code))
.ToListAsync(cancellationToken);
var existingPermissionCodes = existingPermissions.Select(p => p.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var code in permissions)
{
if (existingPermissionCodes.Contains(code))
{
continue;
}
context.Permissions.Add(new DomainPermission
{
TenantId = userOptions.TenantId,
Code = code,
Name = code,
Description = $"Seed permission {code}"
});
}
await context.SaveChangesAsync(cancellationToken);
// 重新加载角色/权限以获取 Id
var roleEntities = await context.Roles
.Where(r => r.TenantId == userOptions.TenantId && roles.Contains(r.Code))
.ToListAsync(cancellationToken);
var permissionEntities = await context.Permissions
.Where(p => p.TenantId == userOptions.TenantId && permissions.Contains(p.Code))
.ToListAsync(cancellationToken);
// 重置用户角色
var existingUserRoles = await context.UserRoles
.Where(ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id)
.ToListAsync(cancellationToken);
context.UserRoles.RemoveRange(existingUserRoles);
var roleIds = roleEntities.Select(r => r.Id).Distinct().ToArray();
var userRoles = roleIds.Select(roleId => new DomainUserRole
{
TenantId = userOptions.TenantId,
UserId = user.Id,
RoleId = roleId
});
await context.UserRoles.AddRangeAsync(userRoles, cancellationToken);
// 为种子角色绑定种子权限
if (permissions.Length > 0 && roleIds.Length > 0)
{
var permissionIds = permissionEntities.Select(p => p.Id).Distinct().ToArray();
var existingRolePermissions = await context.RolePermissions
.Where(rp => rp.TenantId == userOptions.TenantId && roleIds.Contains(rp.RoleId))
.ToListAsync(cancellationToken);
context.RolePermissions.RemoveRange(existingRolePermissions);
var newRolePermissions = roleIds.SelectMany(roleId => permissionIds.Select(permissionId => new DomainRolePermission
{
TenantId = userOptions.TenantId,
RoleId = roleId,
PermissionId = permissionId
}));
await context.RolePermissions.AddRangeAsync(newRolePermissions, cancellationToken);
}
}
await context.SaveChangesAsync(cancellationToken);

View File

@@ -1,8 +1,6 @@
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Infrastructure.Common.Persistence;
using TakeoutSaaS.Shared.Abstractions.Ids;
@@ -31,6 +29,26 @@ public sealed class IdentityDbContext(
/// </summary>
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
/// <summary>
/// 角色集合。
/// </summary>
public DbSet<Role> Roles => Set<Role>();
/// <summary>
/// 权限集合。
/// </summary>
public DbSet<Permission> Permissions => Set<Permission>();
/// <summary>
/// 用户-角色关系。
/// </summary>
public DbSet<UserRole> UserRoles => Set<UserRole>();
/// <summary>
/// 角色-权限关系。
/// </summary>
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
/// <summary>
/// 配置实体模型。
/// </summary>
@@ -40,6 +58,10 @@ public sealed class IdentityDbContext(
base.OnModelCreating(modelBuilder);
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
ConfigureRole(modelBuilder.Entity<Role>());
ConfigurePermission(modelBuilder.Entity<Permission>());
ConfigureUserRole(modelBuilder.Entity<UserRole>());
ConfigureRolePermission(modelBuilder.Entity<RolePermission>());
ApplyTenantQueryFilters(modelBuilder);
}
@@ -59,23 +81,6 @@ public sealed class IdentityDbContext(
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
var converter = new ValueConverter<string[], string>(
v => string.Join(',', v),
v => string.IsNullOrWhiteSpace(v) ? Array.Empty<string>() : v.Split(',', StringSplitOptions.RemoveEmptyEntries));
var comparer = new ValueComparer<string[]>(
(l, r) => (l == null && r == null) || (l != null && r != null && Enumerable.SequenceEqual(l, r)),
v => v.Aggregate(0, (current, item) => HashCode.Combine(current, item.GetHashCode())),
v => v.ToArray());
builder.Property(x => x.Roles)
.HasConversion(converter)
.Metadata.SetValueComparer(comparer);
builder.Property(x => x.Permissions)
.HasConversion(converter)
.Metadata.SetValueComparer(comparer);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Account }).IsUnique();
}
@@ -99,4 +104,58 @@ public sealed class IdentityDbContext(
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.OpenId }).IsUnique();
}
private static void ConfigureRole(EntityTypeBuilder<Role> builder)
{
builder.ToTable("roles");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Code).HasMaxLength(64).IsRequired();
builder.Property(x => x.Description).HasMaxLength(256);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
}
private static void ConfigurePermission(EntityTypeBuilder<Permission> builder)
{
builder.ToTable("permissions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
builder.Property(x => x.Code).HasMaxLength(128).IsRequired();
builder.Property(x => x.Description).HasMaxLength(256);
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.Code }).IsUnique();
}
private static void ConfigureUserRole(EntityTypeBuilder<UserRole> builder)
{
builder.ToTable("user_roles");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.UserId).IsRequired();
builder.Property(x => x.RoleId).IsRequired();
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.UserId, x.RoleId }).IsUnique();
}
private static void ConfigureRolePermission(EntityTypeBuilder<RolePermission> builder)
{
builder.ToTable("role_permissions");
builder.HasKey(x => x.Id);
builder.Property(x => x.TenantId).IsRequired();
builder.Property(x => x.RoleId).IsRequired();
builder.Property(x => x.PermissionId).IsRequired();
ConfigureAuditableEntity(builder);
ConfigureSoftDeleteEntity(builder);
builder.HasIndex(x => x.TenantId);
builder.HasIndex(x => new { x.TenantId, x.RoleId, x.PermissionId }).IsUnique();
}
}