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; } }