347 lines
15 KiB
C#
347 lines
15 KiB
C#
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 = EnterTenantScope(tenantContextAccessor, userOptions.TenantId);
|
|
// 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
|
|
.Where(p => p.TenantId == userOptions.TenantId && permissions.Contains(p.Code))
|
|
.ToListAsync(cancellationToken);
|
|
var existingPermissionCodes = existingPermissions.Select(p => p.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var code in permissions)
|
|
{
|
|
if (existingPermissionCodes.Contains(code))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
context.Permissions.Add(new DomainPermission
|
|
{
|
|
TenantId = userOptions.TenantId,
|
|
Code = code,
|
|
Name = code,
|
|
Description = $"Seed permission {code}"
|
|
});
|
|
}
|
|
|
|
// 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 = await context.Permissions
|
|
.Where(p => p.TenantId == userOptions.TenantId && permissions.Contains(p.Code))
|
|
.ToListAsync(cancellationToken);
|
|
|
|
// 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)];
|
|
|
|
private static IDisposable EnterTenantScope(ITenantContextAccessor accessor, long tenantId)
|
|
{
|
|
var previous = accessor.Current;
|
|
accessor.Current = new TenantContext(tenantId, null, "admin-seed");
|
|
return new Scope(() => accessor.Current = previous);
|
|
}
|
|
|
|
private sealed class Scope(Action disposeAction) : IDisposable
|
|
{
|
|
private readonly Action _disposeAction = disposeAction;
|
|
public void Dispose() => _disposeAction();
|
|
}
|
|
}
|