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