From e41dfa098fa19322bcdc42c4c488d26b7dd51a6c Mon Sep 17 00:00:00 2001 From: 李曜臣 Date: Wed, 22 Jul 2026 11:33:07 +0800 Subject: [PATCH] 泰鄂版菜单;租户列表 --- 泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/TenantConfigurationWrapper.cs | 27 +++++++++++++++++++++++++-- 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs | 8 ++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListInput.cs | 15 +++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListItemDto.cs | 28 ++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyMenusDto.cs | 13 +++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyRoleItemDto.cs | 22 ++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThDeleteCompanyResultDto.cs | 25 +++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs | 16 ++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyAdminInputVo.cs | 19 +++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs | 13 +++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyRoleMenusInputVo.cs | 16 ++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuCreateInputVo.cs | 36 ++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListInputVo.cs | 30 ++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListOutputDto.cs | 29 +++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuTreeDto.cs | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuUpdateInputVo.cs | 8 ++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/FoodLabeling.Th.Application.Contracts.csproj | 1 + 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThRbacMenuAppService.cs | 23 +++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantSelectCredentialCipher.cs | 31 +++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs | 5 +++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs | 1050 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------- 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs | 441 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs | 13 ++++++++++++- 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain.Shared/Helpers/YitIdHelper.cs | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantAdminCredentialEntity.cs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantMenuPermissionEntity.cs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs | 31 +++++++++++++++++++++++++------ 泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json | 2 +- 美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs | 8 ++++++++ 美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- 美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json | 2 +- 项目相关文档/2026-07-21代码优化.md | 420 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 37 files changed, 3068 insertions(+), 97 deletions(-) create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListInput.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListItemDto.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyMenusDto.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyRoleItemDto.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThDeleteCompanyResultDto.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyAdminInputVo.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyRoleMenusInputVo.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuCreateInputVo.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListInputVo.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListOutputDto.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuTreeDto.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuUpdateInputVo.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThRbacMenuAppService.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain.Shared/Helpers/YitIdHelper.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantAdminCredentialEntity.cs create mode 100644 泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantMenuPermissionEntity.cs create mode 100644 项目相关文档/2026-07-21代码优化.md diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/TenantConfigurationWrapper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/TenantConfigurationWrapper.cs index e4590fa..fa87991 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/TenantConfigurationWrapper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/TenantConfigurationWrapper.cs @@ -67,8 +67,31 @@ public class TenantConfigurationWrapper : ITransientDependency return config; } - // 返回默认配置 - return await TenantStoreService.FindAsync(ConnectionStrings.DefaultConnectionStringName); + // 无租户上下文 = 平台主库(DbConnOptions.Url),不查库内名为 Default 的租户记录 + return CreateHostTenantConfiguration(); + } + + /// + /// 平台主库租户配置(CurrentTenant 为空时使用 DbConnOptions.Url)。 + /// + private TenantConfiguration CreateHostTenantConfiguration() + { + if (string.IsNullOrWhiteSpace(DbConnectionOptions.Url)) + { + throw new ApplicationException("未配置主库连接字符串 DbConnOptions.Url"); + } + + return new TenantConfiguration + { + Id = Guid.Empty, + Name = ConnectionStrings.DefaultConnectionStringName, + NormalizedName = ConnectionStrings.DefaultConnectionStringName, + ConnectionStrings = new ConnectionStrings + { + { ConnectionStrings.DefaultConnectionStringName, DbConnectionOptions.Url } + }, + IsActive = true + }; } /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs index 2444faf..1ada7bf 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs @@ -1,6 +1,8 @@ using FoodLabeling.Application.Contracts; +using FoodLabeling.Application.Filters; using FoodLabeling.Application.Options; using FoodLabeling.Domain; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Modularity; using Yi.Framework.Ddd.Application; @@ -21,6 +23,12 @@ public class FoodLabelingApplicationModule : AbpModule { Configure( context.Services.GetConfiguration().GetSection(FoodLabelingBatchImportOptions.SectionName)); + + // 任意业务写接口成功后刷新系统编辑时间戳,供 my-menus.lastUpdated 使用 + Configure(options => + { + options.Filters.AddService(); + }); } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs index 169619b..703ddaa 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs @@ -21,17 +21,20 @@ namespace FoodLabeling.Application.Services; public class AuthSessionAppService : ApplicationService, IAuthSessionAppService { private readonly IDistributedCache _userCache; + private readonly IDistributedCache _systemEditStampCache; private readonly ISqlSugarDbContext _dbContext; private readonly ISqlSugarRepository _userRepository; public AuthSessionAppService( ISqlSugarDbContext dbContext, ISqlSugarRepository userRepository, - IDistributedCache userCache) + IDistributedCache userCache, + IDistributedCache systemEditStampCache) { _dbContext = dbContext; _userRepository = userRepository; _userCache = userCache; + _systemEditStampCache = systemEditStampCache; } /// @@ -110,6 +113,12 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService .OrderBy(x => x) .ToList(); + // 优先系统编辑全局时间戳(任意写接口成功联动);无戳时回退「业务表最近修改」再回退用户时间 + var systemEditAt = await SystemEditStampCacheHelper.GetAsync(_systemEditStampCache); + var lastUpdated = systemEditAt + ?? await ResolveRecentBusinessEditTimeAsync() + ?? user.LastModificationTime; + return new CurrentUserMenuPermissionsOutputDto { User = new CurrentUserBriefDto @@ -124,12 +133,49 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService PermissionCodes = permissionCodes, AccessPermissionCodes = accessPermissionCodes, Menus = BuildMenuTree(menuNodes), - LastUpdated = user.LastModificationTime, + LastUpdated = lastUpdated, Role = roleDisplay, FullName = BuildFullName(user) }; } + /// + /// 缓存未命中时,取主要业务表最近修改时间,避免 Last Updated 长期停在用户资料时间。 + /// + private async Task ResolveRecentBusinessEditTimeAsync() + { + var db = _dbContext.SqlSugarClient; + DateTime? max = null; + + async Task ConsiderAsync(Task query) + { + try + { + var t = await query; + if (t.HasValue && (!max.HasValue || t.Value > max.Value)) + { + max = t; + } + } + catch + { + // 表/列缺失时忽略,继续其它表 + } + } + + await ConsiderAsync(db.Queryable() + .Where(x => !x.IsDeleted) + .MaxAsync(x => (DateTime?)(x.LastModificationTime ?? x.CreationTime))); + await ConsiderAsync(db.Queryable() + .Where(x => !x.IsDeleted) + .MaxAsync(x => (DateTime?)(x.LastModificationTime ?? x.CreationTime))); + await ConsiderAsync(db.Queryable() + .Where(x => !x.IsDeleted) + .MaxAsync(x => (DateTime?)(x.LastModificationTime ?? x.CreationTime))); + + return max; + } + /// [HttpPost] public virtual async Task LogoutAsync() diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListInput.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListInput.cs new file mode 100644 index 0000000..1cd5e50 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListInput.cs @@ -0,0 +1,15 @@ +using Yi.Framework.Ddd.Application.Contracts; + +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// 平台公司(租户)列表分页查询 +/// +public class ThCompanyListInput : PagedAllResultRequestDto +{ + /// 名称关键字(模糊匹配租户名) + public string? Keyword { get; set; } + + /// 租户名称(与 Keyword 等效,兼容旧参) + public string? Name { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListItemDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListItemDto.cs new file mode 100644 index 0000000..cdafd02 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListItemDto.cs @@ -0,0 +1,28 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// 平台公司(租户)列表项(含可回显加密凭据) +/// +public class ThCompanyListItemDto +{ + /// 租户 Id + public Guid Id { get; set; } + + /// 租户名称 + public string Name { get; set; } = string.Empty; + + /// 租户创建时间 + public DateTime CreationTime { get; set; } + + /// 管理员登录账号 + public string LoginAccount { get; set; } = string.Empty; + + /// AES-CBC 加密后的管理员密码(Base64 密文) + public string Password { get; set; } = string.Empty; + + /// AES IV(Base64),前端解密 password 时使用 + public string PasswordSalt { get; set; } = string.Empty; + + /// 已分配的 SaaS 菜单权限 Key 列表 + public List MenuPermissionKeys { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyMenusDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyMenusDto.cs new file mode 100644 index 0000000..60f52a8 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyMenusDto.cs @@ -0,0 +1,13 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// 公司(租户)已分配的 SaaS 菜单权限 +/// +public class ThCompanyMenusDto +{ + /// 租户 Id + public Guid TenantId { get; set; } + + /// 已分配的菜单权限 Key 列表 + public List MenuPermissionKeys { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyRoleItemDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyRoleItemDto.cs new file mode 100644 index 0000000..6d7414b --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyRoleItemDto.cs @@ -0,0 +1,22 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// 租户内角色及已绑定菜单 Id +/// +public class ThCompanyRoleItemDto +{ + /// 角色 Id + public Guid Id { get; set; } + + /// 角色名称 + public string RoleName { get; set; } = string.Empty; + + /// 角色编码 + public string RoleCode { get; set; } = string.Empty; + + /// 启用状态 + public bool State { get; set; } + + /// 已绑定菜单 Id 列表 + public List MenuIds { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThDeleteCompanyResultDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThDeleteCompanyResultDto.cs new file mode 100644 index 0000000..3dde10e --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThDeleteCompanyResultDto.cs @@ -0,0 +1,25 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// 删除公司(租户)及可选 DROP 业务库的执行结果 +/// +public class ThDeleteCompanyResultDto +{ + /// 租户 Id + public Guid TenantId { get; set; } + + /// 租户名称 + public string Name { get; set; } = string.Empty; + + /// 从连接串解析出的业务库名;解析失败时为 null + public string? DatabaseName { get; set; } + + /// 是否已成功执行 DROP DATABASE + public bool DatabaseDropped { get; set; } + + /// 是否已删除 YiTenant 记录(软删) + public bool TenantDeleted { get; set; } + + /// 操作说明(如跳过 DROP 的原因) + public string? Message { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs new file mode 100644 index 0000000..078acbc --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs @@ -0,0 +1,16 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// SaaS 菜单权限树节点 +/// +public class ThSaasMenuPermissionTreeNodeDto +{ + /// 权限 Key(如 labeling:labels) + public string Key { get; set; } = string.Empty; + + /// 中文标题 + public string Title { get; set; } = string.Empty; + + /// 子节点 + public List? Children { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyAdminInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyAdminInputVo.cs new file mode 100644 index 0000000..84698fa --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyAdminInputVo.cs @@ -0,0 +1,19 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// 修改公司(租户)默认管理员账号/密码 +/// +public class ThUpdateCompanyAdminInputVo +{ + /// 租户 Id + public Guid TenantId { get; set; } + + /// 新登录账号(可选,空则不改) + public string? LoginAccount { get; set; } + + /// AES 加密后的新密码(可选,空则不改) + public string? Password { get; set; } + + /// AES IV(修改密码时必填) + public string? PasswordSalt { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs new file mode 100644 index 0000000..0ee4d6d --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs @@ -0,0 +1,13 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// 覆盖式设置公司(租户)SaaS 菜单权限 +/// +public class ThUpdateCompanyMenusInputVo +{ + /// 租户 Id + public Guid TenantId { get; set; } + + /// 菜单权限 Key 列表(覆盖式) + public List MenuPermissionKeys { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyRoleMenusInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyRoleMenusInputVo.cs new file mode 100644 index 0000000..4e259d2 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyRoleMenusInputVo.cs @@ -0,0 +1,16 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +/// +/// 覆盖式设置租户内角色的菜单绑定 +/// +public class ThUpdateCompanyRoleMenusInputVo +{ + /// 租户 Id + public Guid TenantId { get; set; } + + /// 角色 Id + public Guid RoleId { get; set; } + + /// 菜单 Id 列表(覆盖式) + public List MenuIds { get; set; } = new(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuCreateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuCreateInputVo.cs new file mode 100644 index 0000000..5a10b35 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuCreateInputVo.cs @@ -0,0 +1,36 @@ +using Yi.Framework.Rbac.Domain.Shared.Enums; + +namespace FoodLabeling.Th.Application.Contracts.Dtos.RbacMenu; + +/// +/// 泰额版新增菜单入参 +/// +public class ThRbacMenuCreateInputVo +{ + public string MenuName { get; set; } = string.Empty; + + /// + /// 父级 Id(menu 表为字符串;根节点为 0) + /// + public string ParentId { get; set; } = "0"; + + public MenuTypeEnum MenuType { get; set; } + + public MenuSourceEnum MenuSource { get; set; } + + public string? PermissionCode { get; set; } + + public string? Router { get; set; } + + public string? Component { get; set; } + + public string? RouterName { get; set; } + + public string? MenuIcon { get; set; } + + public int OrderNum { get; set; } + + public bool State { get; set; } = true; + + public bool IsShow { get; set; } = true; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListInputVo.cs new file mode 100644 index 0000000..a454339 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListInputVo.cs @@ -0,0 +1,30 @@ +using Volo.Abp.Application.Dtos; +using Yi.Framework.Rbac.Domain.Shared.Enums; + +namespace FoodLabeling.Th.Application.Contracts.Dtos.RbacMenu; + +/// +/// 泰额版菜单分页查询入参 +/// +public class ThRbacMenuGetListInputVo : PagedAndSortedResultRequestDto +{ + /// + /// 菜单名称(模糊匹配) + /// + public string? MenuName { get; set; } + + /// + /// 启用状态 + /// + public bool? State { get; set; } + + /// + /// 菜单来源 + /// + public MenuSourceEnum? MenuSource { get; set; } + + /// + /// 菜单类型 + /// + public MenuTypeEnum? MenuType { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListOutputDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListOutputDto.cs new file mode 100644 index 0000000..931ed09 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuGetListOutputDto.cs @@ -0,0 +1,29 @@ +using Yi.Framework.Rbac.Domain.Shared.Enums; + +namespace FoodLabeling.Th.Application.Contracts.Dtos.RbacMenu; + +/// +/// 泰额版菜单列表/详情输出 +/// +public class ThRbacMenuGetListOutputDto +{ + public string Id { get; set; } = string.Empty; + + public string ParentId { get; set; } = string.Empty; + + public string MenuName { get; set; } = string.Empty; + + public string? RouterName { get; set; } + + public string? Router { get; set; } + + public string? PermissionCode { get; set; } + + public MenuTypeEnum MenuType { get; set; } + + public MenuSourceEnum MenuSource { get; set; } + + public int OrderNum { get; set; } + + public bool State { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuTreeDto.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuTreeDto.cs new file mode 100644 index 0000000..15fa51c --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuTreeDto.cs @@ -0,0 +1,57 @@ +using Yi.Framework.Rbac.Domain.Shared.Enums; + +namespace FoodLabeling.Th.Application.Contracts.Dtos.RbacMenu; + +/// +/// 泰额版菜单树节点(含 menu 表主要字段) +/// +public class ThRbacMenuTreeDto +{ + public string Id { get; set; } = string.Empty; + + public bool IsDeleted { get; set; } + + public DateTime CreationTime { get; set; } + + public string? CreatorId { get; set; } + + public string? LastModifierId { get; set; } + + public DateTime? LastModificationTime { get; set; } + + public int OrderNum { get; set; } + + public bool State { get; set; } + + public string MenuName { get; set; } = string.Empty; + + public string? RouterName { get; set; } + + public MenuTypeEnum MenuType { get; set; } + + public string? PermissionCode { get; set; } + + public string ParentId { get; set; } = string.Empty; + + public string? MenuIcon { get; set; } + + public string? Router { get; set; } + + public bool IsLink { get; set; } + + public bool IsCache { get; set; } + + public bool IsShow { get; set; } + + public string? Remark { get; set; } + + public string? Component { get; set; } + + public MenuSourceEnum MenuSource { get; set; } + + public string? Query { get; set; } + + public string? ConcurrencyStamp { get; set; } + + public List? Children { get; set; } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuUpdateInputVo.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuUpdateInputVo.cs new file mode 100644 index 0000000..a6897fc --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenuUpdateInputVo.cs @@ -0,0 +1,8 @@ +namespace FoodLabeling.Th.Application.Contracts.Dtos.RbacMenu; + +/// +/// 泰额版编辑菜单入参 +/// +public class ThRbacMenuUpdateInputVo : ThRbacMenuCreateInputVo +{ +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/FoodLabeling.Th.Application.Contracts.csproj b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/FoodLabeling.Th.Application.Contracts.csproj index adda440..cd82de6 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/FoodLabeling.Th.Application.Contracts.csproj +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/FoodLabeling.Th.Application.Contracts.csproj @@ -4,6 +4,7 @@ + diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs index 9faf152..0a956aa 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs @@ -1,20 +1,122 @@ using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +using Volo.Abp.Application.Dtos; + using Volo.Abp.Application.Services; + + namespace FoodLabeling.Th.Application.Contracts.IServices; + + /// + /// 泰额版多租户辅助接口(租户列表、当前租户信息) + /// + public interface IThMultiTenancyAppService : IApplicationService + { + /// + /// 获取可选租户列表(平台管理员创建租户后供登录选择) + /// + Task> GetTenantSelectAsync(); + + /// + /// 获取当前请求上下文解析到的租户 Id + /// + Task GetCurrentTenantAsync(); + + + + /// + + /// 平台级管理员:分页获取公司(租户)列表及可回显加密凭据 + + /// + + Task> GetCompanyListAsync(ThCompanyListInput input); + + + + /// + + /// 平台级管理员:修改公司默认管理员账号/密码 + + /// + + Task UpdateCompanyAdminAsync(ThUpdateCompanyAdminInputVo input); + + + + /// + + /// 平台级管理员:获取 SaaS 菜单权限树 + + /// + + Task> GetMenuPermissionTreeAsync(); + + + + /// + + /// 平台级管理员:获取公司(租户)已分配菜单权限 + + /// + + Task GetCompanyMenusAsync(Guid tenantId); + + + + /// + + /// 平台级管理员:覆盖式设置公司(租户)菜单权限 + + /// + + Task UpdateCompanyMenusAsync(ThUpdateCompanyMenusInputVo input); + + + + /// + + /// 平台级管理员:获取租户内角色及已绑定菜单 + + /// + + Task> GetCompanyRolesAsync(Guid tenantId); + + + + /// + + /// 平台级管理员:覆盖式设置租户内角色菜单绑定 + + /// + + Task UpdateCompanyRoleMenusAsync(ThUpdateCompanyRoleMenusInputVo input); + + + + /// + + /// 平台级管理员:删除公司(租户),可选 DROP 对应业务库 + + /// + + Task DeleteCompanyAsync(Guid tenantId, bool dropDatabase = true); + } + diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThRbacMenuAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThRbacMenuAppService.cs new file mode 100644 index 0000000..6c81e5f --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThRbacMenuAppService.cs @@ -0,0 +1,23 @@ +using FoodLabeling.Th.Application.Contracts.Dtos.RbacMenu; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; + +namespace FoodLabeling.Th.Application.Contracts.IServices; + +/// +/// 泰额版租户业务库 menu 表 CRUD +/// +public interface IThRbacMenuAppService : IApplicationService +{ + Task> GetListAsync(ThRbacMenuGetListInputVo input); + + Task GetAsync(string id); + + Task CreateAsync(ThRbacMenuCreateInputVo input); + + Task UpdateAsync(string id, ThRbacMenuUpdateInputVo input); + + Task DeleteAsync(List ids); + + Task> GetTreeAsync(); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs new file mode 100644 index 0000000..0eecf72 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs @@ -0,0 +1,106 @@ +using FoodLabeling.Th.Application.Contracts.Options; +using SqlSugar; +using Volo.Abp; +using Yi.Framework.TenantManagement.Domain; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 跨租户操作业务库:解析/回填连接串并用独立 SqlSugarClient 直连,避免 CurrentTenant.Change 解析空连接串。 +/// +public static class TenantBusinessDatabaseAccessor +{ + private const string InitializeTenantDatabaseHintTemplate = + "请先调用 POST /api/app/th-tenant-provisioning/initialize-tenant-database/{0} 初始化业务库"; + + /// + /// 解析租户业务库连接串;为空时按配置回填并写回主库 YiTenant。 + /// + public static async Task<(string ConnectionString, DbType DbType)> ResolveConnectionAsync( + TenantAggregateRoot tenant, + FoodLabelingThTenantDatabaseOptions dbOptions, + Func persistTenantAsync) + { + var connectionString = tenant.TenantConnectionString?.Trim(); + if (!string.IsNullOrWhiteSpace(connectionString)) + { + return (connectionString, ResolveDbType(tenant.DbType)); + } + + connectionString = TenantDatabaseConnectionStringBuilder.BuildMySqlConnectionString( + dbOptions, + tenant.Name); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw BuildMissingConnectionStringException(tenant.Name); + } + + var dbType = DbType.MySql; + tenant.SetConnectionString(dbType, connectionString); + await persistTenantAsync(tenant); + + return (connectionString, dbType); + } + + /// + /// 创建直连租户业务库的 SqlSugar 客户端(调用方 using 释放)。 + /// + public static SqlSugarClient CreateClient(string connectionString, DbType dbType) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new UserFriendlyException("租户业务库连接串不能为空"); + } + + return new SqlSugarClient(new ConnectionConfig + { + ConfigId = $"th-tenant-direct-{Guid.NewGuid():N}", + DbType = dbType, + ConnectionString = connectionString, + IsAutoCloseConnection = true + }); + } + + /// + /// 探测业务库是否可连;库不存在时给出初始化引导。 + /// + public static void EnsureDatabaseReachable(ISqlSugarClient db, Guid tenantId) + { + try + { + db.Ado.ExecuteCommand("SELECT 1"); + } + catch (Exception ex) when (IsDatabaseNotExistError(ex)) + { + throw new UserFriendlyException( + string.Format(InitializeTenantDatabaseHintTemplate, tenantId)); + } + } + + public static UserFriendlyException BuildMissingConnectionStringException(string tenantName) + { + return new UserFriendlyException( + $"租户「{tenantName}」未配置业务库连接串,请先在租户管理完善连接串或调用开通/初始化接口"); + } + + private static DbType ResolveDbType(DbType tenantDbType) + { + return tenantDbType == default ? DbType.MySql : tenantDbType; + } + + private static bool IsDatabaseNotExistError(Exception ex) + { + var message = ex.Message; + var current = ex; + while (current.InnerException != null) + { + current = current.InnerException; + message += " " + current.Message; + } + + return message.Contains("Unknown database", StringComparison.OrdinalIgnoreCase) + || message.Contains("doesn't exist", StringComparison.OrdinalIgnoreCase) + || message.Contains("1049", StringComparison.Ordinal); + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs new file mode 100644 index 0000000..971f72b --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantDatabaseBootstrapper.cs @@ -0,0 +1,206 @@ +using System.Text.RegularExpressions; +using SqlSugar; +using Volo.Abp; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 租户业务库同步建库与 GRANT(建库候选优先级与 TenantService.CodeFirstForTenantAsync 一致)。 +/// +public static class TenantDatabaseBootstrapper +{ + private static readonly Regex SafeDatabaseNameRegex = + new(@"^[a-zA-Z0-9][a-zA-Z0-9_\-]{0,63}$", RegexOptions.Compiled); + + /// + /// 同步执行 CREATE DATABASE IF NOT EXISTS 并对业务账号 GRANT;失败抛 。 + /// + public static void EnsureDatabaseCreated( + DbConnOptions dbConnOptions, + DbType dbType, + string tenantConnectionString, + string databaseName) + { + if (string.IsNullOrWhiteSpace(databaseName) || !SafeDatabaseNameRegex.IsMatch(databaseName)) + { + throw new UserFriendlyException("租户连接串中的数据库名无效,无法建库"); + } + + var createCandidates = BuildCreateDatabaseCandidates(dbConnOptions, tenantConnectionString); + if (createCandidates.Count == 0) + { + throw new UserFriendlyException("未配置可用的数据库连接串,无法创建租户库"); + } + + var createErrors = new List(); + string? privilegedConnectionString = null; + foreach (var candidate in createCandidates) + { + try + { + ExecuteCreateDatabase(dbType, candidate.ConnectionString, databaseName); + privilegedConnectionString = candidate.ConnectionString; + break; + } + catch (Exception ex) + { + createErrors.Add($"{candidate.Label}: {GetRootMessage(ex)}"); + } + } + + if (privilegedConnectionString == null) + { + var userId = ExtractConnectionValue(tenantConnectionString, "uid") ?? "netteam"; + throw new UserFriendlyException( + $"创建租户库失败(库={databaseName})。尝试结果:{string.Join(" | ", createErrors)}。" + + $"说明:MySQL 报 Access denied to database 新建库名时,通常是账号没有 CREATE 权限(与能否连上主库无关)。" + + $"请在 appsettings 的 DbConnOptions.AdminConnectionString 配置高权限账号连接串后重试;或手动执行:" + + $"CREATE DATABASE IF NOT EXISTS `{databaseName}` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; " + + $"GRANT ALL PRIVILEGES ON `{databaseName}`.* TO '{userId}'@'%'; " + + $"再调用 initialize-tenant-database 补跑建表与 Seed"); + } + + TryGrantTenantDatabaseAccess( + dbType, + privilegedConnectionString, + databaseName, + tenantConnectionString); + } + + /// + /// 建库连接候选(按优先级,互不相同才尝试下一条)。 + /// + internal static List<(string Label, string ConnectionString)> BuildCreateDatabaseCandidates( + DbConnOptions dbConnOptions, + string tenantConnectionString) + { + var createCandidates = new List<(string Label, string ConnectionString)>(); + void AddCandidate(string label, string? conn) + { + if (string.IsNullOrWhiteSpace(conn)) + { + return; + } + + if (createCandidates.Any(x => + string.Equals(x.ConnectionString, conn, StringComparison.OrdinalIgnoreCase))) + { + return; + } + + createCandidates.Add((label, conn)); + } + + AddCandidate("AdminConnectionString", dbConnOptions.AdminConnectionString); + AddCandidate("DbConnOptions.Url(主库)", dbConnOptions.Url); + AddCandidate( + "实例级(无database)", + BuildServerLevelConnectionString( + !string.IsNullOrWhiteSpace(dbConnOptions.Url) + ? dbConnOptions.Url! + : tenantConnectionString)); + + return createCandidates; + } + + private static void ExecuteCreateDatabase(DbType dbType, string connectionString, string databaseName) + { + using var createDb = new SqlSugarClient(new ConnectionConfig + { + ConfigId = $"tenant-create-db-{databaseName}-{Guid.NewGuid():N}", + DbType = dbType, + ConnectionString = connectionString, + IsAutoCloseConnection = true + }); + + createDb.Ado.ExecuteCommand( + $"CREATE DATABASE IF NOT EXISTS `{databaseName}` " + + "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + } + + /// + /// 授权业务账号访问新租户库;失败不抛(建表阶段仍可用高权限连接)。 + /// + private static void TryGrantTenantDatabaseAccess( + DbType dbType, + string privilegedConnectionString, + string databaseName, + string tenantConnectionString) + { + var appUserId = ExtractConnectionValue(tenantConnectionString, "uid"); + var privilegedUserId = ExtractConnectionValue(privilegedConnectionString, "uid"); + if (string.IsNullOrWhiteSpace(appUserId)) + { + return; + } + + if (string.Equals(appUserId, privilegedUserId, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + using var grantDb = new SqlSugarClient(new ConnectionConfig + { + ConfigId = $"tenant-grant-{databaseName}-{Guid.NewGuid():N}", + DbType = dbType, + ConnectionString = privilegedConnectionString, + IsAutoCloseConnection = true + }); + + grantDb.Ado.ExecuteCommand( + $"GRANT ALL PRIVILEGES ON `{databaseName}`.* TO '{EscapeSqlLiteral(appUserId)}'@'%'"); + } + catch + { + // GRANT 失败不阻断:后续 Init 仍可用高权限连接建表 + } + } + + private static string BuildServerLevelConnectionString(string connectionString) + { + var segments = connectionString + .Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => !x.StartsWith("database=", StringComparison.OrdinalIgnoreCase) + && !x.StartsWith("initial catalog=", StringComparison.OrdinalIgnoreCase)) + .ToList(); + return string.Join(';', segments) + ";"; + } + + private static string GetRootMessage(Exception ex) + { + var current = ex; + while (current.InnerException != null) + { + current = current.InnerException; + } + + return current.Message; + } + + private static string EscapeSqlLiteral(string value) + => value.Replace("'", "''", StringComparison.Ordinal); + + 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; + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantSelectCredentialCipher.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantSelectCredentialCipher.cs index 2552d73..b0bc65a 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantSelectCredentialCipher.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/TenantSelectCredentialCipher.cs @@ -48,4 +48,35 @@ public class TenantSelectCredentialCipher : ITransientDependency return (Convert.ToBase64String(cipherBytes), Convert.ToBase64String(iv)); } + + /// + /// 解密前端传入的 AES-CBC 密码(Base64 密文 + Base64 IV) + /// + public string DecryptPassword(string password, string passwordSalt) + { + if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(passwordSalt)) + { + throw new UserFriendlyException("密码解密失败"); + } + + try + { + var cipherBytes = Convert.FromBase64String(password); + var iv = Convert.FromBase64String(passwordSalt); + + using var aes = Aes.Create(); + aes.Key = _key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var decryptor = aes.CreateDecryptor(); + var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); + return Encoding.UTF8.GetString(plainBytes); + } + catch (Exception ex) when (ex is not UserFriendlyException) + { + throw new UserFriendlyException("密码解密失败"); + } + } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs new file mode 100644 index 0000000..d2b1c16 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs @@ -0,0 +1,106 @@ +using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; + +namespace FoodLabeling.Th.Application.MultiTenancy; + +/// +/// 泰额版 SaaS 菜单权限目录(与前端 saas-menu-tree 一致) +/// +public static class ThSaasMenuPermissionCatalog +{ + private static readonly Lazy> TreeLazy = + new(BuildTree); + + private static readonly Lazy> AllKeysLazy = + new(() => new HashSet(CollectAllKeys(TreeLazy.Value), StringComparer.OrdinalIgnoreCase)); + + /// + /// 菜单权限树 + /// + public static IReadOnlyList Tree => TreeLazy.Value; + + /// + /// 全部合法 permission key(含父节点) + /// + public static IReadOnlySet AllKeys => AllKeysLazy.Value; + + /// + /// 校验 key 是否合法;返回非法 key 列表 + /// + public static List FindInvalidKeys(IEnumerable? keys) + { + if (keys == null) + { + return new List(); + } + + return keys + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(x => !AllKeys.Contains(x)) + .ToList(); + } + + private static IReadOnlyList BuildTree() => + new List + { + Node("dashboard", "仪表盘", Node("dashboard:analytics", "数据分析")), + Node( + "labeling", + "标签管理", + Node("labeling:labels", "标签列表"), + Node("labeling:categories", "标签分类"), + Node("labeling:types", "标签类型"), + Node("labeling:templates", "标签模板"), + Node("labeling:multiple-options", "多选项")), + Node( + "modules", + "功能模块", + Node("modules:training", "培训"), + Node("modules:alerts", "告警"), + Node("modules:tasks", "任务"), + Node("modules:food-waste", "食物浪费"), + Node("modules:e-label", "电子标签")), + Node( + "management", + "系统管理", + Node("management:account", "账号管理"), + Node("management:menu", "菜单管理"), + Node("management:devices", "设备管理"), + Node("management:reports", "报表"), + Node("management:invoices", "发票"), + Node("management:qr-codes", "二维码"), + Node("management:support", "支持"), + Node("management:api", "API")) + }; + + private static ThSaasMenuPermissionTreeNodeDto Node( + string key, + string title, + params ThSaasMenuPermissionTreeNodeDto[] children) + { + return new ThSaasMenuPermissionTreeNodeDto + { + Key = key, + Title = title, + Children = children.Length == 0 ? null : children.ToList() + }; + } + + private static IEnumerable CollectAllKeys(IEnumerable nodes) + { + foreach (var node in nodes) + { + yield return node.Key; + if (node.Children == null) + { + continue; + } + + foreach (var childKey in CollectAllKeys(node.Children)) + { + yield return childKey; + } + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs index 5a83be6..469d126 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantDatabaseBackgroundInitializer.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Volo.Abp.DependencyInjection; +using Volo.Abp.Uow; using Yi.Framework.TenantManagement.Application.Contracts; namespace FoodLabeling.Th.Application.MultiTenancy; @@ -43,8 +44,12 @@ public class ThTenantDatabaseBackgroundInitializer _logger.LogInformation("租户 {TenantId} 业务库后台初始化开始", tenantId); using var scope = _scopeFactory.CreateScope(); + var uowManager = scope.ServiceProvider.GetRequiredService(); var tenantService = scope.ServiceProvider.GetRequiredService(); + + using var uow = uowManager.Begin(requiresNew: true, isTransactional: false); await tenantService.InitAsync(tenantId); + await uow.CompleteAsync(); _logger.LogInformation("租户 {TenantId} 业务库后台初始化完成", tenantId); } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs index c82d751..4bcdb52 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs @@ -1,197 +1,1083 @@ +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 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!); + } - /// "name": "Default", + var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant); + using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); + TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, input.TenantId); - /// "creationTime": "2026-01-01T08:00:00", + var adminUsers = await tenantDb.Queryable() + .Where(u => u.UserName == currentLoginAccount && !u.IsDeleted) + .Take(1) + .ToListAsync(); + var adminUser = adminUsers.FirstOrDefault() + ?? throw new UserFriendlyException("未找到租户管理员账号"); - /// "loginAccount": "admin", + 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); + } - /// "password": "Base64CipherText", + await tenantDb.Updateable() + .SetColumns(u => u.UserName == newLoginAccount) + .Where(u => u.Id == adminUser.Id) + .ExecuteCommandAsync(); + } - /// "passwordSalt": "Base64Iv" + 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 不能为空"); + } - /// - name: 租户名称,登录时可传 tenantName + await EnsureTenantExistsAsync(input.TenantId); - /// - creationTime: 租户创建时间 + var normalizedKeys = NormalizePermissionKeys(input.MenuPermissionKeys); + var invalidKeys = ThSaasMenuPermissionCatalog.FindInvalidKeys(normalizedKeys); + if (invalidKeys.Count > 0) + { + throw new UserFriendlyException($"存在非法菜单权限 Key:{string.Join(", ", invalidKeys)}"); + } - /// - loginAccount: 种子管理员账号(UserName=admin,亦可用 admin@example.com 登录) + using (UseHostTenantScope()) + { + await _menuPermissionRepository.DeleteAsync(x => x.TenantId == input.TenantId); - /// - password: RbacOptions.AdminPassword 经 AES-CBC 加密后的 Base64 密文 + if (normalizedKeys.Count == 0) + { + return; + } - /// - passwordSalt: AES IV(Base64),前端解密时使用 + 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(); - [AllowAnonymous] + var menuIdsByRole = roleMenuLinks + .GroupBy(x => x.RoleId) + .ToDictionary( + g => g.Key, + g => g.Select(x => x.MenuId.ToString()).ToList()); - public virtual async Task> GetTenantSelectAsync() + 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 不能为空"); + } - var page = await _tenantService.GetListAsync(new TenantGetListInput - + 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("角色不存在"); + } - SkipCount = 1, + 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)}"); + } - MaxResultCount = 500 + 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); - var adminPlainPassword = _rbacOptions.AdminPassword ?? string.Empty; + TenantAggregateRoot tenant; + using (UseHostTenantScope()) + { + tenant = await _tenantRepository.FindAsync(tenantId) + ?? throw new UserFriendlyException("租户不存在"); + } - return page.Items + EnsureTenantDeletable(tenant.Id, tenant.Name); - .Select(x => + 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"; + } - var encrypted = _credentialCipher.EncryptPassword(adminPlainPassword); + await _tenantService.DeleteAsync(new[] { tenantId }); + result.TenantDeleted = true; - return new ThTenantSelectDto + 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) + }); + } + } - Name = x.Name, + private async Task EnsureTenantExistsAsync(Guid tenantId) + { + if (tenantId == Guid.Empty) + { + throw new UserFriendlyException("租户 Id 不能为空"); + } - CreationTime = x.CreationTime, + using (UseHostTenantScope()) + { + return await _tenantRepository.FindAsync(tenantId) + ?? throw new UserFriendlyException("租户不存在"); + } + } - LoginAccount = ThTenantSelectConsts.DefaultAdminLoginAccount, + /// + /// 解析租户业务库连接串(必要时按配置回填主库 YiTenant),供直连 SqlSugar 使用。 + /// + private Task<(string ConnectionString, DbType DbType)> ResolveTenantBusinessConnectionAsync( + TenantAggregateRoot tenant) + { + return TenantBusinessDatabaseAccessor.ResolveConnectionAsync( + tenant, + _tenantDatabaseOptions, + PersistTenantConnectionStringAsync); + } - Password = encrypted.Password, + private async Task PersistTenantConnectionStringAsync(TenantAggregateRoot tenant) + { + using (UseHostTenantScope()) + { + await _tenantRepository.UpdateAsync(tenant); + } + } - PasswordSalt = encrypted.PasswordSalt + 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()); + } + } - .OrderBy(x => x.Name) + 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); + } + } - public virtual Task GetCurrentTenantAsync() + 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(); + } - return Task.FromResult(new ThCurrentTenantDto + 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 租户"); + } + } - TenantId = CurrentTenant.Id, + private (bool CanDrop, string? PrivilegedConnectionString, string? SkipReason) EvaluateDatabaseDrop( + string? databaseName) + { + if (string.IsNullOrWhiteSpace(databaseName)) + { + return (false, null, "连接串为空或库名解析失败,已跳过 DROP DATABASE,仅删除主库记录"); + } - TenantName = CurrentTenant.Name + 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; + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs new file mode 100644 index 0000000..91833f5 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs @@ -0,0 +1,441 @@ +using FoodLabeling.Application.Services.DbModels; +using FoodLabeling.Th.Application.Contracts.Dtos.RbacMenu; +using FoodLabeling.Th.Application.Contracts.IServices; +using FoodLabeling.Th.Domain.Shared.Helpers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SqlSugar; +using Volo.Abp; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Application.Services; +using Yi.Framework.Rbac.Domain.Shared.Enums; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.Services; + +/// +/// 泰额版租户业务库 menu 表 CRUD(字符串 Id/ParentId,不使用 TreeHelper) +/// +[Authorize] +public class ThRbacMenuAppService : ApplicationService, IThRbacMenuAppService +{ + private readonly ISqlSugarDbContext _dbContext; + + public ThRbacMenuAppService(ISqlSugarDbContext dbContext) + { + _dbContext = dbContext; + } + + /// + /// 分页查询菜单列表 + /// + /// + /// 在当前租户业务库 `menu` 表查询,过滤逻辑删除数据。 + /// + /// 示例请求: + /// ```json + /// { + /// "skipCount": 0, + /// "maxResultCount": 20, + /// "menuName": "Label", + /// "state": true, + /// "menuSource": 1 + /// } + /// ``` + /// + /// 参数说明: + /// - skipCount: 跳过条数 + /// - maxResultCount: 每页条数 + /// - menuName: 菜单名称模糊匹配 + /// - state: 启用状态 + /// - menuSource: 菜单来源(MenuSourceEnum) + /// - menuType: 菜单类型(MenuTypeEnum) + /// + /// 分页与筛选条件 + /// 分页菜单列表 + /// 成功返回分页列表 + /// 未登录 + /// 服务器错误 + [HttpGet("th-rbac-menu/list")] + public virtual async Task> GetListAsync([FromQuery] ThRbacMenuGetListInputVo input) + { + RefAsync total = 0; + var query = BuildFilteredQuery(input) + .OrderBy(x => x.OrderNum, OrderByType.Desc); + + var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total); + var items = entities.Select(MapToListDto).ToList(); + return new PagedResultDto(total, items); + } + + /// + /// 根据 Id 获取菜单详情 + /// + /// + /// 在当前租户业务库按主键查询未删除菜单。 + /// + /// 菜单 Id(字符串) + /// 菜单详情 + /// 成功返回菜单 + /// 菜单不存在 + /// 未登录 + /// 服务器错误 + [HttpGet("th-rbac-menu/{id}")] + public virtual async Task GetAsync(string id) + { + var entity = await FindActiveMenuAsync(id); + return MapToListDto(entity); + } + + /// + /// 新增菜单 + /// + /// + /// 写入当前租户业务库 `menu` 表;Id 使用雪花算法字符串。 + /// + /// 示例请求: + /// ```json + /// { + /// "menuName": "Settings", + /// "parentId": "0", + /// "menuType": 1, + /// "menuSource": 2, + /// "router": "/settings", + /// "orderNum": 10, + /// "state": true + /// } + /// ``` + /// + /// 参数说明: + /// - menuName: 菜单名称(必填) + /// - parentId: 父级 Id,根节点为 0 + /// - menuType: 菜单类型(MenuTypeEnum) + /// - menuSource: 菜单来源(MenuSourceEnum) + /// + /// 新增菜单入参 + /// 新建菜单详情 + /// 创建成功 + /// 参数校验失败 + /// 未登录 + /// 服务器错误 + [HttpPost("th-rbac-menu")] + public virtual async Task CreateAsync([FromBody] ThRbacMenuCreateInputVo input) + { + var name = input.MenuName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new UserFriendlyException("菜单名称不能为空"); + } + + var parentId = NormalizeParentId(input.ParentId); + await EnsureParentExistsAsync(parentId); + + var entity = new MenuDbEntity + { + Id = YitIdHelper.NextId().ToString(), + MenuName = name, + ParentId = parentId, + MenuType = (int)input.MenuType, + MenuSource = (int)input.MenuSource, + PermissionCode = input.PermissionCode?.Trim(), + Router = input.Router?.Trim(), + RouterName = input.RouterName?.Trim(), + Component = input.Component?.Trim(), + MenuIcon = input.MenuIcon?.Trim(), + OrderNum = input.OrderNum, + State = input.State, + IsDeleted = false, + CreationTime = DateTime.Now, + IsCache = false, + IsLink = false, + IsShow = input.IsShow, + ConcurrencyStamp = string.Empty + }; + + await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync(); + return await GetAsync(entity.Id); + } + + /// + /// 编辑菜单 + /// + /// + /// 更新当前租户业务库中指定 Id 的菜单;禁止将 ParentId 设为自身。 + /// + /// 示例请求: + /// ```json + /// { + /// "menuName": "Settings", + /// "parentId": "0", + /// "menuType": 1, + /// "menuSource": 2, + /// "orderNum": 20, + /// "state": true + /// } + /// ``` + /// + /// 菜单 Id + /// 编辑入参 + /// 更新后的菜单详情 + /// 更新成功 + /// 参数错误或菜单不存在 + /// 未登录 + /// 服务器错误 + [HttpPut("th-rbac-menu/{id}")] + public virtual async Task UpdateAsync(string id, [FromBody] ThRbacMenuUpdateInputVo input) + { + var entity = await FindActiveMenuAsync(id); + + var name = input.MenuName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + throw new UserFriendlyException("菜单名称不能为空"); + } + + var parentId = NormalizeParentId(input.ParentId); + if (string.Equals(parentId, id, StringComparison.Ordinal)) + { + throw new UserFriendlyException("父级菜单不能为自身"); + } + + await EnsureParentExistsAsync(parentId, id); + + entity.MenuName = name; + entity.ParentId = parentId; + entity.MenuType = (int)input.MenuType; + entity.MenuSource = (int)input.MenuSource; + entity.PermissionCode = input.PermissionCode?.Trim(); + entity.Router = input.Router?.Trim(); + entity.RouterName = input.RouterName?.Trim(); + entity.Component = input.Component?.Trim(); + entity.MenuIcon = input.MenuIcon?.Trim(); + entity.OrderNum = input.OrderNum; + entity.State = input.State; + entity.IsShow = input.IsShow; + entity.LastModificationTime = DateTime.Now; + + await _dbContext.SqlSugarClient.Updateable(entity) + .Where(x => x.Id == entity.Id) + .ExecuteCommandAsync(); + + return await GetAsync(entity.Id); + } + + /// + /// 批量删除菜单(逻辑删除) + /// + /// + /// 将指定 Id 列表对应菜单标记为 IsDeleted=true。 + /// + /// 示例请求: + /// ```json + /// ["1234567890123456789", "9876543210987654321"] + /// ``` + /// + /// 参数说明: + /// - ids: 待删除菜单 Id 数组 + /// + /// 菜单 Id 列表 + /// 无内容 + /// 删除成功 + /// 未登录 + /// 服务器错误 + [HttpDelete("th-rbac-menu")] + public virtual async Task DeleteAsync([FromBody] List ids) + { + var idList = ids? + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.Ordinal) + .ToList() ?? new List(); + + if (idList.Count == 0) + { + return; + } + + await _dbContext.SqlSugarClient.Updateable() + .SetColumns(x => new MenuDbEntity { IsDeleted = true, LastModificationTime = DateTime.Now }) + .Where(x => idList.Contains(x.Id) && x.IsDeleted == false) + .ExecuteCommandAsync(); + } + + /// + /// 获取全部菜单树 + /// + /// + /// 返回当前租户业务库全部未删除菜单,按 ParentId 字符串组树(不使用 Guid TreeHelper)。 + /// 树节点按 OrderNum 降序排序。 + /// + /// 根节点菜单树 + /// 成功返回菜单树 + /// 未登录 + /// 服务器错误 + [HttpGet("th-rbac-menu/tree")] + public virtual async Task> GetTreeAsync() + { + var menus = await _dbContext.SqlSugarClient.Queryable() + .Where(x => x.IsDeleted == false) + .OrderBy(x => x.OrderNum, OrderByType.Desc) + .ToListAsync(); + + var nodes = menus.Select(MapToTreeDto).ToList(); + return BuildMenuTree(nodes); + } + + private ISugarQueryable BuildFilteredQuery(ThRbacMenuGetListInputVo input) + { + return _dbContext.SqlSugarClient.Queryable() + .Where(x => x.IsDeleted == false) + .WhereIF(!string.IsNullOrWhiteSpace(input.MenuName), x => x.MenuName.Contains(input.MenuName!.Trim())) + .WhereIF(input.State is not null, x => x.State == input.State) + .WhereIF(input.MenuSource is not null, x => x.MenuSource == (int)input.MenuSource!.Value) + .WhereIF(input.MenuType is not null, x => x.MenuType == (int)input.MenuType!.Value); + } + + private async Task FindActiveMenuAsync(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + throw new UserFriendlyException("菜单 Id 不能为空"); + } + + var entity = await _dbContext.SqlSugarClient.Queryable() + .Where(x => x.Id == id.Trim() && x.IsDeleted == false) + .SingleAsync(); + + if (entity is null) + { + throw new UserFriendlyException("菜单不存在"); + } + + return entity; + } + + private async Task EnsureParentExistsAsync(string parentId, string? currentId = null) + { + if (IsRootParentId(parentId)) + { + return; + } + + var parent = await _dbContext.SqlSugarClient.Queryable() + .Where(x => x.Id == parentId && x.IsDeleted == false) + .SingleAsync(); + + if (parent is null) + { + throw new UserFriendlyException("父级菜单不存在"); + } + + if (currentId is not null && string.Equals(parent.ParentId, currentId, StringComparison.Ordinal)) + { + throw new UserFriendlyException("不能将菜单移动到其子节点下"); + } + } + + private static string NormalizeParentId(string? parentId) + { + return string.IsNullOrWhiteSpace(parentId) ? "0" : parentId.Trim(); + } + + private static bool IsRootParentId(string parentId) + { + return parentId == "0" || parentId == "00000000-0000-0000-0000-000000000000"; + } + + private static ThRbacMenuGetListOutputDto MapToListDto(MenuDbEntity x) + { + return new ThRbacMenuGetListOutputDto + { + Id = x.Id, + ParentId = x.ParentId, + MenuName = x.MenuName ?? string.Empty, + RouterName = x.RouterName, + Router = x.Router, + PermissionCode = x.PermissionCode, + MenuType = (MenuTypeEnum)x.MenuType, + MenuSource = (MenuSourceEnum)x.MenuSource, + OrderNum = x.OrderNum, + State = x.State + }; + } + + private static ThRbacMenuTreeDto MapToTreeDto(MenuDbEntity m) + { + return new ThRbacMenuTreeDto + { + Id = m.Id, + IsDeleted = m.IsDeleted, + CreationTime = m.CreationTime, + CreatorId = m.CreatorId, + LastModifierId = m.LastModifierId, + LastModificationTime = m.LastModificationTime, + OrderNum = m.OrderNum, + State = m.State, + MenuName = m.MenuName ?? string.Empty, + RouterName = m.RouterName, + MenuType = (MenuTypeEnum)m.MenuType, + PermissionCode = m.PermissionCode, + ParentId = m.ParentId, + MenuIcon = m.MenuIcon, + Router = m.Router, + IsLink = m.IsLink, + IsCache = m.IsCache, + IsShow = m.IsShow, + Remark = m.Remark, + Component = m.Component, + MenuSource = (MenuSourceEnum)m.MenuSource, + Query = m.Query, + ConcurrencyStamp = m.ConcurrencyStamp, + Children = new List() + }; + } + + private static List BuildMenuTree(List nodes) + { + var nodeById = nodes + .Where(x => !string.IsNullOrWhiteSpace(x.Id)) + .GroupBy(x => x.Id) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var node in nodes) + { + node.Children ??= new List(); + var parentId = NormalizeParentId(node.ParentId); + if (IsRootParentId(parentId)) + { + continue; + } + + if (nodeById.TryGetValue(parentId, out var parent)) + { + parent.Children ??= new List(); + parent.Children.Add(node); + } + } + + var roots = nodes + .Where(n => + { + var pid = NormalizeParentId(n.ParentId); + return IsRootParentId(pid) || !nodeById.ContainsKey(pid); + }) + .ToList(); + + SortTree(roots); + return roots; + } + + private static void SortTree(List nodes) + { + nodes.Sort((a, b) => b.OrderNum.CompareTo(a.OrderNum)); + foreach (var node in nodes) + { + if (node.Children is { Count: > 0 }) + { + SortTree(node.Children); + } + } + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs index 0107f37..6c9fb49 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs @@ -10,6 +10,7 @@ using Volo.Abp; using Volo.Abp.Application.Services; using Volo.Abp.MultiTenancy; using Volo.Abp.Uow; +using Yi.Framework.SqlSugarCore.Abstractions; using Yi.Framework.TenantManagement.Application.Contracts; using Yi.Framework.TenantManagement.Application.Contracts.Dtos; @@ -46,15 +47,18 @@ public class ThTenantProvisioningAppService : ApplicationService, IThTenantProvi private readonly ITenantService _tenantService; private readonly IThTenantDatabaseBackgroundInitializer _backgroundInitializer; private readonly FoodLabelingThTenantDatabaseOptions _dbOptions; + private readonly DbConnOptions _dbConnOptions; public ThTenantProvisioningAppService( ITenantService tenantService, IThTenantDatabaseBackgroundInitializer backgroundInitializer, - IOptions dbOptions) + IOptions dbOptions, + IOptions dbConnOptions) { _tenantService = tenantService; _backgroundInitializer = backgroundInitializer; _dbOptions = dbOptions.Value; + _dbConnOptions = dbConnOptions.Value; } /// @@ -100,6 +104,13 @@ public class ThTenantProvisioningAppService : ApplicationService, IThTenantProvi var initializing = false; if (input.InitializeDatabase) { + // 同步建库 + GRANT,避免仅入队后台 Init 时库不存在导致静默失败 + TenantDatabaseBootstrapper.EnsureDatabaseCreated( + _dbConnOptions, + dbType, + connectionString, + databaseName); + _backgroundInitializer.Enqueue(created.Id); initializing = true; } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain.Shared/Helpers/YitIdHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain.Shared/Helpers/YitIdHelper.cs new file mode 100644 index 0000000..71f0cc5 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain.Shared/Helpers/YitIdHelper.cs @@ -0,0 +1,63 @@ +namespace FoodLabeling.Th.Domain.Shared.Helpers; + +/// +/// 雪花 Id 生成器(项目规范:实体 string Id 使用 NextId().ToString()) +/// +public static class YitIdHelper +{ + private const long Epoch = 1_609_459_200_000L; + private const int WorkerIdBits = 5; + private const int SequenceBits = 12; + private const long MaxSequence = (1L << SequenceBits) - 1; + private const int WorkerIdShift = SequenceBits; + private const int TimestampShift = SequenceBits + WorkerIdBits; + private const long WorkerId = 1; + + private static long _lastTimestamp = -1L; + private static long _sequence; + private static readonly object SyncRoot = new(); + + /// + /// 生成下一个雪花 Id + /// + public static long NextId() + { + lock (SyncRoot) + { + var timestamp = CurrentTimestamp(); + if (timestamp < _lastTimestamp) + { + timestamp = WaitNextMillis(_lastTimestamp); + } + + if (_lastTimestamp == timestamp) + { + _sequence = (_sequence + 1) & MaxSequence; + if (_sequence == 0) + { + timestamp = WaitNextMillis(_lastTimestamp); + } + } + else + { + _sequence = 0; + } + + _lastTimestamp = timestamp; + return ((timestamp - Epoch) << TimestampShift) | (WorkerId << WorkerIdShift) | _sequence; + } + } + + private static long CurrentTimestamp() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + private static long WaitNextMillis(long lastTimestamp) + { + var timestamp = CurrentTimestamp(); + while (timestamp <= lastTimestamp) + { + timestamp = CurrentTimestamp(); + } + + return timestamp; + } +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantAdminCredentialEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantAdminCredentialEntity.cs new file mode 100644 index 0000000..960fb75 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantAdminCredentialEntity.cs @@ -0,0 +1,48 @@ +using SqlSugar; +using Volo.Abp.Domain.Entities; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Domain.Entities; + +/// +/// 平台主库:租户默认管理员可回显凭据(AES 密文,非登录哈希) +/// +/// +/// 主键列名为 TenantId;仓储泛型键仍用 Guid(映射到 Id)。 +/// 勿再对 Id 使用 IsIgnore 别名,否则 SqlSugar Insertable 会 NRE。 +/// +[SugarTable("fl_th_tenant_admin_credential")] +[DefaultTenantTable] +public class ThTenantAdminCredentialEntity : Entity +{ + /// + /// 租户 Id(主键,对应 YiTenant.Id;列名 TenantId) + /// + [SugarColumn(IsPrimaryKey = true, ColumnName = "TenantId")] + public override Guid Id { get; protected set; } + + /// + /// 管理员登录账号(UserName) + /// + [SugarColumn(Length = 64)] + public string LoginAccount { get; set; } = string.Empty; + + /// + /// AES-CBC 加密后的可回显密码(Base64 密文) + /// + [SugarColumn(Length = 512)] + public string PasswordCipher { get; set; } = string.Empty; + + /// + /// AES IV(Base64) + /// + [SugarColumn(Length = 64)] + public string PasswordIv { get; set; } = string.Empty; + + /// + /// 最后修改时间 + /// + public DateTime? LastModificationTime { get; set; } + + public void SetTenantId(Guid tenantId) => Id = tenantId; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantMenuPermissionEntity.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantMenuPermissionEntity.cs new file mode 100644 index 0000000..11944c0 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Domain/Entities/ThTenantMenuPermissionEntity.cs @@ -0,0 +1,45 @@ +using SqlSugar; +using Volo.Abp.Domain.Entities; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Domain.Entities; + +/// +/// 平台主库:租户 SaaS 菜单权限 Key +/// +[SugarTable("fl_th_tenant_menu_permission")] +[DefaultTenantTable] +[SugarIndex( + "uk_fl_th_tenant_menu_permission_tenant_key", + nameof(TenantId), + OrderByType.Asc, + nameof(PermissionKey), + OrderByType.Asc, + IsUnique = true)] +public class ThTenantMenuPermissionEntity : IEntity +{ + /// + /// 主键(雪花 Id 字符串) + /// + [SugarColumn(IsPrimaryKey = true, Length = 32)] + public string Id { get; set; } = string.Empty; + + /// + /// 租户 Id(对应 YiTenant.Id) + /// + public Guid TenantId { get; set; } + + /// + /// SaaS 菜单权限 Key(如 labeling:labels) + /// + [SugarColumn(Length = 128)] + public string PermissionKey { get; set; } = string.Empty; + + /// + /// 创建时间 + /// + public DateTime CreationTime { get; set; } = DateTime.Now; + + /// + public object[] GetKeys() => new object[] { Id }; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs index cb4fd3b..4b8d1e2 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/tenant-management/Yi.Framework.TenantManagement.Application/TenantService.cs @@ -48,6 +48,29 @@ namespace Yi.Framework.TenantManagement.Application private IDisposable UseHostTenantScope() => CurrentTenant.Change(null); /// + /// 用主库连接串直连读取 YiTenant,避免 UoW 事务 + IsAutoCloseConnection 在后台 Init 时 NRE。 + /// + private async Task LoadTenantFromHostAsync(Guid id) + { + if (string.IsNullOrWhiteSpace(_dbConnOptions.Url)) + { + throw new UserFriendlyException("未配置主库连接字符串 DbConnOptions.Url"); + } + + var dbType = _dbConnOptions.DbType ?? DbType.MySql; + using var client = new SqlSugarClient(new ConnectionConfig + { + ConfigId = $"host-yitenant-read-{Guid.NewGuid():N}", + DbType = dbType, + ConnectionString = _dbConnOptions.Url, + IsAutoCloseConnection = true + }); + + var tenant = await client.Queryable().InSingleAsync(id); + return tenant is { IsDeleted: true } ? null : tenant; + } + + /// /// 租户单查 /// /// @@ -153,17 +176,13 @@ namespace Yi.Framework.TenantManagement.Application /// [HttpPut("tenant/init/{id}")] [RequestTimeout("ThTenantDatabaseInit")] + [UnitOfWork(isTransactional: false)] public async Task InitAsync([FromRoute] Guid id) { TenantAggregateRoot tenant; using (UseHostTenantScope()) { - if (CurrentUnitOfWork != null) - { - await CurrentUnitOfWork.SaveChangesAsync(); - } - - tenant = await _repository.FindAsync(id); + tenant = await LoadTenantFromHostAsync(id); if (tenant == null) { throw new UserFriendlyException("未找到租户信息"); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json index 79cae19..ac3f716 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json @@ -20,7 +20,7 @@ }, //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049) "App": { - "SelfUrl": "http://192.168.31.89:19002", + "SelfUrl": "http://192.168.31.248:19002", "CorsOrigins": "http://localhost:19002;http://localhost:18000;http://localhost:5666;http://localhost:5173;http://localhost:5174;http://localhost:3000;http://127.0.0.1:19002" }, //配置 diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs index 2444faf..1ada7bf 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabelingApplicationModule.cs @@ -1,6 +1,8 @@ using FoodLabeling.Application.Contracts; +using FoodLabeling.Application.Filters; using FoodLabeling.Application.Options; using FoodLabeling.Domain; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Modularity; using Yi.Framework.Ddd.Application; @@ -21,6 +23,12 @@ public class FoodLabelingApplicationModule : AbpModule { Configure( context.Services.GetConfiguration().GetSection(FoodLabelingBatchImportOptions.SectionName)); + + // 任意业务写接口成功后刷新系统编辑时间戳,供 my-menus.lastUpdated 使用 + Configure(options => + { + options.Filters.AddService(); + }); } } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs index 18a64b5..1168681 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs @@ -21,17 +21,20 @@ namespace FoodLabeling.Application.Services; public class AuthSessionAppService : ApplicationService, IAuthSessionAppService { private readonly IDistributedCache _userCache; + private readonly IDistributedCache _systemEditStampCache; private readonly ISqlSugarDbContext _dbContext; private readonly ISqlSugarRepository _userRepository; public AuthSessionAppService( ISqlSugarDbContext dbContext, ISqlSugarRepository userRepository, - IDistributedCache userCache) + IDistributedCache userCache, + IDistributedCache systemEditStampCache) { _dbContext = dbContext; _userRepository = userRepository; _userCache = userCache; + _systemEditStampCache = systemEditStampCache; } /// @@ -109,6 +112,12 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService .OrderBy(x => x) .ToList(); + // 优先系统编辑全局时间戳(任意写接口成功联动);无戳时回退「业务表最近修改」再回退用户时间 + var systemEditAt = await SystemEditStampCacheHelper.GetAsync(_systemEditStampCache); + var lastUpdated = systemEditAt + ?? await ResolveRecentBusinessEditTimeAsync() + ?? user.LastModificationTime; + return new CurrentUserMenuPermissionsOutputDto { User = new CurrentUserBriefDto @@ -123,12 +132,49 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService PermissionCodes = permissionCodes, AccessPermissionCodes = accessPermissionCodes, Menus = BuildMenuTree(menuNodes), - LastUpdated = user.LastModificationTime, + LastUpdated = lastUpdated, Role = roleDisplay, FullName = BuildFullName(user) }; } + /// + /// 缓存未命中时,取主要业务表最近修改时间,避免 Last Updated 长期停在用户资料时间。 + /// + private async Task ResolveRecentBusinessEditTimeAsync() + { + var db = _dbContext.SqlSugarClient; + DateTime? max = null; + + async Task ConsiderAsync(Task query) + { + try + { + var t = await query; + if (t.HasValue && (!max.HasValue || t.Value > max.Value)) + { + max = t; + } + } + catch + { + // 表/列缺失时忽略,继续其它表 + } + } + + await ConsiderAsync(db.Queryable() + .Where(x => !x.IsDeleted) + .MaxAsync(x => (DateTime?)(x.LastModificationTime ?? x.CreationTime))); + await ConsiderAsync(db.Queryable() + .Where(x => !x.IsDeleted) + .MaxAsync(x => (DateTime?)(x.LastModificationTime ?? x.CreationTime))); + await ConsiderAsync(db.Queryable() + .Where(x => !x.IsDeleted) + .MaxAsync(x => (DateTime?)(x.LastModificationTime ?? x.CreationTime))); + + return max; + } + /// [HttpPost] public virtual async Task LogoutAsync() diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json b/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json index b4ae86a..31cdd9f 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json @@ -20,7 +20,7 @@ }, //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049) "App": { - "SelfUrl": "http://192.168.31.89:19001", + "SelfUrl": "http://192.168.31.88:19001", "CorsOrigins": "http://localhost:19001;http://localhost:18000;http://localhost:5666;http://localhost:5174;http://localhost:5173;http://localhost:3000" }, //配置 diff --git a/项目相关文档/2026-07-21代码优化.md b/项目相关文档/2026-07-21代码优化.md new file mode 100644 index 0000000..cbf8679 --- /dev/null +++ b/项目相关文档/2026-07-21代码优化.md @@ -0,0 +1,420 @@ +# 2026-07-21 代码优化 + +本文档说明 **2026-07-21** 后端改动。重点为 **泰额版** 平台级管理员: + +1. 「公司列表」返回账号密码、支持修改(前端密码 AES 加盐) +2. **菜单权限分配**、**角色菜单分配** + +另含美国版/泰额版 Dashboard `Last Updated` 修复。前端本次不改。 + +--- + +## 一、变更概述 + +| 项 | 说明 | +|----|------| +| 范围 | 泰额版 `Yi.Abp.Net8`(主);美国版 `Last Updated` 同步修复 | +| 公司 = 租户 | 平台主库 `YiTenant`;前端常把 `name` 映射为 `companyName` | +| 账号密码 | `GET company-list`、`PUT company-admin` | +| 菜单/角色 | `menu-permission-tree`、`company-menus`、`company-roles`、`company-role-menus` | +| 凭据存储 | 主库表 `fl_th_tenant_admin_credential`(可回显 AES 密文,非登录哈希) | +| 菜单存储 | 主库表 `fl_th_tenant_menu_permission`(SaaS `menuPermissionKeys`) | +| 传输加盐 | 与 `FoodLabeling:TenantSelectCrypto` 一致(AES-CBC + `passwordSalt`=IV) | +| 落库哈希 | 租户业务库 `User`:`MD5Helper.GenerateSalt` + `SHA2Encode`(`UserPasswordHelper`) | + +--- + +## 二、平台公司列表(返账号密码) + +### 2.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-multi-tenancy/company-list` | +| 鉴权 | 需登录(Bearer) | +| 说明 | 分页查询主库租户,并返回可解密的管理员账号/密码 | + +> **路由说明**:ABP 常规控制器 RootPath 为 `api/app`;自定义 `[HttpGet(...)]` / `[HttpPut(...)]` 必须带 `th-multi-tenancy/` 前缀,完整路径才是 `/api/app/th-multi-tenancy/...`。此前误写为 `[HttpGet("company-list")]` 时实际注册在 `/api/app/company-list`(404 于文档路径);已纠正。 + +### 2.2 入参 + +| 字段 | 说明 | +|------|------| +| `keyword` / `name` | 租户名称模糊匹配(`keyword` 优先) | +| `skipCount` | 跳过条数 | +| `maxResultCount` | 每页条数 | + +### 2.3 出参示例 + +```json +{ + "totalCount": 1, + "items": [ + { + "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "name": "测试公司", + "creationTime": "2026-01-01T08:00:00", + "loginAccount": "admin", + "password": "Base64CipherText", + "passwordSalt": "Base64Iv", + "menuPermissionKeys": ["dashboard", "labeling:labels"] + } + ] +} +``` + +### 2.4 密码来源优先级 + +1. 主库 `fl_th_tenant_admin_credential` 中已保存的 AES 密文 / IV +2. 无记录时回退 `RbacOptions.AdminPassword`,再经 AES 加密返回 + +默认账号:`admin`(与 `UserDataSeed` / `ThTenantSelectConsts.DefaultAdminLoginAccount` 一致)。 + +--- + +## 三、修改公司管理员账号/密码 + +### 3.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `PUT /api/app/th-multi-tenancy/company-admin` | +| 鉴权 | 需登录(Bearer) | + +### 3.2 入参 + +```json +{ + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "loginAccount": "admin", + "password": "Base64CipherText", + "passwordSalt": "Base64Iv" +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `tenantId` | 是 | 租户 Guid | +| `loginAccount` | 否 | 新登录账号;空则不改账号 | +| `password` | 否 | AES 密文;与 `passwordSalt` 都空则不改密码 | +| `passwordSalt` | 改密时必填 | AES IV(Base64) | + +### 3.3 处理流程 + +1. 主库校验租户存在,读取凭据表当前账号 +2. `CurrentTenant.Change(tenantId)` 定位租户库管理员用户 +3. 若改账号:校验 UserName 不重复后更新 +4. 若改密码:`TenantSelectCredentialCipher.DecryptPassword` → `UserPasswordHelper.ApplyPlainPassword` + `EnsurePasswordColumnsPersistedAsync` +5. Upsert 主库凭据表(可回显 AES 密文),供列表再次返回 + +### 3.4 curl 示例 + +```bash +# 1) 登录拿 Token(按环境改 host / 账号) +curl -X POST "http://127.0.0.1:19002/api/app/th-web-auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"tenantName\":\"Default\",\"userName\":\"admin\",\"password\":\"123456\"}" + +# 2) 公司列表 +curl -X GET "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-list?skipCount=0&maxResultCount=20" \ + -H "Authorization: Bearer " + +# 3) 修改管理员(password / passwordSalt 须为前端 AES 加密结果) +curl -X PUT "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-admin" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d "{\"tenantId\":\"<租户Guid>\",\"loginAccount\":\"admin\",\"password\":\"<密文>\",\"passwordSalt\":\"\"}" +``` + +--- + +## 四、AES 加盐约定(与 07-16 一致) + +| 项 | 值 | +|----|------| +| 算法 | AES-256-CBC | +| 填充 | PKCS7 | +| Key | `SHA256(UTF8(SecretKey))` → 32 字节 | +| IV | 随机 16 字节,Base64 → `passwordSalt` | +| 密文 | Base64 → `password` | +| 配置 | `FoodLabeling:TenantSelectCrypto:SecretKey` | + +```json +"FoodLabeling": { + "TenantSelectCrypto": { + "SecretKey": "FoodLabelingThTenantSelect2026Key!" + } +} +``` + +- **出参**:后端加密,前端用同一 SecretKey + `passwordSalt` 解密展示 +- **入参(改密)**:前端加密后传 `password` + `passwordSalt`,后端 `DecryptPassword` 后再做登录哈希落库 + +解密失败统一:`UserFriendlyException("密码解密失败")`。 + +--- + +## 五、主库凭据表 + +表名:`fl_th_tenant_admin_credential`(实体带 `[DefaultTenantTable]`,走平台主库;CodeFirst 开启时随主库启动建表) + +| 字段 | 说明 | +|------|------| +| `TenantId` | PK,对应 `YiTenant.Id` | +| `LoginAccount` | 管理员登录账号 | +| `PasswordCipher` | AES 可回显密文 | +| `PasswordIv` | AES IV | +| `LastModificationTime` | 最后修改时间 | + +说明:此表存的是**可逆展示密文**,不是 `User.EncryPassword` 的不可逆哈希。 + +--- + +## 六、平台菜单权限分配 + +平台管理员可为每个公司(租户)勾选可用 SaaS 菜单模块;Key 与前端 `saas-menu-tree` / `tenant-menu-drawer` 的 `menuPermissionKeys` 对齐。 + +### 6.1 菜单权限目录树 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-multi-tenancy/menu-permission-tree` | +| 鉴权 | 需登录 | +| 说明 | 返回可分配的 SaaS 菜单树(`key` / `title` / `children`) | + +出参示例: + +```json +[ + { + "key": "dashboard", + "title": "仪表盘", + "children": [{ "key": "dashboard:analytics", "title": "数据分析" }] + }, + { + "key": "labeling", + "title": "标签管理", + "children": [ + { "key": "labeling:labels", "title": "标签列表" }, + { "key": "labeling:categories", "title": "标签分类" }, + { "key": "labeling:types", "title": "标签类型" }, + { "key": "labeling:templates", "title": "标签模板" }, + { "key": "labeling:multiple-options", "title": "多选项" } + ] + }, + { + "key": "management", + "title": "管理", + "children": [ + { "key": "management:account", "title": "账号管理" }, + { "key": "management:menu", "title": "菜单管理" }, + { "key": "management:reports", "title": "报表" } + ] + } +] +``` + +完整 Key 目录(与前端一致): + +| 分组 | Key | +|------|-----| +| dashboard | `dashboard`、`dashboard:analytics` | +| labeling | `labeling`、`labeling:labels`、`labeling:categories`、`labeling:types`、`labeling:templates`、`labeling:multiple-options` | +| modules | `modules`、`modules:training`、`modules:alerts`、`modules:tasks`、`modules:food-waste`、`modules:e-label` | +| management | `management`、`management:account`、`management:menu`、`management:devices`、`management:reports`、`management:invoices`、`management:qr-codes`、`management:support`、`management:api` | + +### 6.2 获取 / 设置公司菜单 + +| 操作 | 方法 / 路径 | +|------|-------------| +| 获取 | `GET /api/app/th-multi-tenancy/company-menus?tenantId={guid}` | +| 设置 | `PUT /api/app/th-multi-tenancy/company-menus`(覆盖式) | + +设置入参: + +```json +{ + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "menuPermissionKeys": ["dashboard", "labeling:labels", "management:account"] +} +``` + +- 非法 Key → `400`,提示非法项 +- `menuPermissionKeys` 为空数组 → 清空该公司全部菜单权限 +- `company-list` 出参已增加 `menuPermissionKeys` + +### 6.3 主库表 `fl_th_tenant_menu_permission` + +| 字段 | 说明 | +|------|------| +| `Id` | 主键,`YitIdHelper.NextId().ToString()` | +| `TenantId` | 租户 Guid | +| `PermissionKey` | SaaS 菜单 Key | +| `CreationTime` | 创建时间 | + +唯一逻辑:同一 `TenantId + PermissionKey` 不重复;写接口覆盖式先清后插。 + +--- + +## 七、平台角色菜单分配 + +平台管理员可跨租户业务库,查看该公司角色并为角色绑定菜单(`Role` / `RoleMenu` / `Menu`)。 + +### 7.1 租户角色列表 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-multi-tenancy/company-roles?tenantId={guid}` | +| 鉴权 | 需登录 | + +出参示例: + +```json +[ + { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "roleName": "管理员", + "roleCode": "admin", + "state": true, + "menuIds": ["11111111-1111-1111-1111-111111111111"] + } +] +``` + +### 7.2 设置角色菜单(覆盖式) + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `PUT /api/app/th-multi-tenancy/company-role-menus` | +| 鉴权 | 需登录 | + +入参: + +```json +{ + "tenantId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "roleId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "menuIds": ["11111111-1111-1111-1111-111111111111"] +} +``` + +逻辑:`CurrentTenant.Change(tenantId)` 后覆盖写 `RoleMenu`(对齐 `RbacRoleMenuAppService.Set`);校验角色/菜单属于该租户库且未删除。 + +--- + +## 八、tenant-select 同步 + +`GET /api/app/th-multi-tenancy/tenant-select`(匿名,登录页): + +| 项 | 说明 | +|----|------| +| `SkipCount` | 由错误的 `1` 修正为 `0` | +| 账号/密码 | 与公司列表相同:优先凭据表,否则 `AdminPassword` | +| `id` | 仍不返回(保持 `ThTenantSelectDto`) | + +--- + +## 九、Dashboard Last Updated(同日附带) + +| 项 | 说明 | +|----|------| +| 接口 | `GET /api/app/auth-session/my-menus` | +| 问题 | `lastUpdated` 误用用户 `LastModificationTime`,相对时间与业务编辑不符 | +| 修复 | 优先系统编辑 stamp → 业务表最近修改 → 用户资料时间 | +| 过滤器 | `SystemEditStampGlobalFilter` 注册到 MVC;写接口成功刷新 stamp | +| 范围 | 美国版 + 泰额版 `food-labeling-us` 同步 | + +--- + +## 十、改动文件列表 + +### 泰额版 — 公司账号密码 + +| 文件 | 变更 | +|------|------| +| `FoodLabeling.Th.Domain/Entities/ThTenantAdminCredentialEntity.cs` | 新建主库凭据实体 | +| `FoodLabeling.Th.Application/MultiTenancy/TenantSelectCredentialCipher.cs` | 新增 `DecryptPassword` | +| `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListInput.cs` | 新建 | +| `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyListItemDto.cs` | 新建(含 `menuPermissionKeys`) | +| `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyAdminInputVo.cs` | 新建 | +| `FoodLabeling.Th.Application.Contracts/IServices/IThMultiTenancyAppService.cs` | 公司列表/改密/菜单/角色接口 | +| `FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs` | 全部实现 + 修复 tenant-select | + +### 泰额版 — 菜单 / 角色分配 + +| 文件 | 变更 | +|------|------| +| `FoodLabeling.Th.Domain/Entities/ThTenantMenuPermissionEntity.cs` | 主库菜单权限表实体 | +| `FoodLabeling.Th.Domain.Shared/Helpers/YitIdHelper.cs` | 雪花 Id(若新建) | +| `FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs` | SaaS 菜单 Key 目录 | +| `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs` | 权限树节点 | +| `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyMenusDto.cs` | 公司菜单出参 | +| `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyMenusInputVo.cs` | 设置公司菜单入参 | +| `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThCompanyRoleItemDto.cs` | 租户角色列表项 | +| `FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThUpdateCompanyRoleMenusInputVo.cs` | 设置角色菜单入参 | + +### 美国版 / 泰额版 — Last Updated + +| 文件 | 变更 | +|------|------| +| `FoodLabeling.Application/Services/AuthSessionAppService.cs` | `lastUpdated` 取值顺序调整 | +| `FoodLabeling.Application/FoodLabelingApplicationModule.cs` | 注册 `SystemEditStampGlobalFilter` | +| (既有)`SystemEditStampGlobalFilter.cs` / `SystemEditStampCache.cs` | 写成功刷新全局 stamp | + +--- + +## 十一、前端对接说明(本次未改前端) + +- 平台公司列表请对接 **`company-list`**(含 `id`、加密凭据、`menuPermissionKeys`),勿仅依赖匿名 `tenant-select`(无 `id`)。 +- 改密时密码须按第四节 AES 约定加密后再提交,**不要**直接传明文。 +- `tenant-admin-modal`:对接 `PUT company-admin`。 +- `tenant-menu-drawer`:树数据用 `menu-permission-tree`;回显/保存用 `company-menus`(字段 `menuPermissionKeys`)。 +- 角色菜单:列表用 `company-roles`,保存用 `company-role-menus`(`menuIds` 为租户库 Menu.Id)。 + +--- + +## 十二、自检清单 + +- [ ] 主库已建表 `fl_th_tenant_admin_credential`、`fl_th_tenant_menu_permission`(或 CodeFirst 已开启并重启成功) +- [ ] `TenantSelectCrypto:SecretKey` 已配置且与前端一致 +- [ ] 自定义 HttpGet/HttpPut 已带 `th-multi-tenancy/` 前缀(勿访问误路由 `/api/app/company-list` 等短路径) +- [ ] `GET company-list` 返回 `loginAccount` / `password` / `passwordSalt` / `menuPermissionKeys` +- [ ] `PUT company-admin` 改密后可用新密码登录该租户 +- [ ] 再次拉列表能解出与改密后一致的明文 +- [ ] `GET menu-permission-tree` 返回 SaaS 菜单树 +- [ ] `PUT company-menus` 后 `GET company-menus` / `company-list` 一致 +- [ ] `GET company-roles` 能列出租户角色及 `menuIds` +- [ ] `PUT company-role-menus` 后该角色菜单绑定已覆盖更新 +- [ ] `tenant-select` 分页从第一条开始(不再 Skip 掉首条) +- [ ] (可选)改业务数据后 `my-menus.lastUpdated` 接近当前时间 + +--- + +## 十三、主库建表 DDL(缺表报错时执行) + +环境:`antis-foodlabeling-host`。若接口报 `Table '...fl_th_tenant_admin_credential' doesn't exist`,在主库执行: + +```sql +CREATE TABLE IF NOT EXISTS `fl_th_tenant_admin_credential` ( + `TenantId` char(36) NOT NULL COMMENT '租户Id,对应 YiTenant.Id', + `LoginAccount` varchar(64) NOT NULL DEFAULT '' COMMENT '管理员登录账号', + `PasswordCipher` varchar(512) NOT NULL DEFAULT '' COMMENT 'AES可回显密文', + `PasswordIv` varchar(64) NOT NULL DEFAULT '' COMMENT 'AES IV Base64', + `LastModificationTime` datetime(6) NULL COMMENT '最后修改时间', + PRIMARY KEY (`TenantId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='平台主库-租户管理员可回显凭据'; + +CREATE TABLE IF NOT EXISTS `fl_th_tenant_menu_permission` ( + `Id` varchar(32) NOT NULL COMMENT '雪花Id', + `TenantId` char(36) NOT NULL COMMENT '租户Id', + `PermissionKey` varchar(128) NOT NULL COMMENT 'SaaS菜单权限Key', + `CreationTime` datetime(6) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_fl_th_tenant_menu_permission_tenant_key` (`TenantId`, `PermissionKey`), + KEY `IX_fl_th_tenant_menu_permission_TenantId` (`TenantId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='平台主库-租户SaaS菜单权限'; +``` + +> 2026-07-21 已在 `antis-foodlabeling-host` 执行上述 DDL(两表已存在)。 -- libgit2 0.21.4