refactor: 清理租户API旧模块代码

This commit is contained in:
2026-02-17 09:57:26 +08:00
parent 2711893474
commit 992930a821
924 changed files with 7 additions and 191722 deletions

View File

@@ -1,54 +0,0 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using TakeoutSaaS.Infrastructure.Identity.Options;
namespace TakeoutSaaS.Infrastructure.Identity.Extensions;
/// <summary>
/// JWT 认证扩展
/// </summary>
public static class JwtAuthenticationExtensions
{
/// <summary>
/// 配置 JWT Bearer 认证
/// </summary>
public static IServiceCollection AddJwtAuthentication(this IServiceCollection services, IConfiguration configuration)
{
var jwtOptions = configuration.GetSection("Identity:Jwt").Get<JwtOptions>()
?? throw new InvalidOperationException("缺少 Identity:Jwt 配置");
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap.Clear();
services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.RequireHttpsMetadata = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1),
NameClaimType = ClaimTypes.NameIdentifier,
RoleClaimType = ClaimTypes.Role
};
});
return services;
}
}

View File

@@ -1,119 +0,0 @@
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 中兜底并记录日志。
}
}

View File

@@ -1,13 +0,0 @@
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// 管理后台重置密码链接令牌配置。
/// </summary>
public sealed class AdminPasswordResetOptions
{
/// <summary>
/// Redis Key 前缀。
/// </summary>
public string Prefix { get; init; } = "identity:admin:pwdreset:";
}

View File

@@ -1,101 +0,0 @@
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>();
}

View File

@@ -1,40 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// JWT 配置选项。
/// </summary>
public sealed class JwtOptions
{
/// <summary>
/// 令牌颁发者Issuer
/// </summary>
[Required]
public string Issuer { get; set; } = string.Empty;
/// <summary>
/// 令牌受众Audience
/// </summary>
[Required]
public string Audience { get; set; } = string.Empty;
/// <summary>
/// JWT 签名密钥(至少 32 个字符)。
/// </summary>
[Required]
[MinLength(32)]
public string Secret { get; set; } = string.Empty;
/// <summary>
/// 访问令牌过期时间分钟范围5-1440。
/// </summary>
[Range(5, 1440)]
public int AccessTokenExpirationMinutes { get; set; } = 60;
/// <summary>
/// 刷新令牌过期时间分钟范围60-2016014天
/// </summary>
[Range(60, 1440 * 14)]
public int RefreshTokenExpirationMinutes { get; set; } = 60 * 24 * 7;
}

View File

@@ -1,21 +0,0 @@
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;
}

View File

@@ -1,12 +0,0 @@
namespace TakeoutSaaS.Infrastructure.Identity.Options;
/// <summary>
/// 刷新令牌存储配置选项。
/// </summary>
public sealed class RefreshTokenStoreOptions
{
/// <summary>
/// Redis 键前缀,用于存储刷新令牌。
/// </summary>
public string Prefix { get; set; } = "identity:refresh:";
}

View File

@@ -1,21 +0,0 @@
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;
}

View File

@@ -1,373 +0,0 @@
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);
}

View File

@@ -1,70 +0,0 @@
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;
}
}

View File

@@ -1,162 +0,0 @@
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);
}

View File

@@ -1,92 +0,0 @@
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);
}

View File

@@ -1,136 +0,0 @@
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);
}

View File

@@ -1,193 +0,0 @@
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);
});
}
}

View File

@@ -1,133 +0,0 @@
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);
}

View File

@@ -1,326 +0,0 @@
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)];
}

View File

@@ -1,246 +0,0 @@
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 });
}
}

View File

@@ -1,35 +0,0 @@
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);
}

View File

@@ -1,72 +0,0 @@
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);
}
}

View File

@@ -1,95 +0,0 @@
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;
}
}

View File

@@ -1,66 +0,0 @@
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. 写入缓存ValueuserId
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('=');
}
}

View File

@@ -1,56 +0,0 @@
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}";
}

View File

@@ -1,77 +0,0 @@
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}";
}

View File

@@ -1,79 +0,0 @@
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; }
}
}