using System.Text.RegularExpressions;
using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy;
using FoodLabeling.Th.Application.Contracts.IServices;
using FoodLabeling.Th.Application.Contracts.Options;
using FoodLabeling.Th.Application.MultiTenancy;
using FoodLabeling.Th.Domain.Entities;
using FoodLabeling.Th.Domain.Shared.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Caching;
using Volo.Abp.Domain.Entities;
using Volo.Abp.MultiTenancy;
using Volo.Abp.Uow;
using Yi.Framework.Rbac.Domain.Entities;
using Yi.Framework.Rbac.Domain.Helpers;
using Yi.Framework.Rbac.Domain.Shared.Consts;
using Yi.Framework.Rbac.Domain.Shared.Options;
using Yi.Framework.SqlSugarCore.Abstractions;
using Yi.Framework.TenantManagement.Application.Contracts;
using Yi.Framework.TenantManagement.Application.Contracts.Dtos;
using Yi.Framework.TenantManagement.Domain;
namespace FoodLabeling.Th.Application.Services;
///
/// 泰额版多租户:租户下拉、公司列表与管理员凭据管理
///
public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppService
{
private static readonly Guid ProtectedDefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private const string ProtectedDefaultTenantName = "Default";
private static readonly HashSet ProtectedDatabaseNames = new(StringComparer.OrdinalIgnoreCase)
{
"antis-foodlabeling-host",
"antis-foodlabeling-us"
};
private static readonly Regex SafeDatabaseNameRegex =
new(@"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$", RegexOptions.Compiled);
private readonly ITenantService _tenantService;
private readonly ISqlSugarRepository _tenantRepository;
private readonly ISqlSugarRepository _credentialRepository;
private readonly ISqlSugarRepository _menuPermissionRepository;
private readonly TenantSelectCredentialCipher _credentialCipher;
private readonly RbacOptions _rbacOptions;
private readonly FoodLabelingThTenantDatabaseOptions _tenantDatabaseOptions;
private readonly DbConnOptions _dbConnOptions;
private readonly IDistributedCache _tenantCache;
public ThMultiTenancyAppService(
ITenantService tenantService,
ISqlSugarRepository tenantRepository,
ISqlSugarRepository credentialRepository,
ISqlSugarRepository menuPermissionRepository,
TenantSelectCredentialCipher credentialCipher,
IOptions rbacOptions,
IOptions tenantDatabaseOptions,
IOptions dbConnOptions,
IDistributedCache tenantCache)
{
_tenantService = tenantService;
_tenantRepository = tenantRepository;
_credentialRepository = credentialRepository;
_menuPermissionRepository = menuPermissionRepository;
_credentialCipher = credentialCipher;
_rbacOptions = rbacOptions.Value;
_tenantDatabaseOptions = tenantDatabaseOptions.Value;
_dbConnOptions = dbConnOptions.Value;
_tenantCache = tenantCache;
}
///
/// 主库表(DefaultTenantTable)读写须将 CurrentTenant 置空
///
private IDisposable UseHostTenantScope() => CurrentTenant.Change(null);
///
/// 获取登录页可选租户列表(含默认管理员加密凭据)
///
///
/// 供 Web 登录页租户下拉使用;匿名可访问。不返回租户 Id 与数据库连接串。
///
/// 示例响应:
/// ```json
/// [
/// {
/// "name": "Default",
/// "creationTime": "2026-01-01T08:00:00",
/// "loginAccount": "admin",
/// "password": "Base64CipherText",
/// "passwordSalt": "Base64Iv"
/// }
/// ]
/// ```
///
/// 字段说明:
/// - name: 租户名称,登录时可传 tenantName
/// - creationTime: 租户创建时间
/// - loginAccount: 管理员登录账号,优先读凭据表,否则为 admin
/// - password: 优先读凭据表 AES 密文,否则 RbacOptions.AdminPassword 加密后返回
/// - passwordSalt: AES IV(Base64),前端解密时使用
///
/// 租户下拉列表
/// 成功返回租户列表
/// 加密配置缺失或服务器错误
[AllowAnonymous]
public virtual async Task> GetTenantSelectAsync()
{
var page = await _tenantService.GetListAsync(new TenantGetListInput
{
SkipCount = 0,
MaxResultCount = 500
});
var credentialMap = await LoadCredentialMapAsync(page.Items.Select(x => x.Id).ToList());
return page.Items
.Select(x =>
{
credentialMap.TryGetValue(x.Id, out var credential);
var resolved = ResolveEncryptedCredential(credential);
return new ThTenantSelectDto
{
Name = x.Name,
CreationTime = x.CreationTime,
LoginAccount = resolved.LoginAccount,
Password = resolved.Password,
PasswordSalt = resolved.PasswordSalt
};
})
.OrderBy(x => x.Name)
.ToList();
}
///
/// 平台管理员分页获取公司(租户)列表及可回显加密凭据
///
///
/// 需登录;查询主库 YiTenant,密码优先读 fl_th_tenant_admin_credential,否则回退 RbacOptions.AdminPassword 再 AES 加密返回。
///
/// 示例请求(GET,query/data):
/// ```json
/// {
/// "keyword": "测试",
/// "skipCount": 0,
/// "maxResultCount": 20
/// }
/// ```
///
/// 示例响应:
/// ```json
/// {
/// "totalCount": 1,
/// "items": [
/// {
/// "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
/// "name": "测试公司",
/// "creationTime": "2026-01-01T08:00:00",
/// "loginAccount": "admin",
/// "password": "Base64CipherText",
/// "passwordSalt": "Base64Iv"
/// }
/// ]
/// }
/// ```
///
/// 参数说明:
/// - keyword/name: 租户名称模糊匹配(keyword 优先)
/// - skipCount: 跳过条数
/// - maxResultCount: 每页条数
///
/// 分页与筛选条件
/// 分页公司列表
/// 成功返回分页列表
/// 未登录
/// 服务器错误
[Authorize]
[HttpGet("th-multi-tenancy/company-list")]
public virtual async Task> GetCompanyListAsync(ThCompanyListInput input)
{
var nameFilter = !string.IsNullOrWhiteSpace(input.Keyword) ? input.Keyword : input.Name;
var page = await _tenantService.GetListAsync(new TenantGetListInput
{
Name = nameFilter,
SkipCount = input.SkipCount,
MaxResultCount = input.MaxResultCount
});
var tenantIds = page.Items.Select(x => x.Id).ToList();
var credentialMap = await LoadCredentialMapAsync(tenantIds);
var menuPermissionMap = await LoadMenuPermissionMapAsync(tenantIds);
var items = page.Items.Select(x =>
{
credentialMap.TryGetValue(x.Id, out var credential);
var resolved = ResolveEncryptedCredential(credential);
menuPermissionMap.TryGetValue(x.Id, out var menuKeys);
return new ThCompanyListItemDto
{
Id = x.Id,
Name = x.Name,
CreationTime = x.CreationTime,
LoginAccount = resolved.LoginAccount,
Password = resolved.Password,
PasswordSalt = resolved.PasswordSalt,
MenuPermissionKeys = menuKeys ?? new List()
};
}).ToList();
return new PagedResultDto(page.TotalCount, items);
}
///
/// 修改公司(租户)默认管理员登录账号与/或密码
///
///
/// 需登录。password/passwordSalt 均为空时不改密码;有 password 时必须带 passwordSalt,后端解密后再写入租户库 User 哈希。
///
/// 示例请求:
/// ```json
/// {
/// "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
/// "loginAccount": "admin",
/// "password": "Base64CipherText",
/// "passwordSalt": "Base64Iv"
/// }
/// ```
///
/// 参数说明:
/// - tenantId: 租户 Id(必填)
/// - loginAccount: 新登录账号(可选)
/// - password: AES 加密新密码(可选)
/// - passwordSalt: AES IV(改密时必填)
///
/// 租户 Id 与待修改凭据
/// 无内容
/// 修改成功
/// 参数错误或账号重复
/// 未登录
/// 租户或管理员不存在
/// 服务器错误
[Authorize]
[HttpPut("th-multi-tenancy/company-admin")]
public virtual async Task UpdateCompanyAdminAsync(ThUpdateCompanyAdminInputVo input)
{
if (input.TenantId == Guid.Empty)
{
throw new UserFriendlyException("租户 Id 不能为空");
}
var hasPassword = !string.IsNullOrWhiteSpace(input.Password);
if (hasPassword && string.IsNullOrWhiteSpace(input.PasswordSalt))
{
throw new UserFriendlyException("修改密码时必须提供 passwordSalt");
}
TenantAggregateRoot tenant;
ThTenantAdminCredentialEntity? existingCredential;
using (UseHostTenantScope())
{
tenant = await _tenantRepository.FindAsync(input.TenantId)
?? throw new UserFriendlyException("租户不存在");
// 主键列 TenantId 映射到实体 Id,用 Queryable 按 Id 查询
var credentials = await _credentialRepository._DbQueryable
.Where(c => c.Id == input.TenantId)
.Take(1)
.ToListAsync();
existingCredential = credentials.FirstOrDefault();
}
var currentLoginAccount = existingCredential?.LoginAccount?.Trim()
?? ThTenantSelectConsts.DefaultAdminLoginAccount;
var newLoginAccount = !string.IsNullOrWhiteSpace(input.LoginAccount)
? input.LoginAccount.Trim()
: currentLoginAccount;
string? plainPassword = null;
if (hasPassword)
{
plainPassword = _credentialCipher.DecryptPassword(input.Password!, input.PasswordSalt!);
}
var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant);
using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType);
TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, input.TenantId);
var adminUsers = await tenantDb.Queryable()
.Where(u => u.UserName == currentLoginAccount && !u.IsDeleted)
.Take(1)
.ToListAsync();
var adminUser = adminUsers.FirstOrDefault()
?? throw new UserFriendlyException("未找到租户管理员账号");
if (!string.Equals(newLoginAccount, currentLoginAccount, StringComparison.OrdinalIgnoreCase))
{
if (await tenantDb.Queryable().AnyAsync(u =>
u.UserName == newLoginAccount && u.Id != adminUser.Id && !u.IsDeleted))
{
throw new UserFriendlyException(UserConst.Exist);
}
await tenantDb.Updateable()
.SetColumns(u => u.UserName == newLoginAccount)
.Where(u => u.Id == adminUser.Id)
.ExecuteCommandAsync();
}
if (plainPassword != null)
{
UserPasswordHelper.ApplyPlainPassword(adminUser, plainPassword);
await tenantDb.Updateable()
.SetColumns(u => u.EncryPassword.Password == adminUser.EncryPassword.Password)
.SetColumns(u => u.EncryPassword.Salt == adminUser.EncryPassword.Salt)
.Where(u => u.Id == adminUser.Id)
.ExecuteCommandAsync();
}
await UpsertCredentialAsync(
input.TenantId,
newLoginAccount,
passwordChanged: plainPassword != null,
plainPassword,
existingCredential);
}
///
/// 获取 SaaS 菜单权限树
///
///
/// 供平台管理员配置租户菜单权限时使用;Key 与前端 saas-menu-tree 一致。
///
/// 示例响应:
/// ```json
/// [
/// {
/// "key": "dashboard",
/// "title": "仪表盘",
/// "children": [
/// { "key": "dashboard:analytics", "title": "数据分析" }
/// ]
/// }
/// ]
/// ```
///
/// SaaS 菜单权限树
/// 成功返回权限树
/// 未登录
/// 服务器错误
[Authorize]
[HttpGet("th-multi-tenancy/menu-permission-tree")]
public virtual Task> GetMenuPermissionTreeAsync()
{
return Task.FromResult(CloneTree(ThSaasMenuPermissionCatalog.Tree));
}
///
/// 获取公司(租户)已分配的 SaaS 菜单权限
///
///
/// 需登录;读取主库 fl_th_tenant_menu_permission。
///
/// 示例请求(GET,query/data):
/// ```json
/// { "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }
/// ```
///
/// 示例响应:
/// ```json
/// {
/// "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
/// "menuPermissionKeys": ["dashboard", "labeling:labels"]
/// }
/// ```
///
/// 参数说明:
/// - tenantId: 租户 Id(必填)
///
/// 租户 Id
/// 租户菜单权限
/// 成功返回菜单权限
/// 租户 Id 无效
/// 未登录
/// 租户不存在
/// 服务器错误
[Authorize]
[HttpGet("th-multi-tenancy/company-menus")]
public virtual async Task GetCompanyMenusAsync([FromQuery] Guid tenantId)
{
await EnsureTenantExistsAsync(tenantId);
var keys = await LoadMenuPermissionKeysAsync(tenantId);
return new ThCompanyMenusDto
{
TenantId = tenantId,
MenuPermissionKeys = keys
};
}
///
/// 覆盖式设置公司(租户)SaaS 菜单权限
///
///
/// 需登录;写入主库 fl_th_tenant_menu_permission(先删后插)。非法 permission key 将报错。
///
/// 示例请求:
/// ```json
/// {
/// "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
/// "menuPermissionKeys": ["dashboard", "labeling:labels"]
/// }
/// ```
///
/// 参数说明:
/// - tenantId: 租户 Id(必填)
/// - menuPermissionKeys: 菜单权限 Key 列表(覆盖式,可为空表示清空)
///
/// 租户 Id 与菜单权限 Key 列表
/// 无内容
/// 设置成功
/// 参数错误或存在非法 permission key
/// 未登录
/// 租户不存在
/// 服务器错误
[Authorize]
[HttpPut("th-multi-tenancy/company-menus")]
[UnitOfWork]
public virtual async Task UpdateCompanyMenusAsync([FromBody] ThUpdateCompanyMenusInputVo input)
{
if (input.TenantId == Guid.Empty)
{
throw new UserFriendlyException("租户 Id 不能为空");
}
await EnsureTenantExistsAsync(input.TenantId);
var normalizedKeys = NormalizePermissionKeys(input.MenuPermissionKeys);
var invalidKeys = ThSaasMenuPermissionCatalog.FindInvalidKeys(normalizedKeys);
if (invalidKeys.Count > 0)
{
throw new UserFriendlyException($"存在非法菜单权限 Key:{string.Join(", ", invalidKeys)}");
}
using (UseHostTenantScope())
{
await _menuPermissionRepository.DeleteAsync(x => x.TenantId == input.TenantId);
if (normalizedKeys.Count == 0)
{
return;
}
var now = DateTime.Now;
var entities = normalizedKeys.Select(key => new ThTenantMenuPermissionEntity
{
Id = YitIdHelper.NextId().ToString(),
TenantId = input.TenantId,
PermissionKey = key,
CreationTime = now
}).ToList();
await _menuPermissionRepository.InsertRangeAsync(entities);
}
}
///
/// 获取租户内角色及已绑定菜单 Id
///
///
/// 需登录;在指定租户业务库查询 Role / RoleMenu。
///
/// 示例请求(GET,query/data):
/// ```json
/// { "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6" }
/// ```
///
/// 示例响应:
/// ```json
/// [
/// {
/// "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
/// "roleName": "管理员",
/// "roleCode": "admin",
/// "state": true,
/// "menuIds": ["11111111-1111-1111-1111-111111111111"]
/// }
/// ]
/// ```
///
/// 参数说明:
/// - tenantId: 租户 Id(必填)
///
/// 租户 Id
/// 角色及菜单绑定列表
/// 成功返回角色列表
/// 租户 Id 无效
/// 未登录
/// 租户不存在
/// 服务器错误
[Authorize]
[HttpGet("th-multi-tenancy/company-roles")]
public virtual async Task> GetCompanyRolesAsync([FromQuery] Guid tenantId)
{
var tenant = await EnsureTenantExistsAsync(tenantId);
var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant);
using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType);
TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, tenantId);
var roles = await tenantDb.Queryable()
.Where(r => !r.IsDeleted)
.OrderBy(r => r.OrderNum)
.ToListAsync();
if (roles.Count == 0)
{
return new List();
}
var roleIds = roles.Select(r => r.Id).ToList();
var roleMenuLinks = await tenantDb.Queryable()
.Where(rm => roleIds.Contains(rm.RoleId))
.ToListAsync();
var menuIdsByRole = roleMenuLinks
.GroupBy(x => x.RoleId)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.MenuId.ToString()).ToList());
return roles.Select(role => new ThCompanyRoleItemDto
{
Id = role.Id,
RoleName = role.RoleName,
RoleCode = role.RoleCode,
State = role.State,
MenuIds = menuIdsByRole.TryGetValue(role.Id, out var menuIds)
? menuIds
: new List()
}).ToList();
}
///
/// 覆盖式设置租户内角色的菜单绑定
///
///
/// 需登录;在指定租户业务库覆盖写入 RoleMenu,逻辑对齐 RbacRoleMenuAppService.Set。
///
/// 示例请求:
/// ```json
/// {
/// "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
/// "roleId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
/// "menuIds": ["11111111-1111-1111-1111-111111111111"]
/// }
/// ```
///
/// 参数说明:
/// - tenantId: 租户 Id(必填)
/// - roleId: 角色 Id(必填)
/// - menuIds: 菜单 Id 列表(覆盖式,可为空表示清空)
///
/// 租户、角色与菜单 Id 列表
/// 无内容
/// 设置成功
/// 参数错误或菜单 Id 无效
/// 未登录
/// 租户或角色不存在
/// 服务器错误
[Authorize]
[HttpPut("th-multi-tenancy/company-role-menus")]
[UnitOfWork]
public virtual async Task UpdateCompanyRoleMenusAsync([FromBody] ThUpdateCompanyRoleMenusInputVo input)
{
if (input.TenantId == Guid.Empty)
{
throw new UserFriendlyException("租户 Id 不能为空");
}
if (input.RoleId == Guid.Empty)
{
throw new UserFriendlyException("角色 Id 不能为空");
}
var tenant = await EnsureTenantExistsAsync(input.TenantId);
var menuIds = ParseMenuIds(input.MenuIds);
var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant);
using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType);
TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, input.TenantId);
var roles = await tenantDb.Queryable()
.Where(x => x.Id == input.RoleId && !x.IsDeleted)
.Take(1)
.ToListAsync();
var role = roles.FirstOrDefault();
if (role is null)
{
throw new UserFriendlyException("角色不存在");
}
if (menuIds.Count == 0)
{
await tenantDb.Deleteable()
.Where(x => x.RoleId == input.RoleId)
.ExecuteCommandAsync();
return;
}
var existMenuIds = await tenantDb.Queryable()
.Where(x => !x.IsDeleted)
.Where(x => menuIds.Contains(x.Id))
.Select(x => x.Id)
.ToListAsync();
var missingMenuIds = menuIds.Except(existMenuIds).ToList();
if (missingMenuIds.Count > 0)
{
throw new UserFriendlyException(
$"存在无效或未删除的菜单 Id:{string.Join(", ", missingMenuIds)}");
}
await tenantDb.Deleteable()
.Where(x => x.RoleId == input.RoleId)
.ExecuteCommandAsync();
var entities = existMenuIds.Select(menuId =>
{
var entity = new RoleMenuEntity
{
RoleId = input.RoleId,
MenuId = menuId
};
EntityHelper.TrySetId(entity, () => GuidGenerator.Create());
return entity;
}).ToList();
if (entities.Count > 0)
{
await tenantDb.Insertable(entities).ExecuteCommandAsync();
}
}
///
/// 删除公司(租户),可选 DROP 对应业务库
///
///
/// 需登录。在主库 scope 执行:校验非 Default 租户后,按 dropDatabase 决定是否 DROP 业务库;
/// 删除 YiTenant(软删)及 fl_th_tenant_admin_credential、fl_th_tenant_menu_permission 附属记录,并清理租户缓存。
///
/// 受保护规则:禁止删除 Name=Default 或 Id=11111111-1111-1111-1111-111111111111;
/// 禁止 DROP antis-foodlabeling-host、antis-foodlabeling-us;
/// 连接串为空或库名解析/校验失败时仅删主库记录,不 DROP。
///
/// 示例请求(DELETE):
/// ```
/// DELETE /api/app/th-multi-tenancy/company/3a227b24-3d9b-ea2d-81f3-6c4fedccb1d2?dropDatabase=true
/// ```
///
/// 示例响应:
/// ```json
/// {
/// "tenantId": "3a227b24-3d9b-ea2d-81f3-6c4fedccb1d2",
/// "name": "中国麦当劳公司",
/// "databaseName": "antis-foodlabeling-t46d56528",
/// "databaseDropped": true,
/// "tenantDeleted": true,
/// "message": null
/// }
/// ```
///
/// 参数说明:
/// - tenantId: 租户 Id(路径参数,必填)
/// - dropDatabase: 是否 DROP 业务库,默认 true
///
/// 租户 Id
/// 是否 DROP 业务库
/// 删除执行结果
/// 删除成功或部分跳过 DROP 时返回结果
/// 租户 Id 无效或受保护租户
/// 未登录
/// 租户不存在
/// DROP 或删除失败
[Authorize]
[HttpDelete("th-multi-tenancy/company/{tenantId}")]
[UnitOfWork]
public virtual async Task DeleteCompanyAsync(
[FromRoute] Guid tenantId,
[FromQuery] bool dropDatabase = true)
{
if (tenantId == Guid.Empty)
{
throw new UserFriendlyException("租户 Id 不能为空");
}
EnsureTenantDeletable(tenantId, null);
TenantAggregateRoot tenant;
using (UseHostTenantScope())
{
tenant = await _tenantRepository.FindAsync(tenantId)
?? throw new UserFriendlyException("租户不存在");
}
EnsureTenantDeletable(tenant.Id, tenant.Name);
var databaseName = ExtractConnectionValue(tenant.TenantConnectionString, "database");
var result = new ThDeleteCompanyResultDto
{
TenantId = tenant.Id,
Name = tenant.Name,
DatabaseName = databaseName
};
if (dropDatabase)
{
var dropDecision = EvaluateDatabaseDrop(databaseName);
if (dropDecision.CanDrop)
{
ExecuteDropDatabase(tenant.DbType, dropDecision.PrivilegedConnectionString!, databaseName!);
result.DatabaseDropped = true;
}
else
{
result.Message = dropDecision.SkipReason;
}
}
else
{
result.Message = "dropDatabase=false,已跳过 DROP DATABASE";
}
await _tenantService.DeleteAsync(new[] { tenantId });
result.TenantDeleted = true;
using (UseHostTenantScope())
{
await _credentialRepository._Db.Ado.ExecuteCommandAsync(
"DELETE FROM `fl_th_tenant_admin_credential` WHERE `TenantId` = @TenantId",
new List { new("@TenantId", tenantId.ToString()) });
await _menuPermissionRepository.DeleteAsync(x => x.TenantId == tenantId);
}
await RemoveTenantCacheAsync(tenant.Id, tenant.Name);
return result;
}
///
public virtual Task GetCurrentTenantAsync()
{
return Task.FromResult(new ThCurrentTenantDto
{
TenantId = CurrentTenant.Id,
TenantName = CurrentTenant.Name
});
}
private async Task> LoadCredentialMapAsync(
IReadOnlyCollection tenantIds)
{
if (tenantIds.Count == 0)
{
return new Dictionary();
}
using (UseHostTenantScope())
{
var list = await _credentialRepository._DbQueryable
.Where(c => tenantIds.Contains(c.Id))
.ToListAsync();
return list.ToDictionary(c => c.Id);
}
}
private (string LoginAccount, string Password, string PasswordSalt) ResolveEncryptedCredential(
ThTenantAdminCredentialEntity? credential)
{
if (credential != null
&& !string.IsNullOrEmpty(credential.PasswordCipher)
&& !string.IsNullOrEmpty(credential.PasswordIv))
{
var loginAccount = string.IsNullOrWhiteSpace(credential.LoginAccount)
? ThTenantSelectConsts.DefaultAdminLoginAccount
: credential.LoginAccount;
return (loginAccount, credential.PasswordCipher, credential.PasswordIv);
}
var plainPassword = _rbacOptions.AdminPassword ?? string.Empty;
var encrypted = _credentialCipher.EncryptPassword(plainPassword);
var account = string.IsNullOrWhiteSpace(credential?.LoginAccount)
? ThTenantSelectConsts.DefaultAdminLoginAccount
: credential!.LoginAccount;
return (account, encrypted.Password, encrypted.PasswordSalt);
}
private async Task UpsertCredentialAsync(
Guid tenantId,
string loginAccount,
bool passwordChanged,
string? plainPassword,
ThTenantAdminCredentialEntity? existingCredential)
{
string passwordCipher;
string passwordIv;
if (passwordChanged)
{
var encrypted = _credentialCipher.EncryptPassword(plainPassword!);
passwordCipher = encrypted.Password;
passwordIv = encrypted.PasswordSalt;
}
else if (existingCredential != null
&& !string.IsNullOrEmpty(existingCredential.PasswordCipher)
&& !string.IsNullOrEmpty(existingCredential.PasswordIv))
{
passwordCipher = existingCredential.PasswordCipher;
passwordIv = existingCredential.PasswordIv;
}
else
{
var encrypted = _credentialCipher.EncryptPassword(_rbacOptions.AdminPassword ?? string.Empty);
passwordCipher = encrypted.Password;
passwordIv = encrypted.PasswordSalt;
}
using (UseHostTenantScope())
{
var now = DateTime.Now;
// 使用 Ado 参数化写入,避免实体映射差异导致 Insertable NRE
if (existingCredential == null)
{
await _credentialRepository._Db.Ado.ExecuteCommandAsync(
@"INSERT INTO `fl_th_tenant_admin_credential`
(`TenantId`, `LoginAccount`, `PasswordCipher`, `PasswordIv`, `LastModificationTime`)
VALUES (@TenantId, @LoginAccount, @PasswordCipher, @PasswordIv, @LastModificationTime)",
new List
{
new("@TenantId", tenantId.ToString()),
new("@LoginAccount", loginAccount),
new("@PasswordCipher", passwordCipher),
new("@PasswordIv", passwordIv),
new("@LastModificationTime", now)
});
return;
}
await _credentialRepository._Db.Ado.ExecuteCommandAsync(
@"UPDATE `fl_th_tenant_admin_credential`
SET `LoginAccount` = @LoginAccount,
`PasswordCipher` = @PasswordCipher,
`PasswordIv` = @PasswordIv,
`LastModificationTime` = @LastModificationTime
WHERE `TenantId` = @TenantId",
new List
{
new("@TenantId", tenantId.ToString()),
new("@LoginAccount", loginAccount),
new("@PasswordCipher", passwordCipher),
new("@PasswordIv", passwordIv),
new("@LastModificationTime", now)
});
}
}
private async Task EnsureTenantExistsAsync(Guid tenantId)
{
if (tenantId == Guid.Empty)
{
throw new UserFriendlyException("租户 Id 不能为空");
}
using (UseHostTenantScope())
{
return await _tenantRepository.FindAsync(tenantId)
?? throw new UserFriendlyException("租户不存在");
}
}
///
/// 解析租户业务库连接串(必要时按配置回填主库 YiTenant),供直连 SqlSugar 使用。
///
private Task<(string ConnectionString, DbType DbType)> ResolveTenantBusinessConnectionAsync(
TenantAggregateRoot tenant)
{
return TenantBusinessDatabaseAccessor.ResolveConnectionAsync(
tenant,
_tenantDatabaseOptions,
PersistTenantConnectionStringAsync);
}
private async Task PersistTenantConnectionStringAsync(TenantAggregateRoot tenant)
{
using (UseHostTenantScope())
{
await _tenantRepository.UpdateAsync(tenant);
}
}
private async Task> LoadMenuPermissionKeysAsync(Guid tenantId)
{
using (UseHostTenantScope())
{
return await _menuPermissionRepository._DbQueryable
.Where(x => x.TenantId == tenantId)
.OrderBy(x => x.CreationTime)
.Select(x => x.PermissionKey)
.ToListAsync();
}
}
private async Task>> LoadMenuPermissionMapAsync(
IReadOnlyCollection tenantIds)
{
if (tenantIds.Count == 0)
{
return new Dictionary>();
}
using (UseHostTenantScope())
{
var list = await _menuPermissionRepository._DbQueryable
.Where(x => tenantIds.Contains(x.TenantId))
.ToListAsync();
return list
.GroupBy(x => x.TenantId)
.ToDictionary(
g => g.Key,
g => g.Select(x => x.PermissionKey).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
}
}
private static List NormalizePermissionKeys(IEnumerable? keys)
{
if (keys == null)
{
return new List();
}
return keys
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static List ParseMenuIds(IEnumerable? menuIds)
{
if (menuIds == null)
{
return new List();
}
var result = new List();
var invalid = new List();
foreach (var raw in menuIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct())
{
if (Guid.TryParse(raw, out var id))
{
result.Add(id);
}
else
{
invalid.Add(raw);
}
}
if (invalid.Count > 0)
{
throw new UserFriendlyException($"菜单 Id 格式无效:{string.Join(", ", invalid)}");
}
return result;
}
private static List CloneTree(
IEnumerable nodes)
{
return nodes.Select(node => new ThSaasMenuPermissionTreeNodeDto
{
Key = node.Key,
Title = node.Title,
Children = node.Children == null ? null : CloneTree(node.Children)
}).ToList();
}
private static void EnsureTenantDeletable(Guid tenantId, string? tenantName)
{
if (tenantId == ProtectedDefaultTenantId)
{
throw new UserFriendlyException("禁止删除 Default 租户");
}
if (string.Equals(tenantName, ProtectedDefaultTenantName, StringComparison.Ordinal))
{
throw new UserFriendlyException("禁止删除 Default 租户");
}
}
private (bool CanDrop, string? PrivilegedConnectionString, string? SkipReason) EvaluateDatabaseDrop(
string? databaseName)
{
if (string.IsNullOrWhiteSpace(databaseName))
{
return (false, null, "连接串为空或库名解析失败,已跳过 DROP DATABASE,仅删除主库记录");
}
if (!SafeDatabaseNameRegex.IsMatch(databaseName))
{
return (false, null, "库名格式不符合安全规则,已跳过 DROP DATABASE,仅删除主库记录");
}
if (ProtectedDatabaseNames.Contains(databaseName))
{
return (false, null, $"库名 {databaseName} 为受保护库,已跳过 DROP DATABASE,仅删除主库记录");
}
var privilegedConnectionString = ResolvePrivilegedConnectionString();
if (string.IsNullOrWhiteSpace(privilegedConnectionString))
{
return (false, null, "未配置高权限连接串,已跳过 DROP DATABASE,仅删除主库记录");
}
return (true, privilegedConnectionString, null);
}
private string? ResolvePrivilegedConnectionString()
{
if (!string.IsNullOrWhiteSpace(_dbConnOptions.AdminConnectionString))
{
return _dbConnOptions.AdminConnectionString;
}
return _dbConnOptions.Url;
}
private static void ExecuteDropDatabase(DbType dbType, string connectionString, string databaseName)
{
using var db = new SqlSugarClient(new ConnectionConfig
{
ConfigId = $"tenant-drop-db-{databaseName}-{Guid.NewGuid():N}",
DbType = dbType,
ConnectionString = connectionString,
IsAutoCloseConnection = true
});
db.Ado.ExecuteCommand($"DROP DATABASE IF EXISTS `{databaseName}`");
}
private async Task RemoveTenantCacheAsync(Guid tenantId, string tenantName)
{
await _tenantCache.RemoveAsync(TenantCacheItem.CalculateCacheKey(tenantId, null!));
if (!string.IsNullOrWhiteSpace(tenantName))
{
await _tenantCache.RemoveAsync(TenantCacheItem.CalculateCacheKey(null, tenantName));
}
}
private static string? ExtractConnectionValue(string connectionString, string key)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
return null;
}
var prefix = key + "=";
foreach (var segment in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
var part = segment.Trim();
if (part.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return part[prefix.Length..].Trim();
}
}
return null;
}
}