@@ -0,0 +1,119 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Common.Extensions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
using TakeoutSaaS.Infrastructure.Logs.Publishers;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using DomainIdentityUser = TakeoutSaaS.Domain.Identity.Entities.IdentityUser;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证基础设施注入。
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册身份认证基础设施(数据库、Redis、JWT、限流等)。
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合。</param>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="enableMiniFeatures">是否启用小程序相关依赖(如微信登录)。</param>
|
||||
/// <param name="enableAdminSeed">是否启用后台账号初始化。</param>
|
||||
/// <returns>服务集合。</returns>
|
||||
/// <exception cref="InvalidOperationException">配置缺失时抛出。</exception>
|
||||
public static IServiceCollection AddIdentityInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
bool enableMiniFeatures = false,
|
||||
bool enableAdminSeed = false)
|
||||
{
|
||||
services.AddDatabaseInfrastructure(configuration);
|
||||
services.AddPostgresDbContext<IdentityDbContext>(DatabaseConstants.IdentityDataSource);
|
||||
|
||||
var redisConnection = configuration.GetConnectionString("Redis");
|
||||
if (string.IsNullOrWhiteSpace(redisConnection))
|
||||
{
|
||||
throw new InvalidOperationException("缺少 Redis 连接字符串配置。");
|
||||
}
|
||||
|
||||
services.AddStackExchangeRedisCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnection;
|
||||
});
|
||||
|
||||
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<IRoleTemplateRepository, EfRoleTemplateRepository>();
|
||||
services.AddScoped<IMenuRepository, EfMenuRepository>();
|
||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||
services.AddScoped<IRefreshTokenStore, RedisRefreshTokenStore>();
|
||||
services.AddScoped<IAdminPasswordResetTokenStore, RedisAdminPasswordResetTokenStore>();
|
||||
services.AddScoped<ILoginRateLimiter, RedisLoginRateLimiter>();
|
||||
services.AddScoped<IPasswordHasher<DomainIdentityUser>, PasswordHasher<DomainIdentityUser>>();
|
||||
services.AddScoped<IIdentityOperationLogPublisher, IdentityOperationLogPublisher>();
|
||||
|
||||
services.AddOptions<JwtOptions>()
|
||||
.Bind(configuration.GetSection("Identity:Jwt"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<LoginRateLimitOptions>()
|
||||
.Bind(configuration.GetSection("Identity:LoginRateLimit"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddOptions<RefreshTokenStoreOptions>()
|
||||
.Bind(configuration.GetSection("Identity:RefreshTokenStore"));
|
||||
|
||||
services.AddOptions<AdminPasswordResetOptions>()
|
||||
.Bind(configuration.GetSection("Identity:AdminPasswordReset"));
|
||||
|
||||
if (enableMiniFeatures)
|
||||
{
|
||||
services.AddOptions<WeChatMiniOptions>()
|
||||
.Bind(configuration.GetSection("Identity:WeChatMini"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHttpClient<IWeChatAuthService, WeChatAuthService>(client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://api.weixin.qq.com/");
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
});
|
||||
}
|
||||
|
||||
if (enableAdminSeed)
|
||||
{
|
||||
services.AddOptions<AdminSeedOptions>()
|
||||
.Bind(configuration.GetSection("Identity:AdminSeed"))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddHostedService<IdentityDataSeeder>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保数据库连接已配置(Database 节或 ConnectionStrings)。
|
||||
/// </summary>
|
||||
/// <param name="configuration">配置源。</param>
|
||||
/// <param name="dataSourceName">数据源名称。</param>
|
||||
/// <exception cref="InvalidOperationException">未配置时抛出。</exception>
|
||||
private static void EnsureDatabaseConnectionConfigured(IConfiguration configuration, string dataSourceName)
|
||||
{
|
||||
// 保留兼容接口,当前逻辑在 DatabaseConnectionFactory 中兜底并记录日志。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台重置密码链接令牌配置。
|
||||
/// </summary>
|
||||
public sealed class AdminPasswordResetOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Redis Key 前缀。
|
||||
/// </summary>
|
||||
public string Prefix { get; init; } = "identity:admin:pwdreset:";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 管理后台初始账号配置。
|
||||
/// </summary>
|
||||
public sealed class AdminSeedOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 是否启用后台账号与权限种子。
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 初始用户列表。
|
||||
/// </summary>
|
||||
public List<SeedUserOptions> Users { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板种子列表。
|
||||
/// </summary>
|
||||
public List<RoleTemplateSeedOptions> RoleTemplates { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 种子用户配置:用于初始化管理后台账号。
|
||||
/// </summary>
|
||||
public sealed class SeedUserOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 登录账号。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 登录密码(明文,将在初始化时进行哈希处理)。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 展示名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 所属租户 ID。
|
||||
/// </summary>
|
||||
public long TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 所属商户 ID(租户管理员为空)。
|
||||
/// </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>
|
||||
public sealed class RoleTemplateSeedOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 模板编码。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string TemplateCode { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板名称。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 模板描述。
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用。
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 权限编码集合。
|
||||
/// </summary>
|
||||
public string[] Permissions { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 登录限流配置选项。
|
||||
/// </summary>
|
||||
public sealed class LoginRateLimitOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 时间窗口(秒),范围:1-3600。
|
||||
/// </summary>
|
||||
[Range(1, 3600)]
|
||||
public int WindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// 时间窗口内允许的最大尝试次数,范围:1-100。
|
||||
/// </summary>
|
||||
[Range(1, 100)]
|
||||
public int MaxAttempts { get; set; } = 5;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 刷新令牌存储配置选项。
|
||||
/// </summary>
|
||||
public sealed class RefreshTokenStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Redis 键前缀,用于存储刷新令牌。
|
||||
/// </summary>
|
||||
public string Prefix { get; set; } = "identity:refresh:";
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序配置选项。
|
||||
/// </summary>
|
||||
public sealed class WeChatMiniOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// 微信小程序 AppId。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string AppId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 微信小程序 AppSecret。
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 后台用户仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfIdentityUserRepository(IdentityDbContext dbContext) : IIdentityUserRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据租户与账号获取后台用户。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public Task<IdentityUser?> FindByAccountAsync(long tenantId, string account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化账号
|
||||
var normalized = account.Trim();
|
||||
|
||||
// 2. 查询用户(强制租户隔离)
|
||||
return dbContext.IdentityUsers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.TenantId == tenantId && x.Account == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断账号是否存在。
|
||||
/// </summary>
|
||||
/// <param name="account">账号。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>存在返回 true。</returns>
|
||||
public Task<bool> ExistsByAccountAsync(string account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化账号
|
||||
var normalized = account.Trim();
|
||||
// 2. 查询是否存在
|
||||
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 async Task<bool> ExistsByAccountAsync(long tenantId, string account, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化账号
|
||||
var normalized = account.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Account == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return await 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 async Task<bool> ExistsByPhoneAsync(long tenantId, string phone, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化手机号
|
||||
var normalized = phone.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Phone == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return await 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 async Task<bool> ExistsByEmailAsync(long tenantId, string email, long? excludeUserId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标准化邮箱
|
||||
var normalized = email.Trim();
|
||||
|
||||
// 2. 构建查询(包含已删除数据,但不放开租户过滤)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var query = dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.Email == normalized);
|
||||
|
||||
if (excludeUserId.HasValue)
|
||||
{
|
||||
query = query.Where(x => x.Id != excludeUserId.Value);
|
||||
}
|
||||
|
||||
// 3. 返回是否存在
|
||||
return await query.AnyAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
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?> GetForUpdateAsync(long userId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.IdentityUsers.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据 ID 获取后台用户(用于更新,包含已删除数据)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户或 null。</returns>
|
||||
public async Task<IdentityUser?> GetForUpdateIncludingDeletedAsync(
|
||||
long tenantId,
|
||||
long userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建查询(包含已删除数据,但强制租户隔离)
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
return await dbContext.IdentityUsers
|
||||
.Where(x => x.TenantId == tenantId)
|
||||
.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按租户与关键字搜索后台用户(只读)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="keyword">关键字(账号/名称)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户列表。</returns>
|
||||
public async Task<IReadOnlyList<IdentityUser>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.IdentityUsers
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId);
|
||||
|
||||
// 2. 关键字过滤
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => x.Account.Contains(normalized) || x.DisplayName.Contains(normalized));
|
||||
}
|
||||
|
||||
// 3. 返回列表
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 分页查询后台用户列表。
|
||||
/// </summary>
|
||||
/// <param name="filter">查询过滤条件。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>分页结果。</returns>
|
||||
public async Task<(IReadOnlyList<IdentityUser> Items, int Total)> SearchPagedAsync(
|
||||
IdentityUserSearchFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!filter.TenantId.HasValue || filter.TenantId.Value <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("TenantId 不能为空且必须大于 0");
|
||||
}
|
||||
|
||||
var tenantId = filter.TenantId.Value;
|
||||
|
||||
using var disableSoftDeleteScope = filter.IncludeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
|
||||
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.IdentityUsers.AsNoTracking();
|
||||
|
||||
// 2. 租户过滤(强制)
|
||||
query = query.Where(x => x.TenantId == tenantId);
|
||||
|
||||
// 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();
|
||||
|
||||
userRoles = userRoles.Where(x => x.TenantId == tenantId);
|
||||
|
||||
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>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户列表。</returns>
|
||||
public async Task<IReadOnlyList<IdentityUser>> GetByIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await dbContext.IdentityUsers.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && userIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量获取后台用户(可用于更新,支持包含已删除数据)。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="includeDeleted">是否包含已删除数据。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>后台用户列表。</returns>
|
||||
public async Task<IReadOnlyList<IdentityUser>> GetForUpdateByIdsAsync(
|
||||
long tenantId,
|
||||
IEnumerable<long> userIds,
|
||||
bool includeDeleted,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var ids = userIds.Distinct().ToArray();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return Array.Empty<IdentityUser>();
|
||||
}
|
||||
|
||||
var query = dbContext.IdentityUsers.Where(x => ids.Contains(x.Id));
|
||||
using var disableSoftDeleteScope = includeDeleted ? dbContext.DisableSoftDeleteFilter() : null;
|
||||
|
||||
query = query.Where(x => x.TenantId == tenantId);
|
||||
|
||||
// 2. 返回列表
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增后台用户。
|
||||
/// </summary>
|
||||
/// <param name="user">后台用户实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddAsync(IdentityUser user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加实体
|
||||
dbContext.IdentityUsers.Add(user);
|
||||
// 2. 返回完成任务
|
||||
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>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core 小程序用户仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfMiniUserRepository(IdentityDbContext dbContext) : IMiniUserRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据 OpenId 获取小程序用户。
|
||||
/// </summary>
|
||||
/// <param name="openId">微信 OpenId。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>匹配的小程序用户或 null。</returns>
|
||||
public Task<MiniUser?> FindByOpenIdAsync(string openId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据用户 ID 获取小程序用户。
|
||||
/// </summary>
|
||||
/// <param name="id">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>匹配的小程序用户或 null。</returns>
|
||||
public Task<MiniUser?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
=> dbContext.MiniUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 创建或更新小程序用户信息。
|
||||
/// </summary>
|
||||
/// <param name="openId">微信 OpenId。</param>
|
||||
/// <param name="unionId">微信 UnionId。</param>
|
||||
/// <param name="nickname">昵称。</param>
|
||||
/// <param name="avatar">头像地址。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>创建或更新后的小程序用户。</returns>
|
||||
public async Task<MiniUser> CreateOrUpdateAsync(string openId, string? unionId, string? nickname, string? avatar, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询现有用户
|
||||
var user = await dbContext.MiniUsers.FirstOrDefaultAsync(x => x.OpenId == openId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
// 2. 未找到则创建
|
||||
user = new MiniUser
|
||||
{
|
||||
Id = 0,
|
||||
OpenId = openId,
|
||||
UnionId = unionId,
|
||||
Nickname = nickname ?? "小程序用户",
|
||||
Avatar = avatar,
|
||||
TenantId = tenantId
|
||||
};
|
||||
dbContext.MiniUsers.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 3. 已存在则更新可变字段
|
||||
user.UnionId = unionId ?? user.UnionId;
|
||||
user.Nickname = nickname ?? user.Nickname;
|
||||
user.Avatar = avatar ?? user.Avatar;
|
||||
}
|
||||
|
||||
// 4. 保存更改
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// EF 权限仓储。
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 权限是系统级数据,使用 IgnoreQueryFilters 忽略多租户过滤。
|
||||
/// </remarks>
|
||||
public sealed class EfPermissionRepository(IdentityDbContext dbContext) : IPermissionRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据权限 ID 获取权限。
|
||||
/// </summary>
|
||||
/// <param name="permissionId">权限 ID。</param>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限实体或 null。</returns>
|
||||
public Task<Permission?> FindByIdAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == permissionId && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限编码获取权限。
|
||||
/// </summary>
|
||||
/// <param name="code">权限编码。</param>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限实体或 null。</returns>
|
||||
public Task<Permission?> FindByCodeAsync(string code, long tenantId, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Code == code && x.DeletedAt == null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限编码集合批量获取权限。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="codes">权限编码集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限列表。</returns>
|
||||
public Task<IReadOnlyList<Permission>> GetByCodesAsync(long tenantId, IEnumerable<string> codes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化编码集合
|
||||
var normalizedCodes = codes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
// 2. 读取权限(忽略租户过滤)
|
||||
return dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && normalizedCodes.Contains(x.Code))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据权限 ID 集合批量获取权限。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="permissionIds">权限 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限列表。</returns>
|
||||
public Task<IReadOnlyList<Permission>> GetByIdsAsync(long tenantId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
|
||||
=> dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null && permissionIds.Contains(x.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 按关键字搜索权限。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="keyword">搜索关键字。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>权限列表。</returns>
|
||||
public Task<IReadOnlyList<Permission>> SearchAsync(long tenantId, string? keyword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询(忽略租户过滤)
|
||||
var query = dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.DeletedAt == null);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
// 2. 追加关键字过滤
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => x.Name.Contains(normalized) || x.Code.Contains(normalized));
|
||||
}
|
||||
|
||||
// 3. 返回列表
|
||||
return query.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<Permission>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增权限。
|
||||
/// </summary>
|
||||
/// <param name="permission">权限实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddAsync(Permission permission, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加实体
|
||||
dbContext.Permissions.Add(permission);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新权限。
|
||||
/// </summary>
|
||||
/// <param name="permission">权限实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task UpdateAsync(Permission permission, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标记实体更新
|
||||
dbContext.Permissions.Update(permission);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除指定权限。
|
||||
/// </summary>
|
||||
/// <param name="permissionId">权限 ID。</param>
|
||||
/// <param name="tenantId">租户 ID(保留参数,实际不使用)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task DeleteAsync(long permissionId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询目标权限
|
||||
var entity = await dbContext.Permissions
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == permissionId, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
// 2. 删除实体
|
||||
dbContext.Permissions.Remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据角色 ID 集合获取角色权限映射。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleIds">角色 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色权限映射列表。</returns>
|
||||
public async Task<IReadOnlyList<RolePermission>> GetByRoleIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询角色权限映射
|
||||
var mappings = await dbContext.RolePermissions
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && roleIds.Contains(x.RoleId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量新增角色权限。
|
||||
/// </summary>
|
||||
/// <param name="rolePermissions">角色权限集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task AddRangeAsync(IEnumerable<RolePermission> rolePermissions, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 转为数组便于计数
|
||||
var toAdd = rolePermissions as RolePermission[] ?? rolePermissions.ToArray();
|
||||
if (toAdd.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 批量插入
|
||||
await dbContext.RolePermissions.AddRangeAsync(toAdd, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换指定角色的权限集合。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="permissionIds">权限 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task ReplaceRolePermissionsAsync(long tenantId, long roleId, IEnumerable<long> permissionIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 使用执行策略保证可靠性
|
||||
var strategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// 1. 删除旧记录(原生 SQL,避免跟踪干扰)
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM \"role_permissions\" WHERE \"TenantId\" = {0} AND \"RoleId\" = {1};",
|
||||
parameters: new object[] { tenantId, roleId },
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// 2. 插入新记录(防重复)
|
||||
foreach (var permissionId in permissionIds.Distinct())
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"INSERT INTO \"role_permissions\" (\"TenantId\",\"RoleId\",\"PermissionId\",\"CreatedAt\",\"CreatedBy\",\"UpdatedAt\",\"UpdatedBy\",\"DeletedAt\",\"DeletedBy\") VALUES ({0},{1},{2},NOW(),NULL,NULL,NULL,NULL,NULL) ON CONFLICT DO NOTHING;",
|
||||
parameters: new object[] { tenantId, roleId, permissionId },
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
await trx.CommitAsync(cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据角色 ID 获取角色。
|
||||
/// </summary>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <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);
|
||||
|
||||
/// <summary>
|
||||
/// 根据角色编码获取角色。
|
||||
/// </summary>
|
||||
/// <param name="code">角色编码。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <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);
|
||||
|
||||
/// <summary>
|
||||
/// 根据角色 ID 集合获取角色列表。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="roleIds">角色 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色列表。</returns>
|
||||
public async Task<IReadOnlyList<Role>> GetByIdsAsync(long tenantId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询角色列表
|
||||
var roles = await dbContext.Roles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && roleIds.Contains(x.Id) && x.DeletedAt == null)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return roles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按关键字搜索角色。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="keyword">搜索关键字。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色列表。</returns>
|
||||
public async 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);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
// 2. 追加关键字过滤
|
||||
var normalized = keyword.Trim();
|
||||
query = query.Where(x => x.Name.Contains(normalized) || x.Code.Contains(normalized));
|
||||
}
|
||||
|
||||
// 3. 返回列表
|
||||
var roles = await query.ToListAsync(cancellationToken);
|
||||
|
||||
// 4. (空行后) 返回只读列表
|
||||
return roles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增角色。
|
||||
/// </summary>
|
||||
/// <param name="role">角色实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task AddAsync(Role role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 添加实体
|
||||
dbContext.Roles.Add(role);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新角色。
|
||||
/// </summary>
|
||||
/// <param name="role">角色实体。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task UpdateAsync(Role role, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 标记更新
|
||||
dbContext.Roles.Update(role);
|
||||
// 2. 返回完成任务
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 软删除角色。
|
||||
/// </summary>
|
||||
/// <param name="roleId">角色 ID。</param>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task DeleteAsync(long roleId, long tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询目标角色
|
||||
var entity = await dbContext.Roles.FirstOrDefaultAsync(x => x.Id == roleId && x.TenantId == tenantId, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
// 2. 标记删除时间
|
||||
entity.DeletedAt = DateTime.UtcNow;
|
||||
dbContext.Roles.Update(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板仓储实现。
|
||||
/// </summary>
|
||||
public sealed class EfRoleTemplateRepository(IdentityDbContext dbContext) : IRoleTemplateRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取全部角色模板,可选按启用状态过滤。
|
||||
/// </summary>
|
||||
/// <param name="isActive">是否启用过滤。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色模板列表。</returns>
|
||||
public Task<IReadOnlyList<RoleTemplate>> GetAllAsync(bool? isActive, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 构建基础查询
|
||||
var query = dbContext.RoleTemplates.AsNoTracking();
|
||||
if (isActive.HasValue)
|
||||
{
|
||||
// 2. 按启用状态过滤
|
||||
query = query.Where(x => x.IsActive == isActive.Value);
|
||||
}
|
||||
|
||||
// 3. 排序并返回
|
||||
return query
|
||||
.OrderBy(x => x.TemplateCode)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<RoleTemplate>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据模板编码获取角色模板。
|
||||
/// </summary>
|
||||
/// <param name="templateCode">模板编码。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>角色模板或 null。</returns>
|
||||
public Task<RoleTemplate?> FindByCodeAsync(string templateCode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化编码
|
||||
var normalized = templateCode.Trim();
|
||||
// 2. 查询模板
|
||||
return dbContext.RoleTemplates.AsNoTracking().FirstOrDefaultAsync(x => x.TemplateCode == normalized, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定模板的权限集合。
|
||||
/// </summary>
|
||||
/// <param name="roleTemplateId">模板 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>模板权限列表。</returns>
|
||||
public Task<IReadOnlyList<RoleTemplatePermission>> GetPermissionsAsync(long roleTemplateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询模板权限
|
||||
return dbContext.RoleTemplatePermissions.AsNoTracking()
|
||||
.Where(x => x.RoleTemplateId == roleTemplateId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ContinueWith(t => (IReadOnlyList<RoleTemplatePermission>)t.Result, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取多个模板的权限集合。
|
||||
/// </summary>
|
||||
/// <param name="roleTemplateIds">模板 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>模板到权限的字典。</returns>
|
||||
public async Task<IDictionary<long, IReadOnlyList<RoleTemplatePermission>>> GetPermissionsAsync(IEnumerable<long> roleTemplateIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 去重 ID
|
||||
var ids = roleTemplateIds.Distinct().ToArray();
|
||||
if (ids.Length == 0)
|
||||
{
|
||||
return new Dictionary<long, IReadOnlyList<RoleTemplatePermission>>();
|
||||
}
|
||||
|
||||
// 2. 批量查询权限
|
||||
var permissions = await dbContext.RoleTemplatePermissions.AsNoTracking()
|
||||
.Where(x => ids.Contains(x.RoleTemplateId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. 组装字典
|
||||
return permissions
|
||||
.GroupBy(x => x.RoleTemplateId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoleTemplatePermission>)g.ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 新增角色模板并配置权限。
|
||||
/// </summary>
|
||||
/// <param name="template">角色模板实体。</param>
|
||||
/// <param name="permissionCodes">权限编码集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task AddAsync(RoleTemplate template, IEnumerable<string> permissionCodes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化模板字段
|
||||
template.TemplateCode = template.TemplateCode.Trim();
|
||||
template.Name = template.Name.Trim();
|
||||
// 2. 保存模板
|
||||
await dbContext.RoleTemplates.AddAsync(template, cancellationToken);
|
||||
// 3. 替换权限
|
||||
await ReplacePermissionsInternalAsync(template, permissionCodes, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新角色模板并重置权限。
|
||||
/// </summary>
|
||||
/// <param name="template">角色模板实体。</param>
|
||||
/// <param name="permissionCodes">权限编码集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task UpdateAsync(RoleTemplate template, IEnumerable<string> permissionCodes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 规范化模板字段
|
||||
template.TemplateCode = template.TemplateCode.Trim();
|
||||
template.Name = template.Name.Trim();
|
||||
// 2. 更新模板
|
||||
dbContext.RoleTemplates.Update(template);
|
||||
// 3. 重置权限
|
||||
await ReplacePermissionsInternalAsync(template, permissionCodes, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除角色模板及其权限。
|
||||
/// </summary>
|
||||
/// <param name="roleTemplateId">模板 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task DeleteAsync(long roleTemplateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询模板
|
||||
var entity = await dbContext.RoleTemplates.FirstOrDefaultAsync(x => x.Id == roleTemplateId, cancellationToken);
|
||||
if (entity != null)
|
||||
{
|
||||
// 2. 删除关联权限
|
||||
var permissions = dbContext.RoleTemplatePermissions.Where(x => x.RoleTemplateId == roleTemplateId);
|
||||
dbContext.RoleTemplatePermissions.RemoveRange(permissions);
|
||||
// 3. 删除模板
|
||||
dbContext.RoleTemplates.Remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
private async Task ReplacePermissionsInternalAsync(RoleTemplate template, IEnumerable<string> permissionCodes, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 使用执行策略保证一致性
|
||||
var strategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// 1. 确保模板已持久化,便于 FK 正确填充
|
||||
if (!dbContext.Entry(template).IsKeySet || template.Id == 0)
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2. 归一化权限编码
|
||||
var normalized = permissionCodes
|
||||
.Where(code => !string.IsNullOrWhiteSpace(code))
|
||||
.Select(code => code.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
// 3. 清空旧权限(原生 SQL 避免跟踪干扰)
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"DELETE FROM \"role_template_permissions\" WHERE \"RoleTemplateId\" = {0};",
|
||||
parameters: new object[] { template.Id },
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
// 4. 插入新权限(ON CONFLICT DO NOTHING 防御重复)
|
||||
foreach (var code in normalized)
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"INSERT INTO \"role_template_permissions\" (\"RoleTemplateId\",\"PermissionCode\",\"CreatedAt\",\"CreatedBy\",\"UpdatedAt\",\"UpdatedBy\",\"DeletedAt\",\"DeletedBy\") VALUES ({0},{1},NOW(),NULL,NULL,NULL,NULL,NULL) ON CONFLICT DO NOTHING;",
|
||||
parameters: new object[] { template.Id, code },
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
await trx.CommitAsync(cancellationToken);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据用户 ID 集合获取用户角色映射。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userIds">用户 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户角色映射列表。</returns>
|
||||
public async Task<IReadOnlyList<UserRole>> GetByUserIdsAsync(long tenantId, IEnumerable<long> userIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询用户角色映射
|
||||
var mappings = await dbContext.UserRoles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && userIds.Contains(x.UserId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取指定用户的角色集合。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>用户角色列表。</returns>
|
||||
public async Task<IReadOnlyList<UserRole>> GetByUserIdAsync(long tenantId, long userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询用户角色映射
|
||||
var mappings = await dbContext.UserRoles
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 2. (空行后) 返回只读列表
|
||||
return mappings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 替换指定用户的角色集合。
|
||||
/// </summary>
|
||||
/// <param name="tenantId">租户 ID。</param>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="roleIds">角色 ID 集合。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task ReplaceUserRolesAsync(long tenantId, long userId, IEnumerable<long> roleIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 使用执行策略保障一致性
|
||||
var strategy = dbContext.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var trx = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// 2. 读取当前角色映射
|
||||
using var disableSoftDeleteScope = dbContext.DisableSoftDeleteFilter();
|
||||
var existing = await dbContext.UserRoles
|
||||
.Where(x => x.TenantId == tenantId && x.UserId == userId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. 去重并构建目标集合
|
||||
var targetRoleIds = roleIds.Distinct().ToArray();
|
||||
var targetRoleSet = targetRoleIds.ToHashSet();
|
||||
var existingRoleMap = existing.ToDictionary(x => x.RoleId);
|
||||
|
||||
// 4. 同步现有映射状态(软删除或恢复)
|
||||
foreach (var mapping in existing)
|
||||
{
|
||||
if (targetRoleSet.Contains(mapping.RoleId))
|
||||
{
|
||||
if (mapping.DeletedAt.HasValue)
|
||||
{
|
||||
mapping.DeletedAt = null;
|
||||
mapping.DeletedBy = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!mapping.DeletedAt.HasValue)
|
||||
{
|
||||
dbContext.UserRoles.Remove(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 补齐新增角色映射
|
||||
var toAdd = targetRoleIds
|
||||
.Where(roleId => !existingRoleMap.ContainsKey(roleId))
|
||||
.Select(roleId => new UserRole
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
RoleId = roleId
|
||||
});
|
||||
|
||||
await dbContext.UserRoles.AddRangeAsync(toAdd, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await trx.CommitAsync(cancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/// <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
|
||||
.AsNoTracking()
|
||||
.Where(x => x.TenantId == tenantId && x.DeletedAt == null && x.RoleId == roleId)
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 保存仓储变更。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>保存任务。</returns>
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
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 DomainRoleTemplate = TakeoutSaaS.Domain.Identity.Entities.RoleTemplate;
|
||||
using DomainRoleTemplatePermission = TakeoutSaaS.Domain.Identity.Entities.RoleTemplatePermission;
|
||||
using DomainUserRole = TakeoutSaaS.Domain.Identity.Entities.UserRole;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 后台账号初始化种子任务
|
||||
/// </summary>
|
||||
public sealed class IdentityDataSeeder(IServiceProvider serviceProvider, ILogger<IdentityDataSeeder> logger) : IHostedService
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行后台账号与权限种子。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 创建作用域并解析依赖
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
|
||||
var options = scope.ServiceProvider.GetRequiredService<IOptions<AdminSeedOptions>>().Value;
|
||||
var passwordHasher = scope.ServiceProvider.GetRequiredService<IPasswordHasher<DomainIdentityUser>>();
|
||||
var tenantContextAccessor = scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>();
|
||||
|
||||
// 2. 校验功能开关
|
||||
if (!options.Enabled)
|
||||
{
|
||||
logger.LogInformation("AdminSeed 已禁用,跳过后台账号初始化");
|
||||
return;
|
||||
}
|
||||
// 3. 确保数据库已迁移
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
// 4. 校验账号配置
|
||||
if (options.Users is null or { Count: 0 })
|
||||
{
|
||||
logger.LogInformation("AdminSeed 未配置账号,跳过后台账号初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 写入角色模板
|
||||
await SeedRoleTemplatesAsync(context, options.RoleTemplates, cancellationToken);
|
||||
|
||||
// 6. 逐个账号处理
|
||||
foreach (var userOptions in options.Users)
|
||||
{
|
||||
// 6.1 进入租户作用域
|
||||
using var tenantScope = tenantContextAccessor.EnterTenantScope(userOptions.TenantId, "admin-seed");
|
||||
// 6.2 查询账号并收集配置
|
||||
var user = await context.IdentityUsers.FirstOrDefaultAsync(x => x.Account == userOptions.Account, cancellationToken);
|
||||
var roles = NormalizeValues(userOptions.Roles);
|
||||
var permissions = NormalizeValues(userOptions.Permissions);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
// 6.3 创建新账号
|
||||
user = new DomainIdentityUser
|
||||
{
|
||||
Id = 0,
|
||||
Account = userOptions.Account,
|
||||
DisplayName = userOptions.DisplayName,
|
||||
TenantId = userOptions.TenantId,
|
||||
MerchantId = userOptions.MerchantId,
|
||||
Avatar = null
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||
context.IdentityUsers.Add(user);
|
||||
logger.LogInformation("已创建后台账号 {Account}", user.Account);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 6.4 更新既有账号
|
||||
user.DisplayName = userOptions.DisplayName;
|
||||
user.TenantId = userOptions.TenantId;
|
||||
user.MerchantId = userOptions.MerchantId;
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, userOptions.Password);
|
||||
logger.LogInformation("已更新后台账号 {Account}", user.Account);
|
||||
}
|
||||
|
||||
// 6.5 确保角色存在
|
||||
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}"
|
||||
});
|
||||
}
|
||||
|
||||
// 6.6 读取当前租户权限定义
|
||||
var existingPermissions = await context.Permissions
|
||||
.AsNoTracking()
|
||||
.Where(p => permissions.Contains(p.Code))
|
||||
.ToListAsync(cancellationToken);
|
||||
var existingPermissionCodes = existingPermissions
|
||||
.Select(p => p.Code)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var missingPermissionCodes = permissions
|
||||
.Where(code => !existingPermissionCodes.Contains(code))
|
||||
.ToArray();
|
||||
if (missingPermissionCodes.Length > 0)
|
||||
{
|
||||
logger.LogWarning("发现未配置的全局权限编码,已忽略:{Codes}", string.Join(", ", missingPermissionCodes));
|
||||
}
|
||||
|
||||
// 6.7 保存基础角色/权限
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// 6.8 重新加载角色/权限以获取 Id
|
||||
var roleEntities = await context.Roles
|
||||
.Where(r => r.TenantId == userOptions.TenantId && roles.Contains(r.Code))
|
||||
.ToListAsync(cancellationToken);
|
||||
var permissionEntities = existingPermissions;
|
||||
|
||||
// 6.9 重置用户角色
|
||||
var existingUserRoles = await context.UserRoles
|
||||
.Where(ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
context.UserRoles.RemoveRange(existingUserRoles);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var roleIds = roleEntities.Select(r => r.Id).Distinct().ToArray();
|
||||
foreach (var roleId in roleIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var alreadyExists = await context.UserRoles.AnyAsync(
|
||||
ur => ur.TenantId == userOptions.TenantId && ur.UserId == user.Id && ur.RoleId == roleId,
|
||||
cancellationToken);
|
||||
if (alreadyExists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.UserRoles.AddAsync(new DomainUserRole
|
||||
{
|
||||
TenantId = userOptions.TenantId,
|
||||
UserId = user.Id,
|
||||
RoleId = roleId
|
||||
}, cancellationToken);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
context.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 为种子角色绑定种子权限
|
||||
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);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var distinctRoleIds = roleIds.Distinct().ToArray();
|
||||
var distinctPermissionIds = permissionIds.Distinct().ToArray();
|
||||
foreach (var roleId in distinctRoleIds)
|
||||
{
|
||||
foreach (var permissionId in distinctPermissionIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var exists = await context.RolePermissions.AnyAsync(
|
||||
rp => rp.TenantId == userOptions.TenantId
|
||||
&& rp.RoleId == roleId
|
||||
&& rp.PermissionId == permissionId,
|
||||
cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6.10 绑定角色与权限
|
||||
await context.RolePermissions.AddAsync(new DomainRolePermission
|
||||
{
|
||||
TenantId = userOptions.TenantId,
|
||||
RoleId = roleId,
|
||||
PermissionId = permissionId
|
||||
}, cancellationToken);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
context.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 最终保存
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止生命周期时的清理(此处无需处理)。
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>已完成任务。</returns>
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static async Task SeedRoleTemplatesAsync(
|
||||
IdentityDbContext context,
|
||||
IList<RoleTemplateSeedOptions> templates,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. 空集合直接返回
|
||||
if (templates is null || templates.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 逐个处理模板
|
||||
foreach (var templateOptions in templates)
|
||||
{
|
||||
// 2.1 校验必填字段
|
||||
if (string.IsNullOrWhiteSpace(templateOptions.TemplateCode) || string.IsNullOrWhiteSpace(templateOptions.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2.2 查询现有模板
|
||||
var code = templateOptions.TemplateCode.Trim();
|
||||
var existing = await context.RoleTemplates.FirstOrDefaultAsync(x => x.TemplateCode == code, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
// 2.3 新增模板
|
||||
existing = new DomainRoleTemplate
|
||||
{
|
||||
TemplateCode = code,
|
||||
Name = templateOptions.Name.Trim(),
|
||||
Description = templateOptions.Description,
|
||||
IsActive = templateOptions.IsActive
|
||||
};
|
||||
|
||||
await context.RoleTemplates.AddAsync(existing, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 2.4 更新模板
|
||||
existing.Name = templateOptions.Name.Trim();
|
||||
existing.Description = templateOptions.Description;
|
||||
existing.IsActive = templateOptions.IsActive;
|
||||
context.RoleTemplates.Update(existing);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// 2.5 重置模板权限
|
||||
var permissionCodes = NormalizeValues(templateOptions.Permissions);
|
||||
var existingPermissions = await context.RoleTemplatePermissions
|
||||
.Where(x => x.RoleTemplateId == existing.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
// 2.6 清空旧权限并保存
|
||||
context.RoleTemplatePermissions.RemoveRange(existingPermissions);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
// 2.7 去重后的权限编码
|
||||
var distinctPermissionCodes = permissionCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
foreach (var permissionCode in distinctPermissionCodes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var alreadyExists = await context.RoleTemplatePermissions.AnyAsync(
|
||||
x => x.RoleTemplateId == existing.Id && x.PermissionCode == permissionCode,
|
||||
cancellationToken);
|
||||
if (alreadyExists)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await context.RoleTemplatePermissions.AddAsync(new DomainRoleTemplatePermission
|
||||
{
|
||||
RoleTemplateId = existing.Id,
|
||||
PermissionCode = permissionCode
|
||||
}, cancellationToken);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException pg && pg.SqlState == PostgresErrorCodes.UniqueViolation)
|
||||
{
|
||||
context.ChangeTracker.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] NormalizeValues(string[]? values)
|
||||
=> values == null
|
||||
? []
|
||||
: [.. values
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.Select(v => v.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)];
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
using MassTransit;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence;
|
||||
using TakeoutSaaS.Shared.Abstractions.Ids;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 身份认证 DbContext,带多租户过滤与审计字段处理。
|
||||
/// </summary>
|
||||
public sealed class IdentityDbContext(
|
||||
DbContextOptions<IdentityDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor? currentUserAccessor = null,
|
||||
IIdGenerator? idGenerator = null)
|
||||
: TenantAwareDbContext(options, tenantProvider, currentUserAccessor, idGenerator)
|
||||
{
|
||||
/// <summary>
|
||||
/// 管理后台用户集合。
|
||||
/// </summary>
|
||||
public DbSet<IdentityUser> IdentityUsers => Set<IdentityUser>();
|
||||
|
||||
/// <summary>
|
||||
/// 小程序用户集合。
|
||||
/// </summary>
|
||||
public DbSet<MiniUser> MiniUsers => Set<MiniUser>();
|
||||
|
||||
/// <summary>
|
||||
/// 角色集合。
|
||||
/// </summary>
|
||||
public DbSet<Role> Roles => Set<Role>();
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板集合(系统级)。
|
||||
/// </summary>
|
||||
public DbSet<RoleTemplate> RoleTemplates => Set<RoleTemplate>();
|
||||
|
||||
/// <summary>
|
||||
/// 角色模板权限集合。
|
||||
/// </summary>
|
||||
public DbSet<RoleTemplatePermission> RoleTemplatePermissions => Set<RoleTemplatePermission>();
|
||||
|
||||
/// <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>
|
||||
public DbSet<MenuDefinition> MenuDefinitions => Set<MenuDefinition>();
|
||||
|
||||
/// <summary>
|
||||
/// 配置实体模型。
|
||||
/// </summary>
|
||||
/// <param name="modelBuilder">模型构建器。</param>
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
ConfigureIdentityUser(modelBuilder.Entity<IdentityUser>());
|
||||
ConfigureMiniUser(modelBuilder.Entity<MiniUser>());
|
||||
ConfigureRole(modelBuilder.Entity<Role>());
|
||||
ConfigureRoleTemplate(modelBuilder.Entity<RoleTemplate>());
|
||||
ConfigureRoleTemplatePermission(modelBuilder.Entity<RoleTemplatePermission>());
|
||||
ConfigurePermission(modelBuilder.Entity<Permission>());
|
||||
ConfigureUserRole(modelBuilder.Entity<UserRole>());
|
||||
ConfigureRolePermission(modelBuilder.Entity<RolePermission>());
|
||||
ConfigureMenuDefinition(modelBuilder.Entity<MenuDefinition>());
|
||||
modelBuilder.AddOutboxMessageEntity();
|
||||
modelBuilder.AddOutboxStateEntity();
|
||||
ApplyTenantQueryFilters(modelBuilder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置管理后台用户实体。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureIdentityUser(EntityTypeBuilder<IdentityUser> builder)
|
||||
{
|
||||
builder.ToTable("identity_users");
|
||||
builder.HasKey(x => x.Id);
|
||||
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.Ignore(x => x.RowVersion);
|
||||
builder.Property<uint>("xmin")
|
||||
.HasColumnName("xmin")
|
||||
.HasColumnType("xid")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.IsConcurrencyToken();
|
||||
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>
|
||||
/// 配置小程序用户实体。
|
||||
/// </summary>
|
||||
/// <param name="builder">实体构建器。</param>
|
||||
private static void ConfigureMiniUser(EntityTypeBuilder<MiniUser> builder)
|
||||
{
|
||||
builder.ToTable("mini_users");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TenantId).IsRequired();
|
||||
builder.Property(x => x.OpenId).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.UnionId).HasMaxLength(128);
|
||||
builder.Property(x => x.Nickname).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Avatar).HasColumnType("text");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
|
||||
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.ParentId).IsRequired();
|
||||
builder.Property(x => x.SortOrder).IsRequired();
|
||||
builder.Property(x => x.Type).HasMaxLength(16).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.Code).IsUnique();
|
||||
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
|
||||
}
|
||||
|
||||
private static void ConfigureRoleTemplate(EntityTypeBuilder<RoleTemplate> builder)
|
||||
{
|
||||
builder.ToTable("role_templates");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.TemplateCode).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Description).HasMaxLength(256);
|
||||
builder.Property(x => x.IsActive).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
builder.HasIndex(x => x.TemplateCode).IsUnique();
|
||||
}
|
||||
|
||||
private static void ConfigureRoleTemplatePermission(EntityTypeBuilder<RoleTemplatePermission> builder)
|
||||
{
|
||||
builder.ToTable("role_template_permissions");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.RoleTemplateId).IsRequired();
|
||||
builder.Property(x => x.PermissionCode).HasMaxLength(128).IsRequired();
|
||||
ConfigureAuditableEntity(builder);
|
||||
builder.HasIndex(x => new { x.RoleTemplateId, x.PermissionCode }).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();
|
||||
}
|
||||
|
||||
private static void ConfigureMenuDefinition(EntityTypeBuilder<MenuDefinition> builder)
|
||||
{
|
||||
builder.ToTable("menu_definitions");
|
||||
builder.HasKey(x => x.Id);
|
||||
builder.Property(x => x.Portal).HasConversion<int>().IsRequired();
|
||||
builder.Property(x => x.ParentId).IsRequired();
|
||||
builder.Property(x => x.Name).HasMaxLength(64).IsRequired();
|
||||
builder.Property(x => x.Path).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Component).HasMaxLength(256).IsRequired();
|
||||
builder.Property(x => x.Title).HasMaxLength(128).IsRequired();
|
||||
builder.Property(x => x.Icon).HasMaxLength(64);
|
||||
builder.Property(x => x.Link).HasMaxLength(512);
|
||||
builder.Property(x => x.SortOrder).IsRequired();
|
||||
builder.Property(x => x.RequiredPermissions).HasMaxLength(1024);
|
||||
builder.Property(x => x.MetaPermissions).HasMaxLength(1024);
|
||||
builder.Property(x => x.MetaRoles).HasMaxLength(1024);
|
||||
builder.Property(x => x.AuthListJson).HasColumnType("text");
|
||||
ConfigureAuditableEntity(builder);
|
||||
ConfigureSoftDeleteEntity(builder);
|
||||
builder.HasIndex(x => new { x.Portal, x.ParentId, x.SortOrder });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Infrastructure.Common.Persistence.DesignTime;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Security;
|
||||
using TakeoutSaaS.Shared.Abstractions.Tenancy;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// 设计时 IdentityDbContext 工厂,供 EF Core CLI 生成迁移使用。
|
||||
/// </summary>
|
||||
internal sealed class IdentityDesignTimeDbContextFactory
|
||||
: DesignTimeDbContextFactoryBase<IdentityDbContext>
|
||||
{
|
||||
/// <summary>
|
||||
/// 初始化 Identity 设计时上下文工厂。
|
||||
/// </summary>
|
||||
public IdentityDesignTimeDbContextFactory()
|
||||
: base(DatabaseConstants.IdentityDataSource, "TAKEOUTSAAS_IDENTITY_CONNECTION")
|
||||
{
|
||||
}
|
||||
// 创建设计时上下文实例
|
||||
/// <summary>
|
||||
/// 创建设计时的 IdentityDbContext。
|
||||
/// </summary>
|
||||
/// <param name="options">DbContext 配置。</param>
|
||||
/// <param name="tenantProvider">租户提供器。</param>
|
||||
/// <param name="currentUserAccessor">当前用户访问器。</param>
|
||||
/// <returns>IdentityDbContext 实例。</returns>
|
||||
protected override IdentityDbContext CreateContext(
|
||||
DbContextOptions<IdentityDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
ICurrentUserAccessor currentUserAccessor)
|
||||
=> new(options, tenantProvider, currentUserAccessor);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TakeoutSaaS.Domain.Identity.Entities;
|
||||
using TakeoutSaaS.Domain.Identity.Enums;
|
||||
using TakeoutSaaS.Domain.Identity.Repositories;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Persistence;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// 菜单仓储 EF 实现。
|
||||
/// </summary>
|
||||
public sealed class EfMenuRepository(IdentityDbContext dbContext) : IMenuRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<MenuDefinition>> GetByPortalAsync(PortalType portal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 按门户类型查询菜单(忽略租户过滤器)
|
||||
var menus = await dbContext.MenuDefinitions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.Where(x => x.Portal == portal && x.DeletedAt == null)
|
||||
.OrderBy(x => x.ParentId)
|
||||
.ThenBy(x => x.SortOrder)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return menus;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<MenuDefinition?> FindByIdAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 按 ID 查询菜单(忽略租户过滤器)
|
||||
return await dbContext.MenuDefinitions
|
||||
.IgnoreQueryFilters()
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.DeletedAt == null, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(MenuDefinition menu, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return dbContext.MenuDefinitions.AddAsync(menu, cancellationToken).AsTask();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateAsync(MenuDefinition menu, CancellationToken cancellationToken = default)
|
||||
{
|
||||
dbContext.MenuDefinitions.Update(menu);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(long id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 查询目标(忽略租户过滤器)
|
||||
var entity = await dbContext.MenuDefinitions
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
// 2. 存在则删除
|
||||
if (entity is not null)
|
||||
{
|
||||
dbContext.MenuDefinitions.Remove(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Contracts;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// JWT 令牌生成器。
|
||||
/// </summary>
|
||||
public sealed class JwtTokenService(IRefreshTokenStore refreshTokenStore, IOptions<JwtOptions> options) : IJwtTokenService
|
||||
{
|
||||
private readonly JwtSecurityTokenHandler _tokenHandler = new();
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 创建访问令牌和刷新令牌对。
|
||||
/// </summary>
|
||||
/// <param name="profile">用户档案</param>
|
||||
/// <param name="isNewUser">是否为新用户(首次登录)</param>
|
||||
/// <param name="cancellationToken">取消令牌</param>
|
||||
/// <returns>令牌响应</returns>
|
||||
public async Task<TokenResponse> CreateTokensAsync(CurrentUserProfile profile, bool isNewUser = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var accessExpires = now.AddMinutes(_options.AccessTokenExpirationMinutes);
|
||||
var refreshExpires = now.AddMinutes(_options.RefreshTokenExpirationMinutes);
|
||||
|
||||
// 1. 构建 JWT Claims(包含用户 ID、账号、租户 ID、商户 ID、角色、权限等)
|
||||
var claims = BuildClaims(profile);
|
||||
|
||||
// 2. 创建签名凭据(使用 HMAC SHA256 算法)
|
||||
var signingCredentials = new SigningCredentials(
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)),
|
||||
SecurityAlgorithms.HmacSha256);
|
||||
|
||||
// 3. 创建 JWT 安全令牌
|
||||
var jwt = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: accessExpires,
|
||||
signingCredentials: signingCredentials);
|
||||
|
||||
// 4. 序列化 JWT 为字符串
|
||||
var accessToken = _tokenHandler.WriteToken(jwt);
|
||||
|
||||
// 5. 生成刷新令牌并存储到 Redis
|
||||
var refreshDescriptor = await refreshTokenStore.IssueAsync(profile.UserId, refreshExpires, cancellationToken);
|
||||
|
||||
return new TokenResponse
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
AccessTokenExpiresAt = accessExpires,
|
||||
RefreshToken = refreshDescriptor.Token,
|
||||
RefreshTokenExpiresAt = refreshDescriptor.ExpiresAt,
|
||||
User = profile,
|
||||
IsNewUser = isNewUser
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 构建 JWT Claims:将用户档案转换为 Claims 集合。
|
||||
/// </summary>
|
||||
/// <param name="profile">用户档案</param>
|
||||
/// <returns>Claims 集合</returns>
|
||||
private static List<Claim> BuildClaims(CurrentUserProfile profile)
|
||||
{
|
||||
var userId = profile.UserId.ToString();
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId),
|
||||
new(ClaimTypes.NameIdentifier, userId),
|
||||
new(JwtRegisteredClaimNames.UniqueName, profile.Account),
|
||||
new("tenant_id", profile.TenantId.ToString()),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
||||
};
|
||||
|
||||
if (profile.MerchantId.HasValue)
|
||||
{
|
||||
claims.Add(new Claim("merchant_id", profile.MerchantId.Value.ToString()));
|
||||
}
|
||||
|
||||
claims.AddRange(profile.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
|
||||
claims.AddRange(profile.Permissions.Select(permission => new Claim("permission", permission)));
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 管理后台重置密码链接令牌存储。
|
||||
/// </summary>
|
||||
public sealed class RedisAdminPasswordResetTokenStore(
|
||||
IDistributedCache cache,
|
||||
IOptions<AdminPasswordResetOptions> options)
|
||||
: IAdminPasswordResetTokenStore
|
||||
{
|
||||
private readonly AdminPasswordResetOptions _options = options.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 生成 URL 安全的随机令牌
|
||||
var token = GenerateUrlSafeToken(48);
|
||||
|
||||
// 2. 写入缓存(Value:userId)
|
||||
await cache.SetStringAsync(BuildKey(token), userId.ToString(), new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpiration = expiresAt
|
||||
}, cancellationToken);
|
||||
|
||||
// 3. 返回令牌
|
||||
return token;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<long?> ConsumeAsync(string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取缓存
|
||||
var key = BuildKey(token);
|
||||
var value = await cache.GetStringAsync(key, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 删除缓存(一次性令牌)
|
||||
await cache.RemoveAsync(key, cancellationToken);
|
||||
|
||||
// 3. 解析用户 ID
|
||||
return long.TryParse(value, out var userId) ? userId : null;
|
||||
}
|
||||
|
||||
private string BuildKey(string token) => $"{_options.Prefix}{token}";
|
||||
|
||||
private static string GenerateUrlSafeToken(int bytesLength)
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(bytesLength);
|
||||
var token = Convert.ToBase64String(bytes);
|
||||
|
||||
return token
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 登录限流实现。
|
||||
/// </summary>
|
||||
public sealed class RedisLoginRateLimiter(IDistributedCache cache, IOptions<LoginRateLimitOptions> options) : ILoginRateLimiter
|
||||
{
|
||||
private readonly LoginRateLimitOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 校验指定键的登录尝试次数,超限将抛出业务异常。
|
||||
/// </summary>
|
||||
/// <param name="key">限流键(如账号或 IP)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task EnsureAllowedAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取当前计数
|
||||
var cacheKey = BuildKey(key);
|
||||
var current = await cache.GetStringAsync(cacheKey, cancellationToken);
|
||||
var count = string.IsNullOrWhiteSpace(current) ? 0 : int.Parse(current);
|
||||
if (count >= _options.MaxAttempts)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Forbidden, "尝试次数过多,请稍后再试");
|
||||
}
|
||||
|
||||
// 2. 累加计数并回写缓存
|
||||
count++;
|
||||
await cache.SetStringAsync(
|
||||
cacheKey,
|
||||
count.ToString(),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(_options.WindowSeconds)
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置指定键的登录计数。
|
||||
/// </summary>
|
||||
/// <param name="key">限流键(如账号或 IP)。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public Task ResetAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> cache.RemoveAsync(BuildKey(key), cancellationToken);
|
||||
|
||||
private static string BuildKey(string key) => $"identity:login:{key}";
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Application.Identity.Models;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Redis 刷新令牌存储。
|
||||
/// </summary>
|
||||
public sealed class RedisRefreshTokenStore(IDistributedCache cache, IOptions<RefreshTokenStoreOptions> options) : IRefreshTokenStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly RefreshTokenStoreOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 签发刷新令牌并写入缓存。
|
||||
/// </summary>
|
||||
/// <param name="userId">用户 ID。</param>
|
||||
/// <param name="expiresAt">过期时间。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>刷新令牌描述。</returns>
|
||||
public async Task<RefreshTokenDescriptor> IssueAsync(long userId, DateTime expiresAt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 生成随机令牌
|
||||
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
var descriptor = new RefreshTokenDescriptor(token, userId, expiresAt, false);
|
||||
|
||||
// 2. 写入缓存
|
||||
var key = BuildKey(token);
|
||||
var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = expiresAt };
|
||||
await cache.SetStringAsync(key, JsonSerializer.Serialize(descriptor, JsonOptions), entryOptions, cancellationToken);
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取刷新令牌描述。
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">刷新令牌值。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>刷新令牌描述或 null。</returns>
|
||||
public async Task<RefreshTokenDescriptor?> GetAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取缓存
|
||||
var json = await cache.GetStringAsync(BuildKey(refreshToken), cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? null
|
||||
: JsonSerializer.Deserialize<RefreshTokenDescriptor>(json, JsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 吊销刷新令牌。
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">刷新令牌值。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>异步任务。</returns>
|
||||
public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 读取令牌
|
||||
var descriptor = await GetAsync(refreshToken, cancellationToken);
|
||||
if (descriptor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 标记吊销并回写缓存
|
||||
var updated = descriptor with { Revoked = true };
|
||||
var entryOptions = new DistributedCacheEntryOptions { AbsoluteExpiration = updated.ExpiresAt };
|
||||
await cache.SetStringAsync(BuildKey(refreshToken), JsonSerializer.Serialize(updated, JsonOptions), entryOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private string BuildKey(string token) => $"{_options.Prefix}{token}";
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using TakeoutSaaS.Application.Identity.Abstractions;
|
||||
using TakeoutSaaS.Infrastructure.Identity.Options;
|
||||
using TakeoutSaaS.Shared.Abstractions.Constants;
|
||||
using TakeoutSaaS.Shared.Abstractions.Exceptions;
|
||||
|
||||
namespace TakeoutSaaS.Infrastructure.Identity.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 微信 code2Session 实现
|
||||
/// </summary>
|
||||
public sealed class WeChatAuthService(HttpClient httpClient, IOptions<WeChatMiniOptions> options) : IWeChatAuthService
|
||||
{
|
||||
private readonly WeChatMiniOptions _options = options.Value;
|
||||
|
||||
/// <summary>
|
||||
/// 调用微信接口完成 code2Session。
|
||||
/// </summary>
|
||||
/// <param name="code">临时登录凭证 code。</param>
|
||||
/// <param name="cancellationToken">取消标记。</param>
|
||||
/// <returns>微信会话信息。</returns>
|
||||
public async Task<WeChatSessionInfo> Code2SessionAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// 1. 拼装请求地址
|
||||
var requestUri = $"sns/jscode2session?appid={Uri.EscapeDataString(_options.AppId)}&secret={Uri.EscapeDataString(_options.Secret)}&js_code={Uri.EscapeDataString(code)}&grant_type=authorization_code";
|
||||
using var response = await httpClient.GetAsync(requestUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
// 2. 读取响应
|
||||
var payload = await response.Content.ReadFromJsonAsync<WeChatSessionResponse>(cancellationToken: cancellationToken);
|
||||
if (payload == null)
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:响应为空");
|
||||
}
|
||||
|
||||
// 3. 校验错误码
|
||||
if (payload.ErrorCode.HasValue && payload.ErrorCode.Value != 0)
|
||||
{
|
||||
var message = string.IsNullOrWhiteSpace(payload.ErrorMessage)
|
||||
? $"微信登录失败,错误码:{payload.ErrorCode}"
|
||||
: payload.ErrorMessage;
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, message);
|
||||
}
|
||||
|
||||
// 4. 校验必要字段
|
||||
if (string.IsNullOrWhiteSpace(payload.OpenId) || string.IsNullOrWhiteSpace(payload.SessionKey))
|
||||
{
|
||||
throw new BusinessException(ErrorCodes.Unauthorized, "微信登录失败:返回数据无效");
|
||||
}
|
||||
|
||||
// 5. 组装会话信息
|
||||
return new WeChatSessionInfo
|
||||
{
|
||||
OpenId = payload.OpenId,
|
||||
UnionId = payload.UnionId,
|
||||
SessionKey = payload.SessionKey
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class WeChatSessionResponse
|
||||
{
|
||||
[JsonPropertyName("openid")]
|
||||
public string? OpenId { get; set; }
|
||||
|
||||
[JsonPropertyName("unionid")]
|
||||
public string? UnionId { get; set; }
|
||||
|
||||
[JsonPropertyName("session_key")]
|
||||
public string? SessionKey { get; set; }
|
||||
|
||||
[JsonPropertyName("errcode")]
|
||||
public int? ErrorCode { get; set; }
|
||||
|
||||
[JsonPropertyName("errmsg")]
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user