diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs index 3c1725e..689fff6 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs @@ -21,10 +21,13 @@ namespace Yi.Framework.SqlSugarCore { #region Properties + private ISqlSugarClient? _sqlSugarClient; + private readonly object _clientLock = new(); + /// - /// SqlSugar客户端实例 + /// SqlSugar 客户端(延迟按当前租户解析连接串,避免构造时 CurrentTenant 尚未就绪落到 host 库) /// - public ISqlSugarClient SqlSugarClient { get; private set; } + public ISqlSugarClient SqlSugarClient => GetOrCreateClient(); /// /// 延迟服务提供者 @@ -75,22 +78,36 @@ namespace Yi.Framework.SqlSugarCore public SqlSugarDbContextFactory(IAbpLazyServiceProvider lazyServiceProvider) { LazyServiceProvider = lazyServiceProvider; + } + + private ISqlSugarClient GetOrCreateClient() + { + if (_sqlSugarClient is not null) + { + return _sqlSugarClient; + } - // 异步获取租户配置 - var tenantConfiguration = AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync()); - - // 构建数据库连接配置 - var connectionConfig = BuildConnectionConfig(options => + lock (_clientLock) { - options.ConnectionString = tenantConfiguration.GetCurrentConnectionString(); - options.DbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName()); - }); + if (_sqlSugarClient is not null) + { + return _sqlSugarClient; + } - // 创建SqlSugar客户端实例 - SqlSugarClient = new SqlSugarClient(connectionConfig); + var tenantConfiguration = + AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync()); - // 配置数据库AOP - ConfigureDbAop(SqlSugarClient); + var connectionConfig = BuildConnectionConfig(options => + { + options.ConnectionString = tenantConfiguration.GetCurrentConnectionString(); + options.DbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName()); + }); + + var client = new SqlSugarClient(connectionConfig); + ConfigureDbAop(client); + _sqlSugarClient = client; + return _sqlSugarClient; + } } /// diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs index 38438c3..9c748e4 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs @@ -1,5 +1,6 @@ using Volo.Abp; using Volo.Abp.MultiTenancy; +using Yi.Framework.SqlSugarCore.Abstractions; namespace FoodLabeling.Application.Helpers; @@ -8,9 +9,15 @@ namespace FoodLabeling.Application.Helpers; /// public static class TenantContextGuard { + /// + /// 平台主库登录(JWT/__tenant 均无业务租户)时访问 fl_* 等业务表的友好提示。 + /// + public const string PlatformCannotAccessBusinessDataMessage = + "当前为平台主库登录,无法访问公司业务数据(标签/产品/成员等)。请选择具体公司登录,或使用平台「公司管理」相关接口。"; + public static void EnsureTenantResolved(ICurrentTenant currentTenant, string? operation = null) { - if (currentTenant.Id.HasValue) + if (currentTenant.Id.HasValue && currentTenant.Id.Value != Guid.Empty) { return; } @@ -19,6 +26,78 @@ public static class TenantContextGuard ? "未识别租户上下文" : $"{operation}:未识别租户上下文"; throw new UserFriendlyException( - $"{hint}。请使用泰额登录接口(th-web-auth / th-app-auth)或请求头 __tenant 携带租户 Id。"); + $"{hint}。请使用泰额登录接口(th-web-auth / th-app-auth)选择具体公司登录,或请求头 __tenant 携带租户 Id。"); + } + + /// + /// 泰额 SaaS 多租户开启时,业务表(fl_* / location 等)必须走租户库,禁止落到 host。 + /// + public static void EnsureBusinessTenantIfSaas( + ICurrentTenant currentTenant, + DbConnOptions? dbConnOptions, + string? operation = null) + { + if (dbConnOptions is null || !dbConnOptions.EnabledSaasMultiTenancy) + { + return; + } + + EnsureTenantResolved(currentTenant, operation); + } + + /// + /// 判断当前 DbContext 是否连到平台主库(antis-foodlabeling-host)。 + /// + public static bool IsConnectedToHostDatabase(ISqlSugarDbContext dbContext, DbConnOptions dbConnOptions) + { + var dbName = dbContext.SqlSugarClient.Ado.Connection.Database; + var hostDbName = TryExtractDatabaseName(dbConnOptions.Url); + return !string.IsNullOrWhiteSpace(hostDbName) + && string.Equals(dbName, hostDbName, StringComparison.OrdinalIgnoreCase); + } + + /// + /// SaaS 模式下校验 DbContext 未落到 host 主库(双保险,避免缺表 500)。 + /// + public static void EnsureNotHostDatabaseIfSaas( + ISqlSugarDbContext dbContext, + DbConnOptions dbConnOptions, + string? operation = null) + { + if (!dbConnOptions.EnabledSaasMultiTenancy) + { + return; + } + + if (!IsConnectedToHostDatabase(dbContext, dbConnOptions)) + { + return; + } + + var hint = string.IsNullOrWhiteSpace(operation) + ? PlatformCannotAccessBusinessDataMessage + : $"{operation}:{PlatformCannotAccessBusinessDataMessage}"; + throw new UserFriendlyException(hint); + } + + private static string? TryExtractDatabaseName(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + return null; + } + + foreach (var part in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) + { + var kv = part.Split('=', 2, StringSplitOptions.TrimEntries); + if (kv.Length == 2 + && (kv[0].Equals("database", StringComparison.OrdinalIgnoreCase) + || kv[0].Equals("Database", StringComparison.OrdinalIgnoreCase))) + { + return kv[1].Trim(); + } + } + + return null; } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs index 3d4c4fa..fecda9c 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs @@ -1,3 +1,7 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; using Microsoft.AspNetCore.Http; using Volo.Abp.MultiTenancy; using Volo.Abp.Security.Claims; @@ -16,20 +20,75 @@ public class JwtClaimTenantResolveContributor : TenantResolveContributorBase public override Task ResolveAsync(ITenantResolveContext context) { - var httpContext = context.ServiceProvider.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor; - var user = httpContext?.HttpContext?.User; - if (user?.Identity?.IsAuthenticated != true) + var httpContext = (context.ServiceProvider.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor) + ?.HttpContext; + if (httpContext is null) { return Task.CompletedTask; } - var tenantClaim = user.FindFirst(TokenTypeConst.TenantId)?.Value - ?? user.FindFirst(AbpClaimTypes.TenantId)?.Value; - if (!string.IsNullOrWhiteSpace(tenantClaim)) + var tenantClaim = TryGetTenantIdFromPrincipal(httpContext.User) + ?? TryGetTenantIdFromAuthorizationHeader( + httpContext.Request.Headers.Authorization.ToString()); + + if (!string.IsNullOrWhiteSpace(tenantClaim) + && Guid.TryParse(tenantClaim, out var tenantGuid) + && tenantGuid != Guid.Empty) { context.TenantIdOrName = tenantClaim; } return Task.CompletedTask; } + + private static string? TryGetTenantIdFromPrincipal(ClaimsPrincipal? user) + { + if (user?.Identity?.IsAuthenticated != true) + { + return null; + } + + return user.FindFirst(TokenTypeConst.TenantId)?.Value + ?? user.FindFirst(AbpClaimTypes.TenantId)?.Value + ?? user.Claims.FirstOrDefault(c => + c.Type.EndsWith("tenantId", StringComparison.OrdinalIgnoreCase) + || c.Type.Equals("tenantid", StringComparison.OrdinalIgnoreCase))?.Value; + } + + /// + /// 多租户中间件可能早于 JWT Principal 就绪;直接从 Authorization 解析 TenantId Claim。 + /// + private static string? TryGetTenantIdFromAuthorizationHeader(string? authorization) + { + if (string.IsNullOrWhiteSpace(authorization)) + { + return null; + } + + const string bearerPrefix = "Bearer "; + if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var jwt = authorization[bearerPrefix.Length..].Trim(); + if (string.IsNullOrWhiteSpace(jwt)) + { + return null; + } + + try + { + var token = new JwtSecurityTokenHandler().ReadJwtToken(jwt); + return token.Claims.FirstOrDefault(c => + c.Type == TokenTypeConst.TenantId + || c.Type == AbpClaimTypes.TenantId + || c.Type.EndsWith("tenantId", StringComparison.OrdinalIgnoreCase)) + ?.Value; + } + catch (Exception) + { + return null; + } + } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs index 8d05650..c63f9af 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs @@ -4,6 +4,7 @@ using FoodLabeling.Application.Contracts.Dtos.Group; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; @@ -25,16 +26,22 @@ public class GroupAppService : ApplicationService, IGroupAppService private readonly ISqlSugarDbContext _dbContext; private readonly IGuidGenerator _guidGenerator; + private readonly DbConnOptions _dbConnOptions; - public GroupAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) + public GroupAppService( + ISqlSugarDbContext dbContext, + IGuidGenerator guidGenerator, + IOptions dbConnOptions) { _dbContext = dbContext; _guidGenerator = guidGenerator; + _dbConnOptions = dbConnOptions.Value; } /// public async Task> GetListAsync(GroupGetListInputVo input) { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Region"); RefAsync total = 0; var query = await BuildGroupJoinedQueryAsync(input); var projected = query.Select((g, p) => new GroupGetListOutputDto diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs index 4c8f5dc..7131105 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs @@ -6,6 +6,7 @@ using FoodLabeling.Application.Contracts.Dtos.LabelTemplate; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using FoodLabeling.Domain.Entities; +using Microsoft.Extensions.Options; using SqlSugar; using Volo.Abp; using Volo.Abp.Application.Services; @@ -22,15 +23,21 @@ public class LabelAppService : ApplicationService, ILabelAppService { private readonly ISqlSugarDbContext _dbContext; private readonly IGuidGenerator _guidGenerator; + private readonly DbConnOptions _dbConnOptions; - public LabelAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) + public LabelAppService( + ISqlSugarDbContext dbContext, + IGuidGenerator guidGenerator, + IOptions dbConnOptions) { _dbContext = dbContext; _guidGenerator = guidGenerator; + _dbConnOptions = dbConnOptions.Value; } public async Task> GetListAsync(LabelGetListInputVo input) { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Label"); RefAsync total = 0; var productId = input.ProductId?.Trim(); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs index 76542b9..acfac5a 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs @@ -24,20 +24,24 @@ public class LocationAppService : ApplicationService, ILocationAppService private readonly ISqlSugarRepository _locationRepository; private readonly ISqlSugarDbContext _dbContext; private readonly IOptionsSnapshot _batchImportOptions; + private readonly DbConnOptions _dbConnOptions; public LocationAppService( ISqlSugarRepository locationRepository, ISqlSugarDbContext dbContext, - IOptionsSnapshot batchImportOptions) + IOptionsSnapshot batchImportOptions, + IOptions dbConnOptions) { _locationRepository = locationRepository; _dbContext = dbContext; _batchImportOptions = batchImportOptions; + _dbConnOptions = dbConnOptions.Value; } /// public async Task> GetListAsync([FromQuery] LocationGetListInputVo input) { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Location"); RefAsync total = 0; var query = await BuildFilteredQueryAsync(input); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs index ab9f5fb..927c724 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PartnerAppService.cs @@ -4,6 +4,7 @@ using FoodLabeling.Application.Contracts.Dtos.Partner; using FoodLabeling.Application.Contracts.IServices; using FoodLabeling.Application.Services.DbModels; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using QuestPDF.Fluent; using QuestPDF.Helpers; using QuestPDF.Infrastructure; @@ -25,16 +26,22 @@ public class PartnerAppService : ApplicationService, IPartnerAppService private readonly ISqlSugarDbContext _dbContext; private readonly IGuidGenerator _guidGenerator; + private readonly DbConnOptions _dbConnOptions; - public PartnerAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) + public PartnerAppService( + ISqlSugarDbContext dbContext, + IGuidGenerator guidGenerator, + IOptions dbConnOptions) { _dbContext = dbContext; _guidGenerator = guidGenerator; + _dbConnOptions = dbConnOptions.Value; } /// public async Task> GetListAsync(PartnerGetListInputVo input) { + EnsureBusinessTenantContext("查询 Company"); RefAsync total = 0; var query = await BuildPartnerListQueryAsync(input); @@ -46,6 +53,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService /// public async Task GetAsync(Guid id) { + EnsureBusinessTenantContext("查询 Company"); if (id == Guid.Empty) { throw new UserFriendlyException("Partner id is required."); @@ -66,6 +74,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService [UnitOfWork] public async Task CreateAsync(PartnerCreateInputVo input) { + EnsureBusinessTenantContext("创建 Company"); var name = input.PartnerName?.Trim(); if (string.IsNullOrWhiteSpace(name)) { @@ -102,6 +111,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService [UnitOfWork] public async Task UpdateAsync(Guid id, PartnerUpdateInputVo input) { + EnsureBusinessTenantContext("更新 Company"); if (id == Guid.Empty) { throw new UserFriendlyException("Partner id is required."); @@ -143,6 +153,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService [UnitOfWork] public async Task DeleteAsync(Guid id) { + EnsureBusinessTenantContext("删除 Company"); if (id == Guid.Empty) { throw new UserFriendlyException("Partner id is required."); @@ -166,6 +177,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService [HttpGet] public async Task ExportPdfAsync([FromQuery] PartnerGetListInputVo input) { + EnsureBusinessTenantContext("导出 Company"); QuestPDF.Settings.License = LicenseType.Community; var count = await (await BuildPartnerListQueryAsync(input)).CountAsync(); @@ -340,6 +352,11 @@ public class PartnerAppService : ApplicationService, IPartnerAppService dto.ZipCode = entity.ZipCode; } + private void EnsureBusinessTenantContext(string operation) + { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, operation); + } + private static string? TrimToNull(string? value) { var t = value?.Trim(); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs index 37fa92b..eb2f827 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs @@ -34,24 +34,28 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService private readonly ISqlSugarDbContext _dbContext; private readonly IGuidGenerator _guidGenerator; private readonly IOptionsSnapshot _batchImportOptions; + private readonly DbConnOptions _dbConnOptions; public TeamMemberAppService( ISqlSugarRepository userRepository, UserManager userManager, ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator, - IOptionsSnapshot batchImportOptions) + IOptionsSnapshot batchImportOptions, + IOptions dbConnOptions) { _userRepository = userRepository; _userManager = userManager; _dbContext = dbContext; _guidGenerator = guidGenerator; _batchImportOptions = batchImportOptions; + _dbConnOptions = dbConnOptions.Value; } /// public async Task> GetListAsync(TeamMemberGetListInputVo input) { + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Team Member"); var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount); var pageSize = input.MaxResultCount; RefAsync total = 0; 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 index 078acbc..8dd4e8b 100644 --- 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 @@ -1,11 +1,11 @@ namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; /// -/// SaaS 菜单权限树节点 +/// SaaS / 平台分配菜单树节点 /// public class ThSaasMenuPermissionTreeNodeDto { - /// 权限 Key(如 labeling:labels) + /// 菜单 Id(与主库/租户库 Menu.Id 一致) public string Key { get; set; } = string.Empty; /// 中文标题 diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Filters/FoodLabelingBusinessTenantActionFilter.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Filters/FoodLabelingBusinessTenantActionFilter.cs new file mode 100644 index 0000000..cbf7839 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Filters/FoodLabelingBusinessTenantActionFilter.cs @@ -0,0 +1,105 @@ +using FoodLabeling.Application.Helpers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; +using Yi.Framework.SqlSugarCore.Abstractions; + +namespace FoodLabeling.Th.Application.Filters; + +/// +/// 泰额 SaaS:业务 AppService(fl_* / 租户 menu 等)须已解析租户上下文,禁止落到 host 主库。 +/// 平台登录误调业务接口时返回友好错误,避免 fl_label 缺表 500。 +/// +public class FoodLabelingBusinessTenantActionFilter : IAsyncActionFilter, ITransientDependency +{ + private readonly ICurrentTenant _currentTenant; + private readonly DbConnOptions _dbConnOptions; + private readonly ISqlSugarDbContext _dbContext; + + public FoodLabelingBusinessTenantActionFilter( + ICurrentTenant currentTenant, + IOptions dbConnOptions, + ISqlSugarDbContext dbContext) + { + _currentTenant = currentTenant; + _dbConnOptions = dbConnOptions.Value; + _dbContext = dbContext; + } + + public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!_dbConnOptions.EnabledSaasMultiTenancy) + { + return next(); + } + + var path = context.HttpContext.Request.Path.Value ?? string.Empty; + if (!RequiresBusinessTenant(path)) + { + return next(); + } + + TenantContextGuard.EnsureBusinessTenantIfSaas(_currentTenant, _dbConnOptions, ResolveOperationLabel(path)); + TenantContextGuard.EnsureNotHostDatabaseIfSaas( + _dbContext, + _dbConnOptions, + ResolveOperationLabel(path)); + + return next(); + } + + /// + /// 平台主库接口(yitenant / 登录 / 开户等)不要求业务租户上下文。 + /// + internal static bool RequiresBusinessTenant(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + if (!path.StartsWith("/api/app", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var lower = path.ToLowerInvariant(); + foreach (var fragment in PlatformPathFragments) + { + if (lower.Contains(fragment, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + private static string ResolveOperationLabel(string path) + { + var segment = path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); + return string.IsNullOrWhiteSpace(segment) ? "业务接口" : segment; + } + + /// 平台侧路径片段(小写),命中则跳过业务租户校验。 + private static readonly string[] PlatformPathFragments = + { + "/th-web-auth", + "/th-app-auth", + "/th-multi-tenancy", + "/th-tenant-provisioning", + "/th-tenant-select", + "/account", + "/oauth", + "/captcha", + "/forgot-password", + "/authorization", + "/login", + "/logout", + "/wwwroot", + "/hangfire", + "/demo", + "/food-label-demo", + }; +} diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs index fdfa398..6dd6798 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs @@ -1,7 +1,9 @@ using FoodLabeling.Application; using FoodLabeling.Th.Application.Contracts; using FoodLabeling.Th.Application.Contracts.Options; +using FoodLabeling.Th.Application.Filters; using FoodLabeling.Th.Domain; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Yi.Framework.Ddd.Application; @@ -31,5 +33,10 @@ public class FoodLabelingThApplicationModule : AbpModule Configure( configuration.GetSection(FoodLabelingThTenantSelectCryptoOptions.SectionName)); + + Configure(options => + { + options.Filters.AddService(); + }); } } 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 index d2b1c16..7ae3faa 100644 --- 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 @@ -1,106 +1,190 @@ using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; +using Yi.Framework.Rbac.Domain.Entities; namespace FoodLabeling.Th.Application.MultiTenancy; /// -/// 泰额版 SaaS 菜单权限目录(与前端 saas-menu-tree 一致) +/// 平台可分配给公司的菜单:以主库 Menu 为准;并兼容历史 SaaS Key → 菜单 Id 映射。 /// public static class ThSaasMenuPermissionCatalog { - private static readonly Lazy> TreeLazy = - new(BuildTree); - - private static readonly Lazy> AllKeysLazy = - new(() => new HashSet(CollectAllKeys(TreeLazy.Value), StringComparer.OrdinalIgnoreCase)); - /// - /// 菜单权限树 + /// 历史静态 SaaS Key → 固定菜单 Guid(th-tenant-menu-seed) /// - public static IReadOnlyList Tree => TreeLazy.Value; + private static readonly Dictionary LegacyKeyToMenuId = + new(StringComparer.OrdinalIgnoreCase) + { + ["dashboard"] = "f0010001-0001-4000-8000-000000000001", + ["dashboard:analytics"] = "f0010001-0001-4000-8000-000000000001", + ["labeling"] = "f0010010-0001-4000-8000-000000000010", + ["labeling:labels"] = "f0010011-0001-4000-8000-000000000011", + ["labeling:categories"] = "f0010012-0001-4000-8000-000000000012", + ["labeling:types"] = "f0010013-0001-4000-8000-000000000013", + ["labeling:templates"] = "f0010014-0001-4000-8000-000000000014", + ["labeling:multiple-options"] = "f0010015-0001-4000-8000-000000000015", + ["modules"] = "f0010020-0001-4000-8000-000000000020", + ["modules:training"] = "f0010021-0001-4000-8000-000000000021", + ["modules:alerts"] = "f0010022-0001-4000-8000-000000000022", + ["modules:tasks"] = "f0010023-0001-4000-8000-000000000023", + ["modules:sensors"] = "f0010024-0001-4000-8000-000000000024", + ["modules:food-waste"] = "f0010025-0001-4000-8000-000000000025", + ["modules:e-label"] = "f0010026-0001-4000-8000-000000000026", + ["management"] = "f0010030-0001-4000-8000-000000000030", + ["management:account"] = "f0010031-0001-4000-8000-000000000031", + ["management:system-menu"] = "f0010032-0001-4000-8000-000000000032", + ["management:menu"] = "f0010033-0001-4000-8000-000000000033", + ["management:devices"] = "f0010034-0001-4000-8000-000000000034", + ["management:reports"] = "f0010035-0001-4000-8000-000000000035", + ["management:invoices"] = "f0010036-0001-4000-8000-000000000036", + ["management:qr-codes"] = "f0010037-0001-4000-8000-000000000037", + ["management:support"] = "f0010038-0001-4000-8000-000000000038", + ["management:api"] = "f0010039-0001-4000-8000-000000000039", + }; /// - /// 全部合法 permission key(含父节点) + /// 是否为仅平台端菜单(不可分配给公司) /// - public static IReadOnlySet AllKeys => AllKeysLazy.Value; + public static bool IsPlatformOnlyMenu(MenuAggregateRoot menu) + { + if (menu == null) + { + return true; + } + + var code = menu.PermissionCode?.Trim() ?? string.Empty; + if (code.StartsWith("menu.platform", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var router = menu.Router?.Trim() ?? string.Empty; + return router.StartsWith("/platform", StringComparison.OrdinalIgnoreCase); + } /// - /// 校验 key 是否合法;返回非法 key 列表 + /// 将历史 SaaS Key / PermissionCode / 菜单 Id 统一为菜单 Id 字符串 /// - public static List FindInvalidKeys(IEnumerable? keys) + public static List NormalizeToMenuIds( + IEnumerable? keys, + IReadOnlyDictionary? permissionCodeToMenuId = null) { 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(); + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var raw in keys) + { + if (string.IsNullOrWhiteSpace(raw)) + { + continue; + } + + var key = raw.Trim(); + string? menuId = null; + + if (Guid.TryParse(key, out _)) + { + menuId = key; + } + else if (LegacyKeyToMenuId.TryGetValue(key, out var mapped)) + { + menuId = mapped; + } + else if (permissionCodeToMenuId != null + && permissionCodeToMenuId.TryGetValue(key, out var byCode)) + { + menuId = byCode; + } + + if (string.IsNullOrWhiteSpace(menuId) || !seen.Add(menuId)) + { + continue; + } + + result.Add(menuId); + } + + return result; } - private static IReadOnlyList BuildTree() => - new List + public static List BuildTree(IEnumerable menus) + { + var list = menus + .Where(m => m != null && !m.IsDeleted && !IsPlatformOnlyMenu(m)) + .OrderBy(m => m.OrderNum) + .ThenBy(m => m.MenuName) + .ToList(); + + var nodes = list.ToDictionary( + m => m.Id.ToString(), + m => new ThSaasMenuPermissionTreeNodeDto + { + Key = m.Id.ToString(), + Title = string.IsNullOrWhiteSpace(m.MenuName) ? m.Id.ToString() : m.MenuName!, + Children = new List() + }, + StringComparer.OrdinalIgnoreCase); + + var roots = new List(); + foreach (var menu in 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")) - }; + var node = nodes[menu.Id.ToString()]; + var parentId = string.IsNullOrWhiteSpace(menu.ParentId) ? "0" : menu.ParentId.Trim(); + if (parentId == "0" + || parentId == Guid.Empty.ToString() + || !nodes.TryGetValue(parentId, out var parent)) + { + roots.Add(node); + continue; + } + + parent.Children ??= new List(); + parent.Children.Add(node); + } + + NormalizeEmptyChildren(roots); + return roots; + } + + public static HashSet CollectAssignableMenuIds(IEnumerable menus) + { + return menus + .Where(m => m != null && !m.IsDeleted && !IsPlatformOnlyMenu(m)) + .Select(m => m.Id.ToString()) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } - private static ThSaasMenuPermissionTreeNodeDto Node( - string key, - string title, - params ThSaasMenuPermissionTreeNodeDto[] children) + public static List FindInvalidMenuIds( + IEnumerable? keys, + IReadOnlySet assignableMenuIds) { - return new ThSaasMenuPermissionTreeNodeDto + if (keys == null) { - Key = key, - Title = title, - Children = children.Length == 0 ? null : children.ToList() - }; + return new List(); + } + + return keys + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(x => x.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Where(x => !assignableMenuIds.Contains(x)) + .ToList(); } - private static IEnumerable CollectAllKeys(IEnumerable nodes) + private static void NormalizeEmptyChildren(List nodes) { foreach (var node in nodes) { - yield return node.Key; - if (node.Children == null) + if (node.Children == null || node.Children.Count == 0) { + node.Children = null; continue; } - foreach (var childKey in CollectAllKeys(node.Children)) - { - yield return childKey; - } + NormalizeEmptyChildren(node.Children); } } } diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs index eaccce7..4ec3352 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs @@ -14,9 +14,9 @@ public static class ThWebPlatformLoginHelper /// 平台登录成功时 th-web-auth 返回的 tenantName(无 TenantId Claim) public const string PlatformTenantDisplayName = "Platform"; - /// 平台账号误选业务租户时的登录拒绝提示 + /// 平台邮箱账号误选业务租户时的登录拒绝提示 public const string PlatformAccountMustUseDefaultMessage = - "登录失败:平台管理员账号请选择 Default 选项登录,不能使用业务租户登录"; + "登录失败:平台管理员邮箱请选择 Default 选项登录,不能使用业务租户登录"; /// /// 是否为业务租户登录(选了具体公司,而非 Default / 空 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 549da0a..3beeca3 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 @@ -46,6 +46,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe private readonly ISqlSugarRepository _tenantRepository; private readonly ISqlSugarRepository _credentialRepository; private readonly ISqlSugarRepository _menuPermissionRepository; + private readonly ISqlSugarRepository _menuRepository; private readonly TenantSelectCredentialCipher _credentialCipher; private readonly RbacOptions _rbacOptions; private readonly FoodLabelingThTenantDatabaseOptions _tenantDatabaseOptions; @@ -57,6 +58,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe ISqlSugarRepository tenantRepository, ISqlSugarRepository credentialRepository, ISqlSugarRepository menuPermissionRepository, + ISqlSugarRepository menuRepository, TenantSelectCredentialCipher credentialCipher, IOptions rbacOptions, IOptions tenantDatabaseOptions, @@ -67,6 +69,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe _tenantRepository = tenantRepository; _credentialRepository = credentialRepository; _menuPermissionRepository = menuPermissionRepository; + _menuRepository = menuRepository; _credentialCipher = credentialCipher; _rbacOptions = rbacOptions.Value; _tenantDatabaseOptions = tenantDatabaseOptions.Value; @@ -333,33 +336,17 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe } /// - /// 获取 SaaS 菜单权限树 + /// 获取可分配给公司的平台菜单树(主库 Menu,排除仅平台端菜单) /// /// - /// 供平台管理员配置租户菜单权限时使用;Key 与前端 saas-menu-tree 一致。 - /// - /// 示例响应: - /// ```json - /// [ - /// { - /// "key": "dashboard", - /// "title": "仪表盘", - /// "children": [ - /// { "key": "dashboard:analytics", "title": "数据分析" } - /// ] - /// } - /// ] - /// ``` + /// 节点 key = 菜单 Id(与租户业务库固定 Guid 种子一致)。 /// - /// SaaS 菜单权限树 - /// 成功返回权限树 - /// 未登录 - /// 服务器错误 [Authorize] [HttpGet("th-multi-tenancy/menu-permission-tree")] - public virtual Task> GetMenuPermissionTreeAsync() + public virtual async Task> GetMenuPermissionTreeAsync() { - return Task.FromResult(CloneTree(ThSaasMenuPermissionCatalog.Tree)); + var menus = await LoadHostMenusAsync(); + return ThSaasMenuPermissionCatalog.BuildTree(menus); } /// @@ -441,8 +428,17 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe await EnsureTenantExistsAsync(input.TenantId); - var normalizedKeys = NormalizePermissionKeys(input.MenuPermissionKeys); - var invalidKeys = ThSaasMenuPermissionCatalog.FindInvalidKeys(normalizedKeys); + var hostMenus = await LoadHostMenusAsync(); + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); + var permissionCodeMap = hostMenus + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); + + var normalizedKeys = ThSaasMenuPermissionCatalog.NormalizeToMenuIds( + input.MenuPermissionKeys, + permissionCodeMap); + var invalidKeys = ThSaasMenuPermissionCatalog.FindInvalidMenuIds(normalizedKeys, assignableIds); if (invalidKeys.Count > 0) { throw new UserFriendlyException($"存在非法菜单权限 Key:{string.Join(", ", invalidKeys)}"); @@ -454,6 +450,8 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe if (normalizedKeys.Count == 0) { + // 清空公司开通菜单时,同步清空租户管理员角色菜单 + await SyncTenantAdminRoleMenusAsync(input.TenantId, Array.Empty()); return; } @@ -468,6 +466,12 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe await _menuPermissionRepository.InsertRangeAsync(entities); } + + var menuIds = normalizedKeys + .Select(x => Guid.TryParse(x, out var id) ? id : Guid.Empty) + .Where(x => x != Guid.Empty) + .ToList(); + await SyncTenantAdminRoleMenusAsync(input.TenantId, menuIds); } /// @@ -590,6 +594,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe var tenant = await EnsureTenantExistsAsync(input.TenantId); var menuIds = ParseMenuIds(input.MenuIds); + + // 角色菜单不得超过平台分配给该公司的菜单范围 + var allowedKeys = await LoadMenuPermissionKeysAsync(input.TenantId); + if (allowedKeys.Count == 0) + { + if (menuIds.Count > 0) + { + throw new UserFriendlyException("该公司尚未开通任何菜单,请先在「菜单权限」中分配"); + } + } + else + { + var allowed = allowedKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); + var outOfScope = menuIds + .Select(x => x.ToString()) + .Where(x => !allowed.Contains(x)) + .ToList(); + if (outOfScope.Count > 0) + { + throw new UserFriendlyException( + $"角色菜单超出公司已开通范围:{string.Join(", ", outOfScope)}"); + } + } + var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant); using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, input.TenantId); @@ -905,14 +933,93 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe private async Task> LoadMenuPermissionKeysAsync(Guid tenantId) { + List rawKeys; using (UseHostTenantScope()) { - return await _menuPermissionRepository._DbQueryable + rawKeys = await _menuPermissionRepository._DbQueryable .Where(x => x.TenantId == tenantId) .OrderBy(x => x.CreationTime) .Select(x => x.PermissionKey) .ToListAsync(); } + + var hostMenus = await LoadHostMenusAsync(); + var permissionCodeMap = hostMenus + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); + return ThSaasMenuPermissionCatalog.NormalizeToMenuIds(rawKeys, permissionCodeMap) + .Where(assignableIds.Contains) + .ToList(); + } + + private async Task> LoadHostMenusAsync() + { + using (UseHostTenantScope()) + { + return await _menuRepository._DbQueryable + .Where(x => !x.IsDeleted) + .OrderBy(x => x.OrderNum) + .ToListAsync(); + } + } + + /// + /// 公司管理员角色菜单 = 平台分配给该公司的菜单集合 + /// + private async Task SyncTenantAdminRoleMenusAsync(Guid tenantId, IReadOnlyCollection menuIds) + { + var tenant = await EnsureTenantExistsAsync(tenantId); + var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant); + using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); + TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, tenantId); + + var adminRoles = await tenantDb.Queryable() + .Where(r => !r.IsDeleted) + .Where(r => r.RoleCode == UserConst.AdminRolesCode || r.RoleCode == UserConst.Admin) + .OrderBy(r => r.OrderNum) + .Take(1) + .ToListAsync(); + var adminRole = adminRoles.FirstOrDefault(); + + if (adminRole is null) + { + return; + } + + await tenantDb.Deleteable() + .Where(x => x.RoleId == adminRole.Id) + .ExecuteCommandAsync(); + + if (menuIds.Count == 0) + { + return; + } + + var existMenuIds = await tenantDb.Queryable() + .Where(x => !x.IsDeleted) + .Where(x => menuIds.Contains(x.Id)) + .Select(x => x.Id) + .ToListAsync(); + + if (existMenuIds.Count == 0) + { + return; + } + + var entities = existMenuIds.Select(menuId => + { + var entity = new RoleMenuEntity + { + RoleId = adminRole.Id, + MenuId = menuId + }; + EntityHelper.TrySetId(entity, () => GuidGenerator.Create()); + return entity; + }).ToList(); + + await tenantDb.Insertable(entities).ExecuteCommandAsync(); } private async Task>> LoadMenuPermissionMapAsync( @@ -923,32 +1030,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe return new Dictionary>(); } + List list; using (UseHostTenantScope()) { - var list = await _menuPermissionRepository._DbQueryable + list = await _menuPermissionRepository._DbQueryable .Where(x => tenantIds.Contains(x.TenantId)) .ToListAsync(); - - return list - .GroupBy(x => x.TenantId) - .ToDictionary( - g => g.Key, - g => g.Select(x => x.PermissionKey).Distinct(StringComparer.OrdinalIgnoreCase).ToList()); } - } - private static List NormalizePermissionKeys(IEnumerable? keys) - { - if (keys == null) - { - return new List(); - } + var hostMenus = await LoadHostMenusAsync(); + var permissionCodeMap = hostMenus + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); - return keys - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Select(x => x.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + return list + .GroupBy(x => x.TenantId) + .ToDictionary( + g => g.Key, + g => ThSaasMenuPermissionCatalog.NormalizeToMenuIds( + g.Select(x => x.PermissionKey), + permissionCodeMap) + .Where(assignableIds.Contains) + .ToList()); } private static List ParseMenuIds(IEnumerable? menuIds) @@ -981,17 +1086,6 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe return result; } - private static List CloneTree( - IEnumerable nodes) - { - return nodes.Select(node => new ThSaasMenuPermissionTreeNodeDto - { - Key = node.Key, - Title = node.Title, - Children = node.Children == null ? null : CloneTree(node.Children) - }).ToList(); - } - private static void EnsureTenantDeletable(Guid tenantId, string? tenantName) { if (tenantId == ProtectedDefaultTenantId) diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs index afc01a1..c9b8cb1 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs @@ -49,7 +49,8 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService /// 校验租户后在租户独立库验证账号,签发含 TenantId 与 RBAC 权限的 JWT。 /// /// 泰额 H5 与公司 Web 共用此接口。选 **Default** 或 tenantId 为空时,若邮箱账号存在于主库则走**平台登录**(JWT 无 TenantId); - /// 选具体公司 tenantId 时在该公司业务库校验;若账号已存在于主库(平台管理员),则拒绝登录并提示改用 Default。 + /// 选具体公司 tenantId 时在该公司业务库校验;若登录标识为邮箱且已存在于主库(平台管理员邮箱),则拒绝并提示改用 Default。 + /// 租户默认账号 UserName=admin 与主库同名时允许业务租户登录。 /// Default 业务库(如 US)仅在主库无该邮箱时作为回落。 /// /// 示例请求(平台,选 Default): @@ -162,7 +163,8 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService } /// - /// 业务租户登录前校验:主库已存在的账号(平台管理员)不得落入公司业务库。 + /// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。 + /// 仅拦截邮箱形态(与 TryPlatformLoginAsync 一致);租户默认 UserName=admin 与主库 admin 同名时允许走租户库。 /// private async Task EnsureNotHostPlatformAccountAsync(string userName) { @@ -171,6 +173,12 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService return; } + // 租户管理员默认账号常为 admin,与主库平台 UserName 同名;不可按用户名拦截 + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(userName)) + { + return; + } + using (CurrentTenant.Change(null)) { var hostUser = await FindActiveUserByEmailAsync(userName.Trim()); diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs index cdd4883..b268bad 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -9,6 +10,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Volo.Abp.DependencyInjection; +using Volo.Abp.MultiTenancy; using Volo.Abp.Security.Claims; using Yi.Framework.Rbac.Domain.Managers; using Yi.Framework.Rbac.Domain.Shared.Consts; @@ -18,11 +20,13 @@ namespace Yi.Framework.Rbac.Domain.Authorization [DebuggerStepThrough] public class RefreshTokenMiddleware : IMiddleware, ITransientDependency { - private AccountManager _accountManager; - public RefreshTokenMiddleware(AccountManager accountManager) - { + private readonly AccountManager _accountManager; + private readonly ICurrentTenant _currentTenant; + public RefreshTokenMiddleware(AccountManager accountManager, ICurrentTenant currentTenant) + { _accountManager = accountManager; + _currentTenant = currentTenant; } public async Task InvokeAsync(HttpContext context, RequestDelegate next) @@ -36,18 +40,75 @@ namespace Yi.Framework.Rbac.Domain.Authorization if (authResult.Succeeded) { var userId = Guid.Parse(authResult.Principal.FindFirst(AbpClaimTypes.UserId).Value.ToString()); - var access_Token = await _accountManager.GetTokenByUserIdAsync(userId); - var refresh_Token = _accountManager.CreateRefreshToken(userId); - context.Response.Headers["access_token"] = access_Token; - context.Response.Headers["refresh_token"] = refresh_Token; - + var tenantId = TryResolveTenantIdFromRequest(context); + using (tenantId.HasValue + ? _currentTenant.Change(tenantId.Value) + : _currentTenant.Change(null)) + { + var access_Token = await _accountManager.GetTokenByUserIdAsync(userId); + var refresh_Token = _accountManager.CreateRefreshToken(userId); + context.Response.Headers["access_token"] = access_Token; + context.Response.Headers["refresh_token"] = refresh_Token; + } //请求头替换,补充后续鉴权逻辑 - context.Request.Headers["Authorization"] = "Bearer " + access_Token; + context.Request.Headers["Authorization"] = "Bearer " + context.Response.Headers["access_token"]; } } await next(context); } + + /// + /// 刷新 access token 时保留业务租户上下文(__tenant 或旧 JWT 中的 TenantId)。 + /// + private static Guid? TryResolveTenantIdFromRequest(HttpContext context) + { + if (context.Request.Headers.TryGetValue("__tenant", out var headerVal)) + { + var headerText = headerVal.ToString(); + if (Guid.TryParse(headerText, out var fromHeader) && fromHeader != Guid.Empty) + { + return fromHeader; + } + } + + var authorization = context.Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(authorization)) + { + return null; + } + + const string bearerPrefix = "Bearer "; + if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var jwt = authorization[bearerPrefix.Length..].Trim(); + if (string.IsNullOrWhiteSpace(jwt)) + { + return null; + } + + try + { + var token = new JwtSecurityTokenHandler().ReadJwtToken(jwt); + var tenantClaim = token.Claims.FirstOrDefault(c => + c.Type == TokenTypeConst.TenantId + || c.Type == AbpClaimTypes.TenantId) + ?.Value; + if (Guid.TryParse(tenantClaim, out var tenantId) && tenantId != Guid.Empty) + { + return tenantId; + } + } + catch (Exception) + { + return null; + } + + return null; + } } diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-role-menu-drawer.vue b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-role-menu-drawer.vue index 22b21ee..7d961dc 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-role-menu-drawer.vue +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-role-menu-drawer.vue @@ -14,6 +14,7 @@ import { $t } from '@vben/locales'; import { Empty, Select, Tag, message } from 'ant-design-vue'; import { + thCompanyMenus, thCompanyRoles, thMenuPermissionTree, thUpdateCompanyRoleMenus, @@ -26,6 +27,7 @@ const roles = ref([]); const selectedRoleId = ref(); const menuKeys = ref([]); const menuTree = ref([]); +const companyMenuKeys = ref([]); const roleOptions = computed(() => roles.value.map((role) => ({ @@ -56,11 +58,13 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ drawerApi.drawerLoading(true); try { - const [tree, roleList] = await Promise.all([ + const [tree, menus, roleList] = await Promise.all([ thMenuPermissionTree(), + thCompanyMenus(found.id), thCompanyRoles(found.id), ]); menuTree.value = tree; + companyMenuKeys.value = menus.menuPermissionKeys ?? []; roles.value = roleList; selectedRoleId.value = roleList[0]?.id; syncSelectedRoleMenus(); @@ -76,6 +80,7 @@ function resetState() { selectedRoleId.value = undefined; menuKeys.value = []; menuTree.value = []; + companyMenuKeys.value = []; } function syncSelectedRoleMenus() { @@ -122,6 +127,7 @@ async function handleSave() { diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue index 53e2756..9baf585 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue @@ -1,13 +1,33 @@ diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue index 86d7983..b8cec83 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue @@ -39,13 +39,16 @@ const modelValue = defineModel({ default: () => [] }); const checkedKeys = computed({ get: () => modelValue.value ?? [], set: (keys: string[]) => { - modelValue.value = normalizeMenuKeysWithParents(keys); + modelValue.value = normalizeAgainstTree(keys); }, }); const treeData = computed(() => { if (props.nodes.length > 0) { - return mapBackendNodes(props.nodes); + const filtered = props.allowedKeys?.length + ? filterBackendNodes(props.nodes, props.allowedKeys) + : props.nodes; + return mapBackendNodes(filtered); } const nodes = props.allowedKeys?.length @@ -54,6 +57,54 @@ const treeData = computed(() => { return mapNodes(nodes); }); +function normalizeAgainstTree(keys: string[]) { + if (props.nodes.length > 0) { + return normalizeBackendKeysWithParents(keys, props.nodes); + } + return normalizeMenuKeysWithParents(keys); +} + +function normalizeBackendKeysWithParents( + selected: string[], + nodes: PermissionTreeNode[], +): string[] { + const set = new Set(selected); + function walk(list: PermissionTreeNode[]) { + for (const node of list) { + const children = node.children ?? []; + if (children.length) { + walk(children); + if (children.some((c) => set.has(c.key))) { + set.add(node.key); + } + } + } + } + walk(nodes); + return [...set]; +} + +function filterBackendNodes( + nodes: PermissionTreeNode[], + allowedKeys: string[], +): PermissionTreeNode[] { + const allowed = new Set(allowedKeys); + function filter(list: PermissionTreeNode[]): PermissionTreeNode[] { + return list + .map((node) => { + const children = node.children ? filter(node.children) : undefined; + const selfOk = allowed.has(node.key); + const childOk = (children?.length ?? 0) > 0; + if (!selfOk && !childOk) { + return null; + } + return { ...node, children }; + }) + .filter(Boolean) as PermissionTreeNode[]; + } + return filter(nodes); +} + function mapNodes(nodes: SaasMenuTreeNode[]) { return nodes.map((node) => ({ children: node.children?.length ? mapNodes(node.children) : undefined, diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs index aa9ffa8..f119ab7 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs @@ -1,4 +1,4 @@ -using FoodLabeling.Application.Services.DbModels; +using FoodLabeling.Application.Services.DbModels; using SqlSugar; namespace FoodLabeling.Application.Helpers; diff --git a/项目相关文档/美国版服务器采购配置清单.md b/项目相关文档/美国版服务器采购配置清单.md new file mode 100644 index 0000000..f7dfebe --- /dev/null +++ b/项目相关文档/美国版服务器采购配置清单.md @@ -0,0 +1,122 @@ +# 美国版服务器采购配置清单 + +> 适用范围:美国版 Web 管理端 + UniApp 员工端 +> 技术栈:.NET 8 API + MySQL + React 静态站 +> 原则:独立环境,不与泰额版共用资源 + +--- + +## 1. 环境定位 + +| 项 | 说明 | +|---|---| +| 用户区域 | 美国 | +| 后端 | `Yi.Abp.Web`(.NET 8 + SqlSugar) | +| 前端 | React 18 + Vite 静态托管 | +| 移动端 | UniApp(调用同一套 API) | +| 文件存储现状 | 本地磁盘(图片、批量导入模板) | +| Redis | 配置存在,当前默认关闭 | + +--- + +## 2. 必买清单 + +| 序号 | 项 | 建议规格 | 备注 | +|---|---|---|---| +| 1 | 云厂商 + 地域 | AWS / Azure / GCP,**美东或美西** | 就近访问、合规 | +| 2 | 应用服务器 | **4 核 8G**,系统盘 **40~80G SSD** ×1(建议预留扩到 2 台) | 运行 API | +| 3 | MySQL(RDS) | **2~4 核 / 4~8G 内存**,存储 **50~100G SSD** | 开启自动备份 7~30 天 | +| 4 | 负载均衡 / 公网 | SLB 或云 LB;带宽 **5~20Mbps** 或按量 | API 对外入口 | +| 5 | 域名 + SSL | 1 个主域名;建议 `api.` / `admin.` 子域 | HTTPS 必需 | +| 6 | 反向代理 | Nginx(可与 API 同机) | 反代 API + 托管 React 静态文件 | +| 7 | 安全组 / 防火墙 | 仅开放 80/443;SSH 限 IP | 数据库不对公网开放 | +| 8 | 备份 + 监控 | RDS 自动备份;CPU / 内存 / 磁盘 / 5xx 告警 | 云厂商自带即可 | + +--- + +## 3. 建议购买(正式上线) + +| 序号 | 项 | 建议规格 | 备注 | +|---|---|---|---| +| 9 | 对象存储 S3 / OSS | **50~100G** + 按量流出 | 图片、导入文件;后期多实例必备 | +| 10 | CDN(可选) | 绑定静态站 / 图片桶 | App 拉图更快 | + +--- + +## 4. 可暂缓 + +| 项 | 说明 | +|---|---| +| Redis | 当前关闭;验证码 / 多实例后再上,**1G** 即可 | +| 第二台 API | 有滚动发布 / 高可用需求再买 | +| 独立日志 / 消息队列 | 当前架构暂不需要 | +| 独立邮件服务 | 已有 Office365 SMTP 可继续使用 | + +--- + +## 5. OSS 结论 + +| 阶段 | 是否购买 | 说明 | +|---|---|---| +| 内测 / 单机试跑 | 可不买 | 本地盘可用 | +| 正式上线 | **建议购买** | 换机不丢文件,方便横向扩容 | +| 容量建议 | 50~100G | 按租户 / 业务前缀隔离目录 | + +未切 OSS 前,本地目录参考: + +- 图片:`/www/wwwroot/FoodLabelingManagementUs/picture` +- 批量导入模板:`/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles` + +未上 OSS 时,建议系统盘 **≥ 80G**,避免图片打满磁盘。 + +> 说明:购买 OSS 后需改造上传逻辑(如 `PictureAppService`);可先开桶,代码分迭代切换。 + +--- + +## 6. 软件与部署要求 + +| 项 | 内容 | +|---|---| +| 运行时 | .NET 8 Runtime、Nginx | +| API | `Yi.Abp.Web` | +| 前端 | React Vite 构建产物由 Nginx 托管 | +| 数据库访问 | 仅 VPC 内网,不对公网 | +| 发布方式 | 建议预留第二台机器做滚动发布 | + +--- + +## 7. 一页勾选版 + +``` +美国版生产环境 +□ 美区 VPC / 账号 +□ ECS/VM 4C8G ×1(系统盘 80G) +□ MySQL RDS 2~4C / 4~8G / 50~100G + 自动备份 +□ SLB + 公网带宽 +□ 域名 + SSL 证书 +□ Nginx(API 反代 + Web 静态) +□ 安全组:80/443;RDS 仅内网 +□ 监控告警 +□ S3/OSS 50~100G(正式建议勾选) +□(可选)Redis 1G +□(可选)CDN +□(可选)第二台 4C8G(高可用) +``` + +--- + +## 8. 预算量级(仅供参考) + +| 场景 | 粗算月费 | +|---|---| +| 起步(1 台 API + RDS + 小容量 S3) | 约 **$80~200 / 月** | +| 扩容方向 | 优先加 RDS 规格,其次加第二台 API;OSS 按量扩容 | + +--- + +## 9. 采购顺序建议 + +1. 确定云厂商与美区地域 +2. 购买 RDS + 应用机 + 域名证书 +3. 同步开通 S3/OSS 桶(即使第一期仍写本地) +4. 跑通发布后再评估 Redis、第二台 API、CDN