Commit 3fc119e5821e6e02e8cdbe380e41cfb52587ae43
1 parent
90f9cc21
2026-08-04代码提交
Showing
21 changed files
with
920 additions
and
168 deletions
泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs
| @@ -21,10 +21,13 @@ namespace Yi.Framework.SqlSugarCore | @@ -21,10 +21,13 @@ namespace Yi.Framework.SqlSugarCore | ||
| 21 | { | 21 | { |
| 22 | #region Properties | 22 | #region Properties |
| 23 | 23 | ||
| 24 | + private ISqlSugarClient? _sqlSugarClient; | ||
| 25 | + private readonly object _clientLock = new(); | ||
| 26 | + | ||
| 24 | /// <summary> | 27 | /// <summary> |
| 25 | - /// SqlSugar客户端实例 | 28 | + /// SqlSugar 客户端(延迟按当前租户解析连接串,避免构造时 CurrentTenant 尚未就绪落到 host 库) |
| 26 | /// </summary> | 29 | /// </summary> |
| 27 | - public ISqlSugarClient SqlSugarClient { get; private set; } | 30 | + public ISqlSugarClient SqlSugarClient => GetOrCreateClient(); |
| 28 | 31 | ||
| 29 | /// <summary> | 32 | /// <summary> |
| 30 | /// 延迟服务提供者 | 33 | /// 延迟服务提供者 |
| @@ -75,22 +78,36 @@ namespace Yi.Framework.SqlSugarCore | @@ -75,22 +78,36 @@ namespace Yi.Framework.SqlSugarCore | ||
| 75 | public SqlSugarDbContextFactory(IAbpLazyServiceProvider lazyServiceProvider) | 78 | public SqlSugarDbContextFactory(IAbpLazyServiceProvider lazyServiceProvider) |
| 76 | { | 79 | { |
| 77 | LazyServiceProvider = lazyServiceProvider; | 80 | LazyServiceProvider = lazyServiceProvider; |
| 81 | + } | ||
| 82 | + | ||
| 83 | + private ISqlSugarClient GetOrCreateClient() | ||
| 84 | + { | ||
| 85 | + if (_sqlSugarClient is not null) | ||
| 86 | + { | ||
| 87 | + return _sqlSugarClient; | ||
| 88 | + } | ||
| 78 | 89 | ||
| 79 | - // 异步获取租户配置 | ||
| 80 | - var tenantConfiguration = AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync()); | ||
| 81 | - | ||
| 82 | - // 构建数据库连接配置 | ||
| 83 | - var connectionConfig = BuildConnectionConfig(options => | 90 | + lock (_clientLock) |
| 84 | { | 91 | { |
| 85 | - options.ConnectionString = tenantConfiguration.GetCurrentConnectionString(); | ||
| 86 | - options.DbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName()); | ||
| 87 | - }); | 92 | + if (_sqlSugarClient is not null) |
| 93 | + { | ||
| 94 | + return _sqlSugarClient; | ||
| 95 | + } | ||
| 88 | 96 | ||
| 89 | - // 创建SqlSugar客户端实例 | ||
| 90 | - SqlSugarClient = new SqlSugarClient(connectionConfig); | 97 | + var tenantConfiguration = |
| 98 | + AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync()); | ||
| 91 | 99 | ||
| 92 | - // 配置数据库AOP | ||
| 93 | - ConfigureDbAop(SqlSugarClient); | 100 | + var connectionConfig = BuildConnectionConfig(options => |
| 101 | + { | ||
| 102 | + options.ConnectionString = tenantConfiguration.GetCurrentConnectionString(); | ||
| 103 | + options.DbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName()); | ||
| 104 | + }); | ||
| 105 | + | ||
| 106 | + var client = new SqlSugarClient(connectionConfig); | ||
| 107 | + ConfigureDbAop(client); | ||
| 108 | + _sqlSugarClient = client; | ||
| 109 | + return _sqlSugarClient; | ||
| 110 | + } | ||
| 94 | } | 111 | } |
| 95 | 112 | ||
| 96 | /// <summary> | 113 | /// <summary> |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs
| 1 | using Volo.Abp; | 1 | using Volo.Abp; |
| 2 | using Volo.Abp.MultiTenancy; | 2 | using Volo.Abp.MultiTenancy; |
| 3 | +using Yi.Framework.SqlSugarCore.Abstractions; | ||
| 3 | 4 | ||
| 4 | namespace FoodLabeling.Application.Helpers; | 5 | namespace FoodLabeling.Application.Helpers; |
| 5 | 6 | ||
| @@ -8,9 +9,15 @@ namespace FoodLabeling.Application.Helpers; | @@ -8,9 +9,15 @@ namespace FoodLabeling.Application.Helpers; | ||
| 8 | /// </summary> | 9 | /// </summary> |
| 9 | public static class TenantContextGuard | 10 | public static class TenantContextGuard |
| 10 | { | 11 | { |
| 12 | + /// <summary> | ||
| 13 | + /// 平台主库登录(JWT/__tenant 均无业务租户)时访问 fl_* 等业务表的友好提示。 | ||
| 14 | + /// </summary> | ||
| 15 | + public const string PlatformCannotAccessBusinessDataMessage = | ||
| 16 | + "当前为平台主库登录,无法访问公司业务数据(标签/产品/成员等)。请选择具体公司登录,或使用平台「公司管理」相关接口。"; | ||
| 17 | + | ||
| 11 | public static void EnsureTenantResolved(ICurrentTenant currentTenant, string? operation = null) | 18 | public static void EnsureTenantResolved(ICurrentTenant currentTenant, string? operation = null) |
| 12 | { | 19 | { |
| 13 | - if (currentTenant.Id.HasValue) | 20 | + if (currentTenant.Id.HasValue && currentTenant.Id.Value != Guid.Empty) |
| 14 | { | 21 | { |
| 15 | return; | 22 | return; |
| 16 | } | 23 | } |
| @@ -19,6 +26,78 @@ public static class TenantContextGuard | @@ -19,6 +26,78 @@ public static class TenantContextGuard | ||
| 19 | ? "未识别租户上下文" | 26 | ? "未识别租户上下文" |
| 20 | : $"{operation}:未识别租户上下文"; | 27 | : $"{operation}:未识别租户上下文"; |
| 21 | throw new UserFriendlyException( | 28 | throw new UserFriendlyException( |
| 22 | - $"{hint}。请使用泰额登录接口(th-web-auth / th-app-auth)或请求头 __tenant 携带租户 Id。"); | 29 | + $"{hint}。请使用泰额登录接口(th-web-auth / th-app-auth)选择具体公司登录,或请求头 __tenant 携带租户 Id。"); |
| 30 | + } | ||
| 31 | + | ||
| 32 | + /// <summary> | ||
| 33 | + /// 泰额 SaaS 多租户开启时,业务表(fl_* / location 等)必须走租户库,禁止落到 host。 | ||
| 34 | + /// </summary> | ||
| 35 | + public static void EnsureBusinessTenantIfSaas( | ||
| 36 | + ICurrentTenant currentTenant, | ||
| 37 | + DbConnOptions? dbConnOptions, | ||
| 38 | + string? operation = null) | ||
| 39 | + { | ||
| 40 | + if (dbConnOptions is null || !dbConnOptions.EnabledSaasMultiTenancy) | ||
| 41 | + { | ||
| 42 | + return; | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + EnsureTenantResolved(currentTenant, operation); | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + /// <summary> | ||
| 49 | + /// 判断当前 DbContext 是否连到平台主库(antis-foodlabeling-host)。 | ||
| 50 | + /// </summary> | ||
| 51 | + public static bool IsConnectedToHostDatabase(ISqlSugarDbContext dbContext, DbConnOptions dbConnOptions) | ||
| 52 | + { | ||
| 53 | + var dbName = dbContext.SqlSugarClient.Ado.Connection.Database; | ||
| 54 | + var hostDbName = TryExtractDatabaseName(dbConnOptions.Url); | ||
| 55 | + return !string.IsNullOrWhiteSpace(hostDbName) | ||
| 56 | + && string.Equals(dbName, hostDbName, StringComparison.OrdinalIgnoreCase); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + /// <summary> | ||
| 60 | + /// SaaS 模式下校验 DbContext 未落到 host 主库(双保险,避免缺表 500)。 | ||
| 61 | + /// </summary> | ||
| 62 | + public static void EnsureNotHostDatabaseIfSaas( | ||
| 63 | + ISqlSugarDbContext dbContext, | ||
| 64 | + DbConnOptions dbConnOptions, | ||
| 65 | + string? operation = null) | ||
| 66 | + { | ||
| 67 | + if (!dbConnOptions.EnabledSaasMultiTenancy) | ||
| 68 | + { | ||
| 69 | + return; | ||
| 70 | + } | ||
| 71 | + | ||
| 72 | + if (!IsConnectedToHostDatabase(dbContext, dbConnOptions)) | ||
| 73 | + { | ||
| 74 | + return; | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + var hint = string.IsNullOrWhiteSpace(operation) | ||
| 78 | + ? PlatformCannotAccessBusinessDataMessage | ||
| 79 | + : $"{operation}:{PlatformCannotAccessBusinessDataMessage}"; | ||
| 80 | + throw new UserFriendlyException(hint); | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + private static string? TryExtractDatabaseName(string? connectionString) | ||
| 84 | + { | ||
| 85 | + if (string.IsNullOrWhiteSpace(connectionString)) | ||
| 86 | + { | ||
| 87 | + return null; | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + foreach (var part in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries)) | ||
| 91 | + { | ||
| 92 | + var kv = part.Split('=', 2, StringSplitOptions.TrimEntries); | ||
| 93 | + if (kv.Length == 2 | ||
| 94 | + && (kv[0].Equals("database", StringComparison.OrdinalIgnoreCase) | ||
| 95 | + || kv[0].Equals("Database", StringComparison.OrdinalIgnoreCase))) | ||
| 96 | + { | ||
| 97 | + return kv[1].Trim(); | ||
| 98 | + } | ||
| 99 | + } | ||
| 100 | + | ||
| 101 | + return null; | ||
| 23 | } | 102 | } |
| 24 | } | 103 | } |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs
| 1 | +using System; | ||
| 2 | +using System.IdentityModel.Tokens.Jwt; | ||
| 3 | +using System.Linq; | ||
| 4 | +using System.Security.Claims; | ||
| 1 | using Microsoft.AspNetCore.Http; | 5 | using Microsoft.AspNetCore.Http; |
| 2 | using Volo.Abp.MultiTenancy; | 6 | using Volo.Abp.MultiTenancy; |
| 3 | using Volo.Abp.Security.Claims; | 7 | using Volo.Abp.Security.Claims; |
| @@ -16,20 +20,75 @@ public class JwtClaimTenantResolveContributor : TenantResolveContributorBase | @@ -16,20 +20,75 @@ public class JwtClaimTenantResolveContributor : TenantResolveContributorBase | ||
| 16 | 20 | ||
| 17 | public override Task ResolveAsync(ITenantResolveContext context) | 21 | public override Task ResolveAsync(ITenantResolveContext context) |
| 18 | { | 22 | { |
| 19 | - var httpContext = context.ServiceProvider.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor; | ||
| 20 | - var user = httpContext?.HttpContext?.User; | ||
| 21 | - if (user?.Identity?.IsAuthenticated != true) | 23 | + var httpContext = (context.ServiceProvider.GetService(typeof(IHttpContextAccessor)) as IHttpContextAccessor) |
| 24 | + ?.HttpContext; | ||
| 25 | + if (httpContext is null) | ||
| 22 | { | 26 | { |
| 23 | return Task.CompletedTask; | 27 | return Task.CompletedTask; |
| 24 | } | 28 | } |
| 25 | 29 | ||
| 26 | - var tenantClaim = user.FindFirst(TokenTypeConst.TenantId)?.Value | ||
| 27 | - ?? user.FindFirst(AbpClaimTypes.TenantId)?.Value; | ||
| 28 | - if (!string.IsNullOrWhiteSpace(tenantClaim)) | 30 | + var tenantClaim = TryGetTenantIdFromPrincipal(httpContext.User) |
| 31 | + ?? TryGetTenantIdFromAuthorizationHeader( | ||
| 32 | + httpContext.Request.Headers.Authorization.ToString()); | ||
| 33 | + | ||
| 34 | + if (!string.IsNullOrWhiteSpace(tenantClaim) | ||
| 35 | + && Guid.TryParse(tenantClaim, out var tenantGuid) | ||
| 36 | + && tenantGuid != Guid.Empty) | ||
| 29 | { | 37 | { |
| 30 | context.TenantIdOrName = tenantClaim; | 38 | context.TenantIdOrName = tenantClaim; |
| 31 | } | 39 | } |
| 32 | 40 | ||
| 33 | return Task.CompletedTask; | 41 | return Task.CompletedTask; |
| 34 | } | 42 | } |
| 43 | + | ||
| 44 | + private static string? TryGetTenantIdFromPrincipal(ClaimsPrincipal? user) | ||
| 45 | + { | ||
| 46 | + if (user?.Identity?.IsAuthenticated != true) | ||
| 47 | + { | ||
| 48 | + return null; | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + return user.FindFirst(TokenTypeConst.TenantId)?.Value | ||
| 52 | + ?? user.FindFirst(AbpClaimTypes.TenantId)?.Value | ||
| 53 | + ?? user.Claims.FirstOrDefault(c => | ||
| 54 | + c.Type.EndsWith("tenantId", StringComparison.OrdinalIgnoreCase) | ||
| 55 | + || c.Type.Equals("tenantid", StringComparison.OrdinalIgnoreCase))?.Value; | ||
| 56 | + } | ||
| 57 | + | ||
| 58 | + /// <summary> | ||
| 59 | + /// 多租户中间件可能早于 JWT Principal 就绪;直接从 Authorization 解析 TenantId Claim。 | ||
| 60 | + /// </summary> | ||
| 61 | + private static string? TryGetTenantIdFromAuthorizationHeader(string? authorization) | ||
| 62 | + { | ||
| 63 | + if (string.IsNullOrWhiteSpace(authorization)) | ||
| 64 | + { | ||
| 65 | + return null; | ||
| 66 | + } | ||
| 67 | + | ||
| 68 | + const string bearerPrefix = "Bearer "; | ||
| 69 | + if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) | ||
| 70 | + { | ||
| 71 | + return null; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + var jwt = authorization[bearerPrefix.Length..].Trim(); | ||
| 75 | + if (string.IsNullOrWhiteSpace(jwt)) | ||
| 76 | + { | ||
| 77 | + return null; | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + try | ||
| 81 | + { | ||
| 82 | + var token = new JwtSecurityTokenHandler().ReadJwtToken(jwt); | ||
| 83 | + return token.Claims.FirstOrDefault(c => | ||
| 84 | + c.Type == TokenTypeConst.TenantId | ||
| 85 | + || c.Type == AbpClaimTypes.TenantId | ||
| 86 | + || c.Type.EndsWith("tenantId", StringComparison.OrdinalIgnoreCase)) | ||
| 87 | + ?.Value; | ||
| 88 | + } | ||
| 89 | + catch (Exception) | ||
| 90 | + { | ||
| 91 | + return null; | ||
| 92 | + } | ||
| 93 | + } | ||
| 35 | } | 94 | } |
泰额版/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; | @@ -4,6 +4,7 @@ using FoodLabeling.Application.Contracts.Dtos.Group; | ||
| 4 | using FoodLabeling.Application.Contracts.IServices; | 4 | using FoodLabeling.Application.Contracts.IServices; |
| 5 | using FoodLabeling.Application.Services.DbModels; | 5 | using FoodLabeling.Application.Services.DbModels; |
| 6 | using Microsoft.AspNetCore.Mvc; | 6 | using Microsoft.AspNetCore.Mvc; |
| 7 | +using Microsoft.Extensions.Options; | ||
| 7 | using QuestPDF.Fluent; | 8 | using QuestPDF.Fluent; |
| 8 | using QuestPDF.Helpers; | 9 | using QuestPDF.Helpers; |
| 9 | using QuestPDF.Infrastructure; | 10 | using QuestPDF.Infrastructure; |
| @@ -25,16 +26,22 @@ public class GroupAppService : ApplicationService, IGroupAppService | @@ -25,16 +26,22 @@ public class GroupAppService : ApplicationService, IGroupAppService | ||
| 25 | 26 | ||
| 26 | private readonly ISqlSugarDbContext _dbContext; | 27 | private readonly ISqlSugarDbContext _dbContext; |
| 27 | private readonly IGuidGenerator _guidGenerator; | 28 | private readonly IGuidGenerator _guidGenerator; |
| 29 | + private readonly DbConnOptions _dbConnOptions; | ||
| 28 | 30 | ||
| 29 | - public GroupAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) | 31 | + public GroupAppService( |
| 32 | + ISqlSugarDbContext dbContext, | ||
| 33 | + IGuidGenerator guidGenerator, | ||
| 34 | + IOptions<DbConnOptions> dbConnOptions) | ||
| 30 | { | 35 | { |
| 31 | _dbContext = dbContext; | 36 | _dbContext = dbContext; |
| 32 | _guidGenerator = guidGenerator; | 37 | _guidGenerator = guidGenerator; |
| 38 | + _dbConnOptions = dbConnOptions.Value; | ||
| 33 | } | 39 | } |
| 34 | 40 | ||
| 35 | /// <inheritdoc /> | 41 | /// <inheritdoc /> |
| 36 | public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input) | 42 | public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input) |
| 37 | { | 43 | { |
| 44 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Region"); | ||
| 38 | RefAsync<int> total = 0; | 45 | RefAsync<int> total = 0; |
| 39 | var query = await BuildGroupJoinedQueryAsync(input); | 46 | var query = await BuildGroupJoinedQueryAsync(input); |
| 40 | var projected = query.Select((g, p) => new GroupGetListOutputDto | 47 | var projected = query.Select((g, p) => new GroupGetListOutputDto |
泰额版/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; | @@ -6,6 +6,7 @@ using FoodLabeling.Application.Contracts.Dtos.LabelTemplate; | ||
| 6 | using FoodLabeling.Application.Contracts.IServices; | 6 | using FoodLabeling.Application.Contracts.IServices; |
| 7 | using FoodLabeling.Application.Services.DbModels; | 7 | using FoodLabeling.Application.Services.DbModels; |
| 8 | using FoodLabeling.Domain.Entities; | 8 | using FoodLabeling.Domain.Entities; |
| 9 | +using Microsoft.Extensions.Options; | ||
| 9 | using SqlSugar; | 10 | using SqlSugar; |
| 10 | using Volo.Abp; | 11 | using Volo.Abp; |
| 11 | using Volo.Abp.Application.Services; | 12 | using Volo.Abp.Application.Services; |
| @@ -22,15 +23,21 @@ public class LabelAppService : ApplicationService, ILabelAppService | @@ -22,15 +23,21 @@ public class LabelAppService : ApplicationService, ILabelAppService | ||
| 22 | { | 23 | { |
| 23 | private readonly ISqlSugarDbContext _dbContext; | 24 | private readonly ISqlSugarDbContext _dbContext; |
| 24 | private readonly IGuidGenerator _guidGenerator; | 25 | private readonly IGuidGenerator _guidGenerator; |
| 26 | + private readonly DbConnOptions _dbConnOptions; | ||
| 25 | 27 | ||
| 26 | - public LabelAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) | 28 | + public LabelAppService( |
| 29 | + ISqlSugarDbContext dbContext, | ||
| 30 | + IGuidGenerator guidGenerator, | ||
| 31 | + IOptions<DbConnOptions> dbConnOptions) | ||
| 27 | { | 32 | { |
| 28 | _dbContext = dbContext; | 33 | _dbContext = dbContext; |
| 29 | _guidGenerator = guidGenerator; | 34 | _guidGenerator = guidGenerator; |
| 35 | + _dbConnOptions = dbConnOptions.Value; | ||
| 30 | } | 36 | } |
| 31 | 37 | ||
| 32 | public async Task<PagedResultWithPageDto<LabelGetListOutputDto>> GetListAsync(LabelGetListInputVo input) | 38 | public async Task<PagedResultWithPageDto<LabelGetListOutputDto>> GetListAsync(LabelGetListInputVo input) |
| 33 | { | 39 | { |
| 40 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Label"); | ||
| 34 | RefAsync<int> total = 0; | 41 | RefAsync<int> total = 0; |
| 35 | 42 | ||
| 36 | var productId = input.ProductId?.Trim(); | 43 | var productId = input.ProductId?.Trim(); |
泰额版/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 | @@ -24,20 +24,24 @@ public class LocationAppService : ApplicationService, ILocationAppService | ||
| 24 | private readonly ISqlSugarRepository<LocationAggregateRoot, Guid> _locationRepository; | 24 | private readonly ISqlSugarRepository<LocationAggregateRoot, Guid> _locationRepository; |
| 25 | private readonly ISqlSugarDbContext _dbContext; | 25 | private readonly ISqlSugarDbContext _dbContext; |
| 26 | private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions; | 26 | private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions; |
| 27 | + private readonly DbConnOptions _dbConnOptions; | ||
| 27 | 28 | ||
| 28 | public LocationAppService( | 29 | public LocationAppService( |
| 29 | ISqlSugarRepository<LocationAggregateRoot, Guid> locationRepository, | 30 | ISqlSugarRepository<LocationAggregateRoot, Guid> locationRepository, |
| 30 | ISqlSugarDbContext dbContext, | 31 | ISqlSugarDbContext dbContext, |
| 31 | - IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions) | 32 | + IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions, |
| 33 | + IOptions<DbConnOptions> dbConnOptions) | ||
| 32 | { | 34 | { |
| 33 | _locationRepository = locationRepository; | 35 | _locationRepository = locationRepository; |
| 34 | _dbContext = dbContext; | 36 | _dbContext = dbContext; |
| 35 | _batchImportOptions = batchImportOptions; | 37 | _batchImportOptions = batchImportOptions; |
| 38 | + _dbConnOptions = dbConnOptions.Value; | ||
| 36 | } | 39 | } |
| 37 | 40 | ||
| 38 | /// <inheritdoc /> | 41 | /// <inheritdoc /> |
| 39 | public async Task<PagedResultWithPageDto<LocationGetListOutputDto>> GetListAsync([FromQuery] LocationGetListInputVo input) | 42 | public async Task<PagedResultWithPageDto<LocationGetListOutputDto>> GetListAsync([FromQuery] LocationGetListInputVo input) |
| 40 | { | 43 | { |
| 44 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Location"); | ||
| 41 | RefAsync<int> total = 0; | 45 | RefAsync<int> total = 0; |
| 42 | 46 | ||
| 43 | var query = await BuildFilteredQueryAsync(input); | 47 | var query = await BuildFilteredQueryAsync(input); |
泰额版/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; | @@ -4,6 +4,7 @@ using FoodLabeling.Application.Contracts.Dtos.Partner; | ||
| 4 | using FoodLabeling.Application.Contracts.IServices; | 4 | using FoodLabeling.Application.Contracts.IServices; |
| 5 | using FoodLabeling.Application.Services.DbModels; | 5 | using FoodLabeling.Application.Services.DbModels; |
| 6 | using Microsoft.AspNetCore.Mvc; | 6 | using Microsoft.AspNetCore.Mvc; |
| 7 | +using Microsoft.Extensions.Options; | ||
| 7 | using QuestPDF.Fluent; | 8 | using QuestPDF.Fluent; |
| 8 | using QuestPDF.Helpers; | 9 | using QuestPDF.Helpers; |
| 9 | using QuestPDF.Infrastructure; | 10 | using QuestPDF.Infrastructure; |
| @@ -25,16 +26,22 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | @@ -25,16 +26,22 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | ||
| 25 | 26 | ||
| 26 | private readonly ISqlSugarDbContext _dbContext; | 27 | private readonly ISqlSugarDbContext _dbContext; |
| 27 | private readonly IGuidGenerator _guidGenerator; | 28 | private readonly IGuidGenerator _guidGenerator; |
| 29 | + private readonly DbConnOptions _dbConnOptions; | ||
| 28 | 30 | ||
| 29 | - public PartnerAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator) | 31 | + public PartnerAppService( |
| 32 | + ISqlSugarDbContext dbContext, | ||
| 33 | + IGuidGenerator guidGenerator, | ||
| 34 | + IOptions<DbConnOptions> dbConnOptions) | ||
| 30 | { | 35 | { |
| 31 | _dbContext = dbContext; | 36 | _dbContext = dbContext; |
| 32 | _guidGenerator = guidGenerator; | 37 | _guidGenerator = guidGenerator; |
| 38 | + _dbConnOptions = dbConnOptions.Value; | ||
| 33 | } | 39 | } |
| 34 | 40 | ||
| 35 | /// <inheritdoc /> | 41 | /// <inheritdoc /> |
| 36 | public async Task<PagedResultWithPageDto<PartnerGetListOutputDto>> GetListAsync(PartnerGetListInputVo input) | 42 | public async Task<PagedResultWithPageDto<PartnerGetListOutputDto>> GetListAsync(PartnerGetListInputVo input) |
| 37 | { | 43 | { |
| 44 | + EnsureBusinessTenantContext("查询 Company"); | ||
| 38 | RefAsync<int> total = 0; | 45 | RefAsync<int> total = 0; |
| 39 | var query = await BuildPartnerListQueryAsync(input); | 46 | var query = await BuildPartnerListQueryAsync(input); |
| 40 | 47 | ||
| @@ -46,6 +53,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | @@ -46,6 +53,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | ||
| 46 | /// <inheritdoc /> | 53 | /// <inheritdoc /> |
| 47 | public async Task<PartnerGetOutputDto> GetAsync(Guid id) | 54 | public async Task<PartnerGetOutputDto> GetAsync(Guid id) |
| 48 | { | 55 | { |
| 56 | + EnsureBusinessTenantContext("查询 Company"); | ||
| 49 | if (id == Guid.Empty) | 57 | if (id == Guid.Empty) |
| 50 | { | 58 | { |
| 51 | throw new UserFriendlyException("Partner id is required."); | 59 | throw new UserFriendlyException("Partner id is required."); |
| @@ -66,6 +74,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | @@ -66,6 +74,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | ||
| 66 | [UnitOfWork] | 74 | [UnitOfWork] |
| 67 | public async Task<PartnerGetOutputDto> CreateAsync(PartnerCreateInputVo input) | 75 | public async Task<PartnerGetOutputDto> CreateAsync(PartnerCreateInputVo input) |
| 68 | { | 76 | { |
| 77 | + EnsureBusinessTenantContext("创建 Company"); | ||
| 69 | var name = input.PartnerName?.Trim(); | 78 | var name = input.PartnerName?.Trim(); |
| 70 | if (string.IsNullOrWhiteSpace(name)) | 79 | if (string.IsNullOrWhiteSpace(name)) |
| 71 | { | 80 | { |
| @@ -102,6 +111,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | @@ -102,6 +111,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | ||
| 102 | [UnitOfWork] | 111 | [UnitOfWork] |
| 103 | public async Task<PartnerGetOutputDto> UpdateAsync(Guid id, PartnerUpdateInputVo input) | 112 | public async Task<PartnerGetOutputDto> UpdateAsync(Guid id, PartnerUpdateInputVo input) |
| 104 | { | 113 | { |
| 114 | + EnsureBusinessTenantContext("更新 Company"); | ||
| 105 | if (id == Guid.Empty) | 115 | if (id == Guid.Empty) |
| 106 | { | 116 | { |
| 107 | throw new UserFriendlyException("Partner id is required."); | 117 | throw new UserFriendlyException("Partner id is required."); |
| @@ -143,6 +153,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | @@ -143,6 +153,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | ||
| 143 | [UnitOfWork] | 153 | [UnitOfWork] |
| 144 | public async Task DeleteAsync(Guid id) | 154 | public async Task DeleteAsync(Guid id) |
| 145 | { | 155 | { |
| 156 | + EnsureBusinessTenantContext("删除 Company"); | ||
| 146 | if (id == Guid.Empty) | 157 | if (id == Guid.Empty) |
| 147 | { | 158 | { |
| 148 | throw new UserFriendlyException("Partner id is required."); | 159 | throw new UserFriendlyException("Partner id is required."); |
| @@ -166,6 +177,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | @@ -166,6 +177,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | ||
| 166 | [HttpGet] | 177 | [HttpGet] |
| 167 | public async Task<IActionResult> ExportPdfAsync([FromQuery] PartnerGetListInputVo input) | 178 | public async Task<IActionResult> ExportPdfAsync([FromQuery] PartnerGetListInputVo input) |
| 168 | { | 179 | { |
| 180 | + EnsureBusinessTenantContext("导出 Company"); | ||
| 169 | QuestPDF.Settings.License = LicenseType.Community; | 181 | QuestPDF.Settings.License = LicenseType.Community; |
| 170 | 182 | ||
| 171 | var count = await (await BuildPartnerListQueryAsync(input)).CountAsync(); | 183 | var count = await (await BuildPartnerListQueryAsync(input)).CountAsync(); |
| @@ -340,6 +352,11 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | @@ -340,6 +352,11 @@ public class PartnerAppService : ApplicationService, IPartnerAppService | ||
| 340 | dto.ZipCode = entity.ZipCode; | 352 | dto.ZipCode = entity.ZipCode; |
| 341 | } | 353 | } |
| 342 | 354 | ||
| 355 | + private void EnsureBusinessTenantContext(string operation) | ||
| 356 | + { | ||
| 357 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, operation); | ||
| 358 | + } | ||
| 359 | + | ||
| 343 | private static string? TrimToNull(string? value) | 360 | private static string? TrimToNull(string? value) |
| 344 | { | 361 | { |
| 345 | var t = value?.Trim(); | 362 | var t = value?.Trim(); |
泰额版/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 | @@ -34,24 +34,28 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService | ||
| 34 | private readonly ISqlSugarDbContext _dbContext; | 34 | private readonly ISqlSugarDbContext _dbContext; |
| 35 | private readonly IGuidGenerator _guidGenerator; | 35 | private readonly IGuidGenerator _guidGenerator; |
| 36 | private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions; | 36 | private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions; |
| 37 | + private readonly DbConnOptions _dbConnOptions; | ||
| 37 | 38 | ||
| 38 | public TeamMemberAppService( | 39 | public TeamMemberAppService( |
| 39 | ISqlSugarRepository<UserAggregateRoot, Guid> userRepository, | 40 | ISqlSugarRepository<UserAggregateRoot, Guid> userRepository, |
| 40 | UserManager userManager, | 41 | UserManager userManager, |
| 41 | ISqlSugarDbContext dbContext, | 42 | ISqlSugarDbContext dbContext, |
| 42 | IGuidGenerator guidGenerator, | 43 | IGuidGenerator guidGenerator, |
| 43 | - IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions) | 44 | + IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions, |
| 45 | + IOptions<DbConnOptions> dbConnOptions) | ||
| 44 | { | 46 | { |
| 45 | _userRepository = userRepository; | 47 | _userRepository = userRepository; |
| 46 | _userManager = userManager; | 48 | _userManager = userManager; |
| 47 | _dbContext = dbContext; | 49 | _dbContext = dbContext; |
| 48 | _guidGenerator = guidGenerator; | 50 | _guidGenerator = guidGenerator; |
| 49 | _batchImportOptions = batchImportOptions; | 51 | _batchImportOptions = batchImportOptions; |
| 52 | + _dbConnOptions = dbConnOptions.Value; | ||
| 50 | } | 53 | } |
| 51 | 54 | ||
| 52 | /// <inheritdoc /> | 55 | /// <inheritdoc /> |
| 53 | public async Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input) | 56 | public async Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input) |
| 54 | { | 57 | { |
| 58 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Team Member"); | ||
| 55 | var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount); | 59 | var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount); |
| 56 | var pageSize = input.MaxResultCount; | 60 | var pageSize = input.MaxResultCount; |
| 57 | RefAsync<int> total = 0; | 61 | RefAsync<int> total = 0; |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs
| 1 | namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; | 1 | namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; |
| 2 | 2 | ||
| 3 | /// <summary> | 3 | /// <summary> |
| 4 | -/// SaaS 菜单权限树节点 | 4 | +/// SaaS / 平台分配菜单树节点 |
| 5 | /// </summary> | 5 | /// </summary> |
| 6 | public class ThSaasMenuPermissionTreeNodeDto | 6 | public class ThSaasMenuPermissionTreeNodeDto |
| 7 | { | 7 | { |
| 8 | - /// <summary>权限 Key(如 labeling:labels)</summary> | 8 | + /// <summary>菜单 Id(与主库/租户库 Menu.Id 一致)</summary> |
| 9 | public string Key { get; set; } = string.Empty; | 9 | public string Key { get; set; } = string.Empty; |
| 10 | 10 | ||
| 11 | /// <summary>中文标题</summary> | 11 | /// <summary>中文标题</summary> |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Filters/FoodLabelingBusinessTenantActionFilter.cs
0 → 100644
| 1 | +using FoodLabeling.Application.Helpers; | ||
| 2 | +using Microsoft.AspNetCore.Mvc.Filters; | ||
| 3 | +using Microsoft.Extensions.Options; | ||
| 4 | +using Volo.Abp.DependencyInjection; | ||
| 5 | +using Volo.Abp.MultiTenancy; | ||
| 6 | +using Yi.Framework.SqlSugarCore.Abstractions; | ||
| 7 | + | ||
| 8 | +namespace FoodLabeling.Th.Application.Filters; | ||
| 9 | + | ||
| 10 | +/// <summary> | ||
| 11 | +/// 泰额 SaaS:业务 AppService(fl_* / 租户 menu 等)须已解析租户上下文,禁止落到 host 主库。 | ||
| 12 | +/// 平台登录误调业务接口时返回友好错误,避免 <c>fl_label</c> 缺表 500。 | ||
| 13 | +/// </summary> | ||
| 14 | +public class FoodLabelingBusinessTenantActionFilter : IAsyncActionFilter, ITransientDependency | ||
| 15 | +{ | ||
| 16 | + private readonly ICurrentTenant _currentTenant; | ||
| 17 | + private readonly DbConnOptions _dbConnOptions; | ||
| 18 | + private readonly ISqlSugarDbContext _dbContext; | ||
| 19 | + | ||
| 20 | + public FoodLabelingBusinessTenantActionFilter( | ||
| 21 | + ICurrentTenant currentTenant, | ||
| 22 | + IOptions<DbConnOptions> dbConnOptions, | ||
| 23 | + ISqlSugarDbContext dbContext) | ||
| 24 | + { | ||
| 25 | + _currentTenant = currentTenant; | ||
| 26 | + _dbConnOptions = dbConnOptions.Value; | ||
| 27 | + _dbContext = dbContext; | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + public Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) | ||
| 31 | + { | ||
| 32 | + if (!_dbConnOptions.EnabledSaasMultiTenancy) | ||
| 33 | + { | ||
| 34 | + return next(); | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + var path = context.HttpContext.Request.Path.Value ?? string.Empty; | ||
| 38 | + if (!RequiresBusinessTenant(path)) | ||
| 39 | + { | ||
| 40 | + return next(); | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + TenantContextGuard.EnsureBusinessTenantIfSaas(_currentTenant, _dbConnOptions, ResolveOperationLabel(path)); | ||
| 44 | + TenantContextGuard.EnsureNotHostDatabaseIfSaas( | ||
| 45 | + _dbContext, | ||
| 46 | + _dbConnOptions, | ||
| 47 | + ResolveOperationLabel(path)); | ||
| 48 | + | ||
| 49 | + return next(); | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + /// <summary> | ||
| 53 | + /// 平台主库接口(yitenant / 登录 / 开户等)不要求业务租户上下文。 | ||
| 54 | + /// </summary> | ||
| 55 | + internal static bool RequiresBusinessTenant(string path) | ||
| 56 | + { | ||
| 57 | + if (string.IsNullOrWhiteSpace(path)) | ||
| 58 | + { | ||
| 59 | + return false; | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + if (!path.StartsWith("/api/app", StringComparison.OrdinalIgnoreCase)) | ||
| 63 | + { | ||
| 64 | + return false; | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + var lower = path.ToLowerInvariant(); | ||
| 68 | + foreach (var fragment in PlatformPathFragments) | ||
| 69 | + { | ||
| 70 | + if (lower.Contains(fragment, StringComparison.Ordinal)) | ||
| 71 | + { | ||
| 72 | + return false; | ||
| 73 | + } | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + return true; | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + private static string ResolveOperationLabel(string path) | ||
| 80 | + { | ||
| 81 | + var segment = path.Split('/', StringSplitOptions.RemoveEmptyEntries).LastOrDefault(); | ||
| 82 | + return string.IsNullOrWhiteSpace(segment) ? "业务接口" : segment; | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + /// <summary>平台侧路径片段(小写),命中则跳过业务租户校验。</summary> | ||
| 86 | + private static readonly string[] PlatformPathFragments = | ||
| 87 | + { | ||
| 88 | + "/th-web-auth", | ||
| 89 | + "/th-app-auth", | ||
| 90 | + "/th-multi-tenancy", | ||
| 91 | + "/th-tenant-provisioning", | ||
| 92 | + "/th-tenant-select", | ||
| 93 | + "/account", | ||
| 94 | + "/oauth", | ||
| 95 | + "/captcha", | ||
| 96 | + "/forgot-password", | ||
| 97 | + "/authorization", | ||
| 98 | + "/login", | ||
| 99 | + "/logout", | ||
| 100 | + "/wwwroot", | ||
| 101 | + "/hangfire", | ||
| 102 | + "/demo", | ||
| 103 | + "/food-label-demo", | ||
| 104 | + }; | ||
| 105 | +} |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/FoodLabelingThApplicationModule.cs
| 1 | using FoodLabeling.Application; | 1 | using FoodLabeling.Application; |
| 2 | using FoodLabeling.Th.Application.Contracts; | 2 | using FoodLabeling.Th.Application.Contracts; |
| 3 | using FoodLabeling.Th.Application.Contracts.Options; | 3 | using FoodLabeling.Th.Application.Contracts.Options; |
| 4 | +using FoodLabeling.Th.Application.Filters; | ||
| 4 | using FoodLabeling.Th.Domain; | 5 | using FoodLabeling.Th.Domain; |
| 6 | +using Microsoft.AspNetCore.Mvc; | ||
| 5 | using Microsoft.Extensions.Configuration; | 7 | using Microsoft.Extensions.Configuration; |
| 6 | using Microsoft.Extensions.DependencyInjection; | 8 | using Microsoft.Extensions.DependencyInjection; |
| 7 | using Yi.Framework.Ddd.Application; | 9 | using Yi.Framework.Ddd.Application; |
| @@ -31,5 +33,10 @@ public class FoodLabelingThApplicationModule : AbpModule | @@ -31,5 +33,10 @@ public class FoodLabelingThApplicationModule : AbpModule | ||
| 31 | 33 | ||
| 32 | Configure<FoodLabelingThTenantSelectCryptoOptions>( | 34 | Configure<FoodLabelingThTenantSelectCryptoOptions>( |
| 33 | configuration.GetSection(FoodLabelingThTenantSelectCryptoOptions.SectionName)); | 35 | configuration.GetSection(FoodLabelingThTenantSelectCryptoOptions.SectionName)); |
| 36 | + | ||
| 37 | + Configure<MvcOptions>(options => | ||
| 38 | + { | ||
| 39 | + options.Filters.AddService<FoodLabelingBusinessTenantActionFilter>(); | ||
| 40 | + }); | ||
| 34 | } | 41 | } |
| 35 | } | 42 | } |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs
| 1 | using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; | 1 | using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; |
| 2 | +using Yi.Framework.Rbac.Domain.Entities; | ||
| 2 | 3 | ||
| 3 | namespace FoodLabeling.Th.Application.MultiTenancy; | 4 | namespace FoodLabeling.Th.Application.MultiTenancy; |
| 4 | 5 | ||
| 5 | /// <summary> | 6 | /// <summary> |
| 6 | -/// 泰额版 SaaS 菜单权限目录(与前端 saas-menu-tree 一致) | 7 | +/// 平台可分配给公司的菜单:以主库 Menu 为准;并兼容历史 SaaS Key → 菜单 Id 映射。 |
| 7 | /// </summary> | 8 | /// </summary> |
| 8 | public static class ThSaasMenuPermissionCatalog | 9 | public static class ThSaasMenuPermissionCatalog |
| 9 | { | 10 | { |
| 10 | - private static readonly Lazy<IReadOnlyList<ThSaasMenuPermissionTreeNodeDto>> TreeLazy = | ||
| 11 | - new(BuildTree); | ||
| 12 | - | ||
| 13 | - private static readonly Lazy<HashSet<string>> AllKeysLazy = | ||
| 14 | - new(() => new HashSet<string>(CollectAllKeys(TreeLazy.Value), StringComparer.OrdinalIgnoreCase)); | ||
| 15 | - | ||
| 16 | /// <summary> | 11 | /// <summary> |
| 17 | - /// 菜单权限树 | 12 | + /// 历史静态 SaaS Key → 固定菜单 Guid(th-tenant-menu-seed) |
| 18 | /// </summary> | 13 | /// </summary> |
| 19 | - public static IReadOnlyList<ThSaasMenuPermissionTreeNodeDto> Tree => TreeLazy.Value; | 14 | + private static readonly Dictionary<string, string> LegacyKeyToMenuId = |
| 15 | + new(StringComparer.OrdinalIgnoreCase) | ||
| 16 | + { | ||
| 17 | + ["dashboard"] = "f0010001-0001-4000-8000-000000000001", | ||
| 18 | + ["dashboard:analytics"] = "f0010001-0001-4000-8000-000000000001", | ||
| 19 | + ["labeling"] = "f0010010-0001-4000-8000-000000000010", | ||
| 20 | + ["labeling:labels"] = "f0010011-0001-4000-8000-000000000011", | ||
| 21 | + ["labeling:categories"] = "f0010012-0001-4000-8000-000000000012", | ||
| 22 | + ["labeling:types"] = "f0010013-0001-4000-8000-000000000013", | ||
| 23 | + ["labeling:templates"] = "f0010014-0001-4000-8000-000000000014", | ||
| 24 | + ["labeling:multiple-options"] = "f0010015-0001-4000-8000-000000000015", | ||
| 25 | + ["modules"] = "f0010020-0001-4000-8000-000000000020", | ||
| 26 | + ["modules:training"] = "f0010021-0001-4000-8000-000000000021", | ||
| 27 | + ["modules:alerts"] = "f0010022-0001-4000-8000-000000000022", | ||
| 28 | + ["modules:tasks"] = "f0010023-0001-4000-8000-000000000023", | ||
| 29 | + ["modules:sensors"] = "f0010024-0001-4000-8000-000000000024", | ||
| 30 | + ["modules:food-waste"] = "f0010025-0001-4000-8000-000000000025", | ||
| 31 | + ["modules:e-label"] = "f0010026-0001-4000-8000-000000000026", | ||
| 32 | + ["management"] = "f0010030-0001-4000-8000-000000000030", | ||
| 33 | + ["management:account"] = "f0010031-0001-4000-8000-000000000031", | ||
| 34 | + ["management:system-menu"] = "f0010032-0001-4000-8000-000000000032", | ||
| 35 | + ["management:menu"] = "f0010033-0001-4000-8000-000000000033", | ||
| 36 | + ["management:devices"] = "f0010034-0001-4000-8000-000000000034", | ||
| 37 | + ["management:reports"] = "f0010035-0001-4000-8000-000000000035", | ||
| 38 | + ["management:invoices"] = "f0010036-0001-4000-8000-000000000036", | ||
| 39 | + ["management:qr-codes"] = "f0010037-0001-4000-8000-000000000037", | ||
| 40 | + ["management:support"] = "f0010038-0001-4000-8000-000000000038", | ||
| 41 | + ["management:api"] = "f0010039-0001-4000-8000-000000000039", | ||
| 42 | + }; | ||
| 20 | 43 | ||
| 21 | /// <summary> | 44 | /// <summary> |
| 22 | - /// 全部合法 permission key(含父节点) | 45 | + /// 是否为仅平台端菜单(不可分配给公司) |
| 23 | /// </summary> | 46 | /// </summary> |
| 24 | - public static IReadOnlySet<string> AllKeys => AllKeysLazy.Value; | 47 | + public static bool IsPlatformOnlyMenu(MenuAggregateRoot menu) |
| 48 | + { | ||
| 49 | + if (menu == null) | ||
| 50 | + { | ||
| 51 | + return true; | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + var code = menu.PermissionCode?.Trim() ?? string.Empty; | ||
| 55 | + if (code.StartsWith("menu.platform", StringComparison.OrdinalIgnoreCase)) | ||
| 56 | + { | ||
| 57 | + return true; | ||
| 58 | + } | ||
| 59 | + | ||
| 60 | + var router = menu.Router?.Trim() ?? string.Empty; | ||
| 61 | + return router.StartsWith("/platform", StringComparison.OrdinalIgnoreCase); | ||
| 62 | + } | ||
| 25 | 63 | ||
| 26 | /// <summary> | 64 | /// <summary> |
| 27 | - /// 校验 key 是否合法;返回非法 key 列表 | 65 | + /// 将历史 SaaS Key / PermissionCode / 菜单 Id 统一为菜单 Id 字符串 |
| 28 | /// </summary> | 66 | /// </summary> |
| 29 | - public static List<string> FindInvalidKeys(IEnumerable<string>? keys) | 67 | + public static List<string> NormalizeToMenuIds( |
| 68 | + IEnumerable<string>? keys, | ||
| 69 | + IReadOnlyDictionary<string, string>? permissionCodeToMenuId = null) | ||
| 30 | { | 70 | { |
| 31 | if (keys == null) | 71 | if (keys == null) |
| 32 | { | 72 | { |
| 33 | return new List<string>(); | 73 | return new List<string>(); |
| 34 | } | 74 | } |
| 35 | 75 | ||
| 36 | - return keys | ||
| 37 | - .Where(x => !string.IsNullOrWhiteSpace(x)) | ||
| 38 | - .Select(x => x.Trim()) | ||
| 39 | - .Distinct(StringComparer.OrdinalIgnoreCase) | ||
| 40 | - .Where(x => !AllKeys.Contains(x)) | ||
| 41 | - .ToList(); | 76 | + var result = new List<string>(); |
| 77 | + var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||
| 78 | + foreach (var raw in keys) | ||
| 79 | + { | ||
| 80 | + if (string.IsNullOrWhiteSpace(raw)) | ||
| 81 | + { | ||
| 82 | + continue; | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + var key = raw.Trim(); | ||
| 86 | + string? menuId = null; | ||
| 87 | + | ||
| 88 | + if (Guid.TryParse(key, out _)) | ||
| 89 | + { | ||
| 90 | + menuId = key; | ||
| 91 | + } | ||
| 92 | + else if (LegacyKeyToMenuId.TryGetValue(key, out var mapped)) | ||
| 93 | + { | ||
| 94 | + menuId = mapped; | ||
| 95 | + } | ||
| 96 | + else if (permissionCodeToMenuId != null | ||
| 97 | + && permissionCodeToMenuId.TryGetValue(key, out var byCode)) | ||
| 98 | + { | ||
| 99 | + menuId = byCode; | ||
| 100 | + } | ||
| 101 | + | ||
| 102 | + if (string.IsNullOrWhiteSpace(menuId) || !seen.Add(menuId)) | ||
| 103 | + { | ||
| 104 | + continue; | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + result.Add(menuId); | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + return result; | ||
| 42 | } | 111 | } |
| 43 | 112 | ||
| 44 | - private static IReadOnlyList<ThSaasMenuPermissionTreeNodeDto> BuildTree() => | ||
| 45 | - new List<ThSaasMenuPermissionTreeNodeDto> | 113 | + public static List<ThSaasMenuPermissionTreeNodeDto> BuildTree(IEnumerable<MenuAggregateRoot> menus) |
| 114 | + { | ||
| 115 | + var list = menus | ||
| 116 | + .Where(m => m != null && !m.IsDeleted && !IsPlatformOnlyMenu(m)) | ||
| 117 | + .OrderBy(m => m.OrderNum) | ||
| 118 | + .ThenBy(m => m.MenuName) | ||
| 119 | + .ToList(); | ||
| 120 | + | ||
| 121 | + var nodes = list.ToDictionary( | ||
| 122 | + m => m.Id.ToString(), | ||
| 123 | + m => new ThSaasMenuPermissionTreeNodeDto | ||
| 124 | + { | ||
| 125 | + Key = m.Id.ToString(), | ||
| 126 | + Title = string.IsNullOrWhiteSpace(m.MenuName) ? m.Id.ToString() : m.MenuName!, | ||
| 127 | + Children = new List<ThSaasMenuPermissionTreeNodeDto>() | ||
| 128 | + }, | ||
| 129 | + StringComparer.OrdinalIgnoreCase); | ||
| 130 | + | ||
| 131 | + var roots = new List<ThSaasMenuPermissionTreeNodeDto>(); | ||
| 132 | + foreach (var menu in list) | ||
| 46 | { | 133 | { |
| 47 | - Node("dashboard", "仪表盘", Node("dashboard:analytics", "数据分析")), | ||
| 48 | - Node( | ||
| 49 | - "labeling", | ||
| 50 | - "标签管理", | ||
| 51 | - Node("labeling:labels", "标签列表"), | ||
| 52 | - Node("labeling:categories", "标签分类"), | ||
| 53 | - Node("labeling:types", "标签类型"), | ||
| 54 | - Node("labeling:templates", "标签模板"), | ||
| 55 | - Node("labeling:multiple-options", "多选项")), | ||
| 56 | - Node( | ||
| 57 | - "modules", | ||
| 58 | - "功能模块", | ||
| 59 | - Node("modules:training", "培训"), | ||
| 60 | - Node("modules:alerts", "告警"), | ||
| 61 | - Node("modules:tasks", "任务"), | ||
| 62 | - Node("modules:food-waste", "食物浪费"), | ||
| 63 | - Node("modules:e-label", "电子标签")), | ||
| 64 | - Node( | ||
| 65 | - "management", | ||
| 66 | - "系统管理", | ||
| 67 | - Node("management:account", "账号管理"), | ||
| 68 | - Node("management:menu", "菜单管理"), | ||
| 69 | - Node("management:devices", "设备管理"), | ||
| 70 | - Node("management:reports", "报表"), | ||
| 71 | - Node("management:invoices", "发票"), | ||
| 72 | - Node("management:qr-codes", "二维码"), | ||
| 73 | - Node("management:support", "支持"), | ||
| 74 | - Node("management:api", "API")) | ||
| 75 | - }; | 134 | + var node = nodes[menu.Id.ToString()]; |
| 135 | + var parentId = string.IsNullOrWhiteSpace(menu.ParentId) ? "0" : menu.ParentId.Trim(); | ||
| 136 | + if (parentId == "0" | ||
| 137 | + || parentId == Guid.Empty.ToString() | ||
| 138 | + || !nodes.TryGetValue(parentId, out var parent)) | ||
| 139 | + { | ||
| 140 | + roots.Add(node); | ||
| 141 | + continue; | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + parent.Children ??= new List<ThSaasMenuPermissionTreeNodeDto>(); | ||
| 145 | + parent.Children.Add(node); | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + NormalizeEmptyChildren(roots); | ||
| 149 | + return roots; | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + public static HashSet<string> CollectAssignableMenuIds(IEnumerable<MenuAggregateRoot> menus) | ||
| 153 | + { | ||
| 154 | + return menus | ||
| 155 | + .Where(m => m != null && !m.IsDeleted && !IsPlatformOnlyMenu(m)) | ||
| 156 | + .Select(m => m.Id.ToString()) | ||
| 157 | + .ToHashSet(StringComparer.OrdinalIgnoreCase); | ||
| 158 | + } | ||
| 76 | 159 | ||
| 77 | - private static ThSaasMenuPermissionTreeNodeDto Node( | ||
| 78 | - string key, | ||
| 79 | - string title, | ||
| 80 | - params ThSaasMenuPermissionTreeNodeDto[] children) | 160 | + public static List<string> FindInvalidMenuIds( |
| 161 | + IEnumerable<string>? keys, | ||
| 162 | + IReadOnlySet<string> assignableMenuIds) | ||
| 81 | { | 163 | { |
| 82 | - return new ThSaasMenuPermissionTreeNodeDto | 164 | + if (keys == null) |
| 83 | { | 165 | { |
| 84 | - Key = key, | ||
| 85 | - Title = title, | ||
| 86 | - Children = children.Length == 0 ? null : children.ToList() | ||
| 87 | - }; | 166 | + return new List<string>(); |
| 167 | + } | ||
| 168 | + | ||
| 169 | + return keys | ||
| 170 | + .Where(x => !string.IsNullOrWhiteSpace(x)) | ||
| 171 | + .Select(x => x.Trim()) | ||
| 172 | + .Distinct(StringComparer.OrdinalIgnoreCase) | ||
| 173 | + .Where(x => !assignableMenuIds.Contains(x)) | ||
| 174 | + .ToList(); | ||
| 88 | } | 175 | } |
| 89 | 176 | ||
| 90 | - private static IEnumerable<string> CollectAllKeys(IEnumerable<ThSaasMenuPermissionTreeNodeDto> nodes) | 177 | + private static void NormalizeEmptyChildren(List<ThSaasMenuPermissionTreeNodeDto> nodes) |
| 91 | { | 178 | { |
| 92 | foreach (var node in nodes) | 179 | foreach (var node in nodes) |
| 93 | { | 180 | { |
| 94 | - yield return node.Key; | ||
| 95 | - if (node.Children == null) | 181 | + if (node.Children == null || node.Children.Count == 0) |
| 96 | { | 182 | { |
| 183 | + node.Children = null; | ||
| 97 | continue; | 184 | continue; |
| 98 | } | 185 | } |
| 99 | 186 | ||
| 100 | - foreach (var childKey in CollectAllKeys(node.Children)) | ||
| 101 | - { | ||
| 102 | - yield return childKey; | ||
| 103 | - } | 187 | + NormalizeEmptyChildren(node.Children); |
| 104 | } | 188 | } |
| 105 | } | 189 | } |
| 106 | } | 190 | } |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs
| @@ -14,9 +14,9 @@ public static class ThWebPlatformLoginHelper | @@ -14,9 +14,9 @@ public static class ThWebPlatformLoginHelper | ||
| 14 | /// <summary>平台登录成功时 th-web-auth 返回的 tenantName(无 TenantId Claim)</summary> | 14 | /// <summary>平台登录成功时 th-web-auth 返回的 tenantName(无 TenantId Claim)</summary> |
| 15 | public const string PlatformTenantDisplayName = "Platform"; | 15 | public const string PlatformTenantDisplayName = "Platform"; |
| 16 | 16 | ||
| 17 | - /// <summary>平台账号误选业务租户时的登录拒绝提示</summary> | 17 | + /// <summary>平台邮箱账号误选业务租户时的登录拒绝提示</summary> |
| 18 | public const string PlatformAccountMustUseDefaultMessage = | 18 | public const string PlatformAccountMustUseDefaultMessage = |
| 19 | - "登录失败:平台管理员账号请选择 Default 选项登录,不能使用业务租户登录"; | 19 | + "登录失败:平台管理员邮箱请选择 Default 选项登录,不能使用业务租户登录"; |
| 20 | 20 | ||
| 21 | /// <summary> | 21 | /// <summary> |
| 22 | /// 是否为业务租户登录(选了具体公司,而非 Default / 空 tenantId) | 22 | /// 是否为业务租户登录(选了具体公司,而非 Default / 空 tenantId) |
泰额版/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 | @@ -46,6 +46,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 46 | private readonly ISqlSugarRepository<TenantAggregateRoot, Guid> _tenantRepository; | 46 | private readonly ISqlSugarRepository<TenantAggregateRoot, Guid> _tenantRepository; |
| 47 | private readonly ISqlSugarRepository<ThTenantAdminCredentialEntity, Guid> _credentialRepository; | 47 | private readonly ISqlSugarRepository<ThTenantAdminCredentialEntity, Guid> _credentialRepository; |
| 48 | private readonly ISqlSugarRepository<ThTenantMenuPermissionEntity, string> _menuPermissionRepository; | 48 | private readonly ISqlSugarRepository<ThTenantMenuPermissionEntity, string> _menuPermissionRepository; |
| 49 | + private readonly ISqlSugarRepository<MenuAggregateRoot, Guid> _menuRepository; | ||
| 49 | private readonly TenantSelectCredentialCipher _credentialCipher; | 50 | private readonly TenantSelectCredentialCipher _credentialCipher; |
| 50 | private readonly RbacOptions _rbacOptions; | 51 | private readonly RbacOptions _rbacOptions; |
| 51 | private readonly FoodLabelingThTenantDatabaseOptions _tenantDatabaseOptions; | 52 | private readonly FoodLabelingThTenantDatabaseOptions _tenantDatabaseOptions; |
| @@ -57,6 +58,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -57,6 +58,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 57 | ISqlSugarRepository<TenantAggregateRoot, Guid> tenantRepository, | 58 | ISqlSugarRepository<TenantAggregateRoot, Guid> tenantRepository, |
| 58 | ISqlSugarRepository<ThTenantAdminCredentialEntity, Guid> credentialRepository, | 59 | ISqlSugarRepository<ThTenantAdminCredentialEntity, Guid> credentialRepository, |
| 59 | ISqlSugarRepository<ThTenantMenuPermissionEntity, string> menuPermissionRepository, | 60 | ISqlSugarRepository<ThTenantMenuPermissionEntity, string> menuPermissionRepository, |
| 61 | + ISqlSugarRepository<MenuAggregateRoot, Guid> menuRepository, | ||
| 60 | TenantSelectCredentialCipher credentialCipher, | 62 | TenantSelectCredentialCipher credentialCipher, |
| 61 | IOptions<RbacOptions> rbacOptions, | 63 | IOptions<RbacOptions> rbacOptions, |
| 62 | IOptions<FoodLabelingThTenantDatabaseOptions> tenantDatabaseOptions, | 64 | IOptions<FoodLabelingThTenantDatabaseOptions> tenantDatabaseOptions, |
| @@ -67,6 +69,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -67,6 +69,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 67 | _tenantRepository = tenantRepository; | 69 | _tenantRepository = tenantRepository; |
| 68 | _credentialRepository = credentialRepository; | 70 | _credentialRepository = credentialRepository; |
| 69 | _menuPermissionRepository = menuPermissionRepository; | 71 | _menuPermissionRepository = menuPermissionRepository; |
| 72 | + _menuRepository = menuRepository; | ||
| 70 | _credentialCipher = credentialCipher; | 73 | _credentialCipher = credentialCipher; |
| 71 | _rbacOptions = rbacOptions.Value; | 74 | _rbacOptions = rbacOptions.Value; |
| 72 | _tenantDatabaseOptions = tenantDatabaseOptions.Value; | 75 | _tenantDatabaseOptions = tenantDatabaseOptions.Value; |
| @@ -333,33 +336,17 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -333,33 +336,17 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 333 | } | 336 | } |
| 334 | 337 | ||
| 335 | /// <summary> | 338 | /// <summary> |
| 336 | - /// 获取 SaaS 菜单权限树 | 339 | + /// 获取可分配给公司的平台菜单树(主库 Menu,排除仅平台端菜单) |
| 337 | /// </summary> | 340 | /// </summary> |
| 338 | /// <remarks> | 341 | /// <remarks> |
| 339 | - /// 供平台管理员配置租户菜单权限时使用;Key 与前端 saas-menu-tree 一致。 | ||
| 340 | - /// | ||
| 341 | - /// 示例响应: | ||
| 342 | - /// ```json | ||
| 343 | - /// [ | ||
| 344 | - /// { | ||
| 345 | - /// "key": "dashboard", | ||
| 346 | - /// "title": "仪表盘", | ||
| 347 | - /// "children": [ | ||
| 348 | - /// { "key": "dashboard:analytics", "title": "数据分析" } | ||
| 349 | - /// ] | ||
| 350 | - /// } | ||
| 351 | - /// ] | ||
| 352 | - /// ``` | 342 | + /// 节点 key = 菜单 Id(与租户业务库固定 Guid 种子一致)。 |
| 353 | /// </remarks> | 343 | /// </remarks> |
| 354 | - /// <returns>SaaS 菜单权限树</returns> | ||
| 355 | - /// <response code="200">成功返回权限树</response> | ||
| 356 | - /// <response code="401">未登录</response> | ||
| 357 | - /// <response code="500">服务器错误</response> | ||
| 358 | [Authorize] | 344 | [Authorize] |
| 359 | [HttpGet("th-multi-tenancy/menu-permission-tree")] | 345 | [HttpGet("th-multi-tenancy/menu-permission-tree")] |
| 360 | - public virtual Task<List<ThSaasMenuPermissionTreeNodeDto>> GetMenuPermissionTreeAsync() | 346 | + public virtual async Task<List<ThSaasMenuPermissionTreeNodeDto>> GetMenuPermissionTreeAsync() |
| 361 | { | 347 | { |
| 362 | - return Task.FromResult(CloneTree(ThSaasMenuPermissionCatalog.Tree)); | 348 | + var menus = await LoadHostMenusAsync(); |
| 349 | + return ThSaasMenuPermissionCatalog.BuildTree(menus); | ||
| 363 | } | 350 | } |
| 364 | 351 | ||
| 365 | /// <summary> | 352 | /// <summary> |
| @@ -441,8 +428,17 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -441,8 +428,17 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 441 | 428 | ||
| 442 | await EnsureTenantExistsAsync(input.TenantId); | 429 | await EnsureTenantExistsAsync(input.TenantId); |
| 443 | 430 | ||
| 444 | - var normalizedKeys = NormalizePermissionKeys(input.MenuPermissionKeys); | ||
| 445 | - var invalidKeys = ThSaasMenuPermissionCatalog.FindInvalidKeys(normalizedKeys); | 431 | + var hostMenus = await LoadHostMenusAsync(); |
| 432 | + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); | ||
| 433 | + var permissionCodeMap = hostMenus | ||
| 434 | + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) | ||
| 435 | + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) | ||
| 436 | + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); | ||
| 437 | + | ||
| 438 | + var normalizedKeys = ThSaasMenuPermissionCatalog.NormalizeToMenuIds( | ||
| 439 | + input.MenuPermissionKeys, | ||
| 440 | + permissionCodeMap); | ||
| 441 | + var invalidKeys = ThSaasMenuPermissionCatalog.FindInvalidMenuIds(normalizedKeys, assignableIds); | ||
| 446 | if (invalidKeys.Count > 0) | 442 | if (invalidKeys.Count > 0) |
| 447 | { | 443 | { |
| 448 | throw new UserFriendlyException($"存在非法菜单权限 Key:{string.Join(", ", invalidKeys)}"); | 444 | throw new UserFriendlyException($"存在非法菜单权限 Key:{string.Join(", ", invalidKeys)}"); |
| @@ -454,6 +450,8 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -454,6 +450,8 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 454 | 450 | ||
| 455 | if (normalizedKeys.Count == 0) | 451 | if (normalizedKeys.Count == 0) |
| 456 | { | 452 | { |
| 453 | + // 清空公司开通菜单时,同步清空租户管理员角色菜单 | ||
| 454 | + await SyncTenantAdminRoleMenusAsync(input.TenantId, Array.Empty<Guid>()); | ||
| 457 | return; | 455 | return; |
| 458 | } | 456 | } |
| 459 | 457 | ||
| @@ -468,6 +466,12 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -468,6 +466,12 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 468 | 466 | ||
| 469 | await _menuPermissionRepository.InsertRangeAsync(entities); | 467 | await _menuPermissionRepository.InsertRangeAsync(entities); |
| 470 | } | 468 | } |
| 469 | + | ||
| 470 | + var menuIds = normalizedKeys | ||
| 471 | + .Select(x => Guid.TryParse(x, out var id) ? id : Guid.Empty) | ||
| 472 | + .Where(x => x != Guid.Empty) | ||
| 473 | + .ToList(); | ||
| 474 | + await SyncTenantAdminRoleMenusAsync(input.TenantId, menuIds); | ||
| 471 | } | 475 | } |
| 472 | 476 | ||
| 473 | /// <summary> | 477 | /// <summary> |
| @@ -590,6 +594,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -590,6 +594,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 590 | 594 | ||
| 591 | var tenant = await EnsureTenantExistsAsync(input.TenantId); | 595 | var tenant = await EnsureTenantExistsAsync(input.TenantId); |
| 592 | var menuIds = ParseMenuIds(input.MenuIds); | 596 | var menuIds = ParseMenuIds(input.MenuIds); |
| 597 | + | ||
| 598 | + // 角色菜单不得超过平台分配给该公司的菜单范围 | ||
| 599 | + var allowedKeys = await LoadMenuPermissionKeysAsync(input.TenantId); | ||
| 600 | + if (allowedKeys.Count == 0) | ||
| 601 | + { | ||
| 602 | + if (menuIds.Count > 0) | ||
| 603 | + { | ||
| 604 | + throw new UserFriendlyException("该公司尚未开通任何菜单,请先在「菜单权限」中分配"); | ||
| 605 | + } | ||
| 606 | + } | ||
| 607 | + else | ||
| 608 | + { | ||
| 609 | + var allowed = allowedKeys.ToHashSet(StringComparer.OrdinalIgnoreCase); | ||
| 610 | + var outOfScope = menuIds | ||
| 611 | + .Select(x => x.ToString()) | ||
| 612 | + .Where(x => !allowed.Contains(x)) | ||
| 613 | + .ToList(); | ||
| 614 | + if (outOfScope.Count > 0) | ||
| 615 | + { | ||
| 616 | + throw new UserFriendlyException( | ||
| 617 | + $"角色菜单超出公司已开通范围:{string.Join(", ", outOfScope)}"); | ||
| 618 | + } | ||
| 619 | + } | ||
| 620 | + | ||
| 593 | var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant); | 621 | var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant); |
| 594 | using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); | 622 | using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); |
| 595 | TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, input.TenantId); | 623 | TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, input.TenantId); |
| @@ -905,14 +933,93 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -905,14 +933,93 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 905 | 933 | ||
| 906 | private async Task<List<string>> LoadMenuPermissionKeysAsync(Guid tenantId) | 934 | private async Task<List<string>> LoadMenuPermissionKeysAsync(Guid tenantId) |
| 907 | { | 935 | { |
| 936 | + List<string> rawKeys; | ||
| 908 | using (UseHostTenantScope()) | 937 | using (UseHostTenantScope()) |
| 909 | { | 938 | { |
| 910 | - return await _menuPermissionRepository._DbQueryable | 939 | + rawKeys = await _menuPermissionRepository._DbQueryable |
| 911 | .Where(x => x.TenantId == tenantId) | 940 | .Where(x => x.TenantId == tenantId) |
| 912 | .OrderBy(x => x.CreationTime) | 941 | .OrderBy(x => x.CreationTime) |
| 913 | .Select(x => x.PermissionKey) | 942 | .Select(x => x.PermissionKey) |
| 914 | .ToListAsync(); | 943 | .ToListAsync(); |
| 915 | } | 944 | } |
| 945 | + | ||
| 946 | + var hostMenus = await LoadHostMenusAsync(); | ||
| 947 | + var permissionCodeMap = hostMenus | ||
| 948 | + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) | ||
| 949 | + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) | ||
| 950 | + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); | ||
| 951 | + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); | ||
| 952 | + return ThSaasMenuPermissionCatalog.NormalizeToMenuIds(rawKeys, permissionCodeMap) | ||
| 953 | + .Where(assignableIds.Contains) | ||
| 954 | + .ToList(); | ||
| 955 | + } | ||
| 956 | + | ||
| 957 | + private async Task<List<MenuAggregateRoot>> LoadHostMenusAsync() | ||
| 958 | + { | ||
| 959 | + using (UseHostTenantScope()) | ||
| 960 | + { | ||
| 961 | + return await _menuRepository._DbQueryable | ||
| 962 | + .Where(x => !x.IsDeleted) | ||
| 963 | + .OrderBy(x => x.OrderNum) | ||
| 964 | + .ToListAsync(); | ||
| 965 | + } | ||
| 966 | + } | ||
| 967 | + | ||
| 968 | + /// <summary> | ||
| 969 | + /// 公司管理员角色菜单 = 平台分配给该公司的菜单集合 | ||
| 970 | + /// </summary> | ||
| 971 | + private async Task SyncTenantAdminRoleMenusAsync(Guid tenantId, IReadOnlyCollection<Guid> menuIds) | ||
| 972 | + { | ||
| 973 | + var tenant = await EnsureTenantExistsAsync(tenantId); | ||
| 974 | + var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant); | ||
| 975 | + using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); | ||
| 976 | + TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, tenantId); | ||
| 977 | + | ||
| 978 | + var adminRoles = await tenantDb.Queryable<RoleAggregateRoot>() | ||
| 979 | + .Where(r => !r.IsDeleted) | ||
| 980 | + .Where(r => r.RoleCode == UserConst.AdminRolesCode || r.RoleCode == UserConst.Admin) | ||
| 981 | + .OrderBy(r => r.OrderNum) | ||
| 982 | + .Take(1) | ||
| 983 | + .ToListAsync(); | ||
| 984 | + var adminRole = adminRoles.FirstOrDefault(); | ||
| 985 | + | ||
| 986 | + if (adminRole is null) | ||
| 987 | + { | ||
| 988 | + return; | ||
| 989 | + } | ||
| 990 | + | ||
| 991 | + await tenantDb.Deleteable<RoleMenuEntity>() | ||
| 992 | + .Where(x => x.RoleId == adminRole.Id) | ||
| 993 | + .ExecuteCommandAsync(); | ||
| 994 | + | ||
| 995 | + if (menuIds.Count == 0) | ||
| 996 | + { | ||
| 997 | + return; | ||
| 998 | + } | ||
| 999 | + | ||
| 1000 | + var existMenuIds = await tenantDb.Queryable<MenuAggregateRoot>() | ||
| 1001 | + .Where(x => !x.IsDeleted) | ||
| 1002 | + .Where(x => menuIds.Contains(x.Id)) | ||
| 1003 | + .Select(x => x.Id) | ||
| 1004 | + .ToListAsync(); | ||
| 1005 | + | ||
| 1006 | + if (existMenuIds.Count == 0) | ||
| 1007 | + { | ||
| 1008 | + return; | ||
| 1009 | + } | ||
| 1010 | + | ||
| 1011 | + var entities = existMenuIds.Select(menuId => | ||
| 1012 | + { | ||
| 1013 | + var entity = new RoleMenuEntity | ||
| 1014 | + { | ||
| 1015 | + RoleId = adminRole.Id, | ||
| 1016 | + MenuId = menuId | ||
| 1017 | + }; | ||
| 1018 | + EntityHelper.TrySetId(entity, () => GuidGenerator.Create()); | ||
| 1019 | + return entity; | ||
| 1020 | + }).ToList(); | ||
| 1021 | + | ||
| 1022 | + await tenantDb.Insertable(entities).ExecuteCommandAsync(); | ||
| 916 | } | 1023 | } |
| 917 | 1024 | ||
| 918 | private async Task<Dictionary<Guid, List<string>>> LoadMenuPermissionMapAsync( | 1025 | private async Task<Dictionary<Guid, List<string>>> LoadMenuPermissionMapAsync( |
| @@ -923,32 +1030,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -923,32 +1030,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 923 | return new Dictionary<Guid, List<string>>(); | 1030 | return new Dictionary<Guid, List<string>>(); |
| 924 | } | 1031 | } |
| 925 | 1032 | ||
| 1033 | + List<ThTenantMenuPermissionEntity> list; | ||
| 926 | using (UseHostTenantScope()) | 1034 | using (UseHostTenantScope()) |
| 927 | { | 1035 | { |
| 928 | - var list = await _menuPermissionRepository._DbQueryable | 1036 | + list = await _menuPermissionRepository._DbQueryable |
| 929 | .Where(x => tenantIds.Contains(x.TenantId)) | 1037 | .Where(x => tenantIds.Contains(x.TenantId)) |
| 930 | .ToListAsync(); | 1038 | .ToListAsync(); |
| 931 | - | ||
| 932 | - return list | ||
| 933 | - .GroupBy(x => x.TenantId) | ||
| 934 | - .ToDictionary( | ||
| 935 | - g => g.Key, | ||
| 936 | - g => g.Select(x => x.PermissionKey).Distinct(StringComparer.OrdinalIgnoreCase).ToList()); | ||
| 937 | } | 1039 | } |
| 938 | - } | ||
| 939 | 1040 | ||
| 940 | - private static List<string> NormalizePermissionKeys(IEnumerable<string>? keys) | ||
| 941 | - { | ||
| 942 | - if (keys == null) | ||
| 943 | - { | ||
| 944 | - return new List<string>(); | ||
| 945 | - } | 1041 | + var hostMenus = await LoadHostMenusAsync(); |
| 1042 | + var permissionCodeMap = hostMenus | ||
| 1043 | + .Where(m => !string.IsNullOrWhiteSpace(m.PermissionCode)) | ||
| 1044 | + .GroupBy(m => m.PermissionCode!.Trim(), StringComparer.OrdinalIgnoreCase) | ||
| 1045 | + .ToDictionary(g => g.Key, g => g.First().Id.ToString(), StringComparer.OrdinalIgnoreCase); | ||
| 1046 | + var assignableIds = ThSaasMenuPermissionCatalog.CollectAssignableMenuIds(hostMenus); | ||
| 946 | 1047 | ||
| 947 | - return keys | ||
| 948 | - .Where(x => !string.IsNullOrWhiteSpace(x)) | ||
| 949 | - .Select(x => x.Trim()) | ||
| 950 | - .Distinct(StringComparer.OrdinalIgnoreCase) | ||
| 951 | - .ToList(); | 1048 | + return list |
| 1049 | + .GroupBy(x => x.TenantId) | ||
| 1050 | + .ToDictionary( | ||
| 1051 | + g => g.Key, | ||
| 1052 | + g => ThSaasMenuPermissionCatalog.NormalizeToMenuIds( | ||
| 1053 | + g.Select(x => x.PermissionKey), | ||
| 1054 | + permissionCodeMap) | ||
| 1055 | + .Where(assignableIds.Contains) | ||
| 1056 | + .ToList()); | ||
| 952 | } | 1057 | } |
| 953 | 1058 | ||
| 954 | private static List<Guid> ParseMenuIds(IEnumerable<string>? menuIds) | 1059 | private static List<Guid> ParseMenuIds(IEnumerable<string>? menuIds) |
| @@ -981,17 +1086,6 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | @@ -981,17 +1086,6 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe | ||
| 981 | return result; | 1086 | return result; |
| 982 | } | 1087 | } |
| 983 | 1088 | ||
| 984 | - private static List<ThSaasMenuPermissionTreeNodeDto> CloneTree( | ||
| 985 | - IEnumerable<ThSaasMenuPermissionTreeNodeDto> nodes) | ||
| 986 | - { | ||
| 987 | - return nodes.Select(node => new ThSaasMenuPermissionTreeNodeDto | ||
| 988 | - { | ||
| 989 | - Key = node.Key, | ||
| 990 | - Title = node.Title, | ||
| 991 | - Children = node.Children == null ? null : CloneTree(node.Children) | ||
| 992 | - }).ToList(); | ||
| 993 | - } | ||
| 994 | - | ||
| 995 | private static void EnsureTenantDeletable(Guid tenantId, string? tenantName) | 1089 | private static void EnsureTenantDeletable(Guid tenantId, string? tenantName) |
| 996 | { | 1090 | { |
| 997 | if (tenantId == ProtectedDefaultTenantId) | 1091 | if (tenantId == ProtectedDefaultTenantId) |
泰额版/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 | @@ -49,7 +49,8 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService | ||
| 49 | /// 校验租户后在租户独立库验证账号,签发含 TenantId 与 RBAC 权限的 JWT。 | 49 | /// 校验租户后在租户独立库验证账号,签发含 TenantId 与 RBAC 权限的 JWT。 |
| 50 | /// | 50 | /// |
| 51 | /// 泰额 H5 与公司 Web 共用此接口。选 **Default** 或 tenantId 为空时,若邮箱账号存在于主库则走**平台登录**(JWT 无 TenantId); | 51 | /// 泰额 H5 与公司 Web 共用此接口。选 **Default** 或 tenantId 为空时,若邮箱账号存在于主库则走**平台登录**(JWT 无 TenantId); |
| 52 | - /// 选具体公司 tenantId 时在该公司业务库校验;若账号已存在于主库(平台管理员),则拒绝登录并提示改用 Default。 | 52 | + /// 选具体公司 tenantId 时在该公司业务库校验;若登录标识为邮箱且已存在于主库(平台管理员邮箱),则拒绝并提示改用 Default。 |
| 53 | + /// 租户默认账号 UserName=admin 与主库同名时允许业务租户登录。 | ||
| 53 | /// Default 业务库(如 US)仅在主库无该邮箱时作为回落。 | 54 | /// Default 业务库(如 US)仅在主库无该邮箱时作为回落。 |
| 54 | /// | 55 | /// |
| 55 | /// 示例请求(平台,选 Default): | 56 | /// 示例请求(平台,选 Default): |
| @@ -162,7 +163,8 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService | @@ -162,7 +163,8 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService | ||
| 162 | } | 163 | } |
| 163 | 164 | ||
| 164 | /// <summary> | 165 | /// <summary> |
| 165 | - /// 业务租户登录前校验:主库已存在的账号(平台管理员)不得落入公司业务库。 | 166 | + /// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。 |
| 167 | + /// 仅拦截邮箱形态(与 TryPlatformLoginAsync 一致);租户默认 UserName=admin 与主库 admin 同名时允许走租户库。 | ||
| 166 | /// </summary> | 168 | /// </summary> |
| 167 | private async Task EnsureNotHostPlatformAccountAsync(string userName) | 169 | private async Task EnsureNotHostPlatformAccountAsync(string userName) |
| 168 | { | 170 | { |
| @@ -171,6 +173,12 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService | @@ -171,6 +173,12 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService | ||
| 171 | return; | 173 | return; |
| 172 | } | 174 | } |
| 173 | 175 | ||
| 176 | + // 租户管理员默认账号常为 admin,与主库平台 UserName 同名;不可按用户名拦截 | ||
| 177 | + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(userName)) | ||
| 178 | + { | ||
| 179 | + return; | ||
| 180 | + } | ||
| 181 | + | ||
| 174 | using (CurrentTenant.Change(null)) | 182 | using (CurrentTenant.Change(null)) |
| 175 | { | 183 | { |
| 176 | var hostUser = await FindActiveUserByEmailAsync(userName.Trim()); | 184 | var hostUser = await FindActiveUserByEmailAsync(userName.Trim()); |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs
| 1 | using System; | 1 | using System; |
| 2 | using System.Collections.Generic; | 2 | using System.Collections.Generic; |
| 3 | using System.Diagnostics; | 3 | using System.Diagnostics; |
| 4 | +using System.IdentityModel.Tokens.Jwt; | ||
| 4 | using System.Linq; | 5 | using System.Linq; |
| 5 | using System.Text; | 6 | using System.Text; |
| 6 | using System.Threading.Tasks; | 7 | using System.Threading.Tasks; |
| @@ -9,6 +10,7 @@ using Microsoft.AspNetCore.Authentication; | @@ -9,6 +10,7 @@ using Microsoft.AspNetCore.Authentication; | ||
| 9 | using Microsoft.AspNetCore.Builder; | 10 | using Microsoft.AspNetCore.Builder; |
| 10 | using Microsoft.AspNetCore.Http; | 11 | using Microsoft.AspNetCore.Http; |
| 11 | using Volo.Abp.DependencyInjection; | 12 | using Volo.Abp.DependencyInjection; |
| 13 | +using Volo.Abp.MultiTenancy; | ||
| 12 | using Volo.Abp.Security.Claims; | 14 | using Volo.Abp.Security.Claims; |
| 13 | using Yi.Framework.Rbac.Domain.Managers; | 15 | using Yi.Framework.Rbac.Domain.Managers; |
| 14 | using Yi.Framework.Rbac.Domain.Shared.Consts; | 16 | using Yi.Framework.Rbac.Domain.Shared.Consts; |
| @@ -18,11 +20,13 @@ namespace Yi.Framework.Rbac.Domain.Authorization | @@ -18,11 +20,13 @@ namespace Yi.Framework.Rbac.Domain.Authorization | ||
| 18 | [DebuggerStepThrough] | 20 | [DebuggerStepThrough] |
| 19 | public class RefreshTokenMiddleware : IMiddleware, ITransientDependency | 21 | public class RefreshTokenMiddleware : IMiddleware, ITransientDependency |
| 20 | { | 22 | { |
| 21 | - private AccountManager _accountManager; | ||
| 22 | - public RefreshTokenMiddleware(AccountManager accountManager) | ||
| 23 | - { | 23 | + private readonly AccountManager _accountManager; |
| 24 | + private readonly ICurrentTenant _currentTenant; | ||
| 24 | 25 | ||
| 26 | + public RefreshTokenMiddleware(AccountManager accountManager, ICurrentTenant currentTenant) | ||
| 27 | + { | ||
| 25 | _accountManager = accountManager; | 28 | _accountManager = accountManager; |
| 29 | + _currentTenant = currentTenant; | ||
| 26 | } | 30 | } |
| 27 | 31 | ||
| 28 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) | 32 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) |
| @@ -36,18 +40,75 @@ namespace Yi.Framework.Rbac.Domain.Authorization | @@ -36,18 +40,75 @@ namespace Yi.Framework.Rbac.Domain.Authorization | ||
| 36 | if (authResult.Succeeded) | 40 | if (authResult.Succeeded) |
| 37 | { | 41 | { |
| 38 | var userId = Guid.Parse(authResult.Principal.FindFirst(AbpClaimTypes.UserId).Value.ToString()); | 42 | var userId = Guid.Parse(authResult.Principal.FindFirst(AbpClaimTypes.UserId).Value.ToString()); |
| 39 | - var access_Token = await _accountManager.GetTokenByUserIdAsync(userId); | ||
| 40 | - var refresh_Token = _accountManager.CreateRefreshToken(userId); | ||
| 41 | - context.Response.Headers["access_token"] = access_Token; | ||
| 42 | - context.Response.Headers["refresh_token"] = refresh_Token; | ||
| 43 | - | 43 | + var tenantId = TryResolveTenantIdFromRequest(context); |
| 44 | + using (tenantId.HasValue | ||
| 45 | + ? _currentTenant.Change(tenantId.Value) | ||
| 46 | + : _currentTenant.Change(null)) | ||
| 47 | + { | ||
| 48 | + var access_Token = await _accountManager.GetTokenByUserIdAsync(userId); | ||
| 49 | + var refresh_Token = _accountManager.CreateRefreshToken(userId); | ||
| 50 | + context.Response.Headers["access_token"] = access_Token; | ||
| 51 | + context.Response.Headers["refresh_token"] = refresh_Token; | ||
| 52 | + } | ||
| 44 | 53 | ||
| 45 | //请求头替换,补充后续鉴权逻辑 | 54 | //请求头替换,补充后续鉴权逻辑 |
| 46 | - context.Request.Headers["Authorization"] = "Bearer " + access_Token; | 55 | + context.Request.Headers["Authorization"] = "Bearer " + context.Response.Headers["access_token"]; |
| 47 | } | 56 | } |
| 48 | } | 57 | } |
| 49 | await next(context); | 58 | await next(context); |
| 50 | } | 59 | } |
| 60 | + | ||
| 61 | + /// <summary> | ||
| 62 | + /// 刷新 access token 时保留业务租户上下文(__tenant 或旧 JWT 中的 TenantId)。 | ||
| 63 | + /// </summary> | ||
| 64 | + private static Guid? TryResolveTenantIdFromRequest(HttpContext context) | ||
| 65 | + { | ||
| 66 | + if (context.Request.Headers.TryGetValue("__tenant", out var headerVal)) | ||
| 67 | + { | ||
| 68 | + var headerText = headerVal.ToString(); | ||
| 69 | + if (Guid.TryParse(headerText, out var fromHeader) && fromHeader != Guid.Empty) | ||
| 70 | + { | ||
| 71 | + return fromHeader; | ||
| 72 | + } | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + var authorization = context.Request.Headers.Authorization.ToString(); | ||
| 76 | + if (string.IsNullOrWhiteSpace(authorization)) | ||
| 77 | + { | ||
| 78 | + return null; | ||
| 79 | + } | ||
| 80 | + | ||
| 81 | + const string bearerPrefix = "Bearer "; | ||
| 82 | + if (!authorization.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase)) | ||
| 83 | + { | ||
| 84 | + return null; | ||
| 85 | + } | ||
| 86 | + | ||
| 87 | + var jwt = authorization[bearerPrefix.Length..].Trim(); | ||
| 88 | + if (string.IsNullOrWhiteSpace(jwt)) | ||
| 89 | + { | ||
| 90 | + return null; | ||
| 91 | + } | ||
| 92 | + | ||
| 93 | + try | ||
| 94 | + { | ||
| 95 | + var token = new JwtSecurityTokenHandler().ReadJwtToken(jwt); | ||
| 96 | + var tenantClaim = token.Claims.FirstOrDefault(c => | ||
| 97 | + c.Type == TokenTypeConst.TenantId | ||
| 98 | + || c.Type == AbpClaimTypes.TenantId) | ||
| 99 | + ?.Value; | ||
| 100 | + if (Guid.TryParse(tenantClaim, out var tenantId) && tenantId != Guid.Empty) | ||
| 101 | + { | ||
| 102 | + return tenantId; | ||
| 103 | + } | ||
| 104 | + } | ||
| 105 | + catch (Exception) | ||
| 106 | + { | ||
| 107 | + return null; | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + return null; | ||
| 111 | + } | ||
| 51 | } | 112 | } |
| 52 | 113 | ||
| 53 | 114 |
泰额版/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'; | @@ -14,6 +14,7 @@ import { $t } from '@vben/locales'; | ||
| 14 | import { Empty, Select, Tag, message } from 'ant-design-vue'; | 14 | import { Empty, Select, Tag, message } from 'ant-design-vue'; |
| 15 | 15 | ||
| 16 | import { | 16 | import { |
| 17 | + thCompanyMenus, | ||
| 17 | thCompanyRoles, | 18 | thCompanyRoles, |
| 18 | thMenuPermissionTree, | 19 | thMenuPermissionTree, |
| 19 | thUpdateCompanyRoleMenus, | 20 | thUpdateCompanyRoleMenus, |
| @@ -26,6 +27,7 @@ const roles = ref<ThCompanyRoleItemDto[]>([]); | @@ -26,6 +27,7 @@ const roles = ref<ThCompanyRoleItemDto[]>([]); | ||
| 26 | const selectedRoleId = ref<string>(); | 27 | const selectedRoleId = ref<string>(); |
| 27 | const menuKeys = ref<string[]>([]); | 28 | const menuKeys = ref<string[]>([]); |
| 28 | const menuTree = ref<ThSaasMenuPermissionTreeNodeDto[]>([]); | 29 | const menuTree = ref<ThSaasMenuPermissionTreeNodeDto[]>([]); |
| 30 | +const companyMenuKeys = ref<string[]>([]); | ||
| 29 | 31 | ||
| 30 | const roleOptions = computed(() => | 32 | const roleOptions = computed(() => |
| 31 | roles.value.map((role) => ({ | 33 | roles.value.map((role) => ({ |
| @@ -56,11 +58,13 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ | @@ -56,11 +58,13 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ | ||
| 56 | 58 | ||
| 57 | drawerApi.drawerLoading(true); | 59 | drawerApi.drawerLoading(true); |
| 58 | try { | 60 | try { |
| 59 | - const [tree, roleList] = await Promise.all([ | 61 | + const [tree, menus, roleList] = await Promise.all([ |
| 60 | thMenuPermissionTree(), | 62 | thMenuPermissionTree(), |
| 63 | + thCompanyMenus(found.id), | ||
| 61 | thCompanyRoles(found.id), | 64 | thCompanyRoles(found.id), |
| 62 | ]); | 65 | ]); |
| 63 | menuTree.value = tree; | 66 | menuTree.value = tree; |
| 67 | + companyMenuKeys.value = menus.menuPermissionKeys ?? []; | ||
| 64 | roles.value = roleList; | 68 | roles.value = roleList; |
| 65 | selectedRoleId.value = roleList[0]?.id; | 69 | selectedRoleId.value = roleList[0]?.id; |
| 66 | syncSelectedRoleMenus(); | 70 | syncSelectedRoleMenus(); |
| @@ -76,6 +80,7 @@ function resetState() { | @@ -76,6 +80,7 @@ function resetState() { | ||
| 76 | selectedRoleId.value = undefined; | 80 | selectedRoleId.value = undefined; |
| 77 | menuKeys.value = []; | 81 | menuKeys.value = []; |
| 78 | menuTree.value = []; | 82 | menuTree.value = []; |
| 83 | + companyMenuKeys.value = []; | ||
| 79 | } | 84 | } |
| 80 | 85 | ||
| 81 | function syncSelectedRoleMenus() { | 86 | function syncSelectedRoleMenus() { |
| @@ -122,6 +127,7 @@ async function handleSave() { | @@ -122,6 +127,7 @@ async function handleSave() { | ||
| 122 | <MenuPermissionTree | 127 | <MenuPermissionTree |
| 123 | v-if="selectedRole" | 128 | v-if="selectedRole" |
| 124 | v-model="menuKeys" | 129 | v-model="menuKeys" |
| 130 | + :allowed-keys="companyMenuKeys" | ||
| 125 | :height="430" | 131 | :height="430" |
| 126 | :nodes="menuTree" | 132 | :nodes="menuTree" |
| 127 | /> | 133 | /> |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue
| 1 | <script lang="ts" setup> | 1 | <script lang="ts" setup> |
| 2 | +import type { ThSaasMenuPermissionTreeNodeDto } from '#/api/th'; | ||
| 3 | + | ||
| 4 | +import { onMounted, ref } from 'vue'; | ||
| 5 | + | ||
| 6 | +import { thMenuPermissionTree } from '#/api/th'; | ||
| 7 | + | ||
| 2 | import MenuPermissionTree from './menu-permission-tree.vue'; | 8 | import MenuPermissionTree from './menu-permission-tree.vue'; |
| 3 | 9 | ||
| 4 | const modelValue = defineModel<string[]>({ default: () => [] }); | 10 | const modelValue = defineModel<string[]>({ default: () => [] }); |
| 5 | 11 | ||
| 6 | -defineProps<{ | 12 | +const props = defineProps<{ |
| 7 | allowedKeys?: string[] | null; | 13 | allowedKeys?: string[] | null; |
| 8 | }>(); | 14 | }>(); |
| 15 | + | ||
| 16 | +const menuTree = ref<ThSaasMenuPermissionTreeNodeDto[]>([]); | ||
| 17 | + | ||
| 18 | +onMounted(async () => { | ||
| 19 | + try { | ||
| 20 | + menuTree.value = await thMenuPermissionTree(); | ||
| 21 | + } catch { | ||
| 22 | + menuTree.value = []; | ||
| 23 | + } | ||
| 24 | +}); | ||
| 9 | </script> | 25 | </script> |
| 10 | 26 | ||
| 11 | <template> | 27 | <template> |
| 12 | - <MenuPermissionTree v-model="modelValue" :allowed-keys="allowedKeys" /> | 28 | + <MenuPermissionTree |
| 29 | + v-model="modelValue" | ||
| 30 | + :allowed-keys="props.allowedKeys" | ||
| 31 | + :nodes="menuTree" | ||
| 32 | + /> | ||
| 13 | </template> | 33 | </template> |
泰额版/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<string[]>({ default: () => [] }); | @@ -39,13 +39,16 @@ const modelValue = defineModel<string[]>({ default: () => [] }); | ||
| 39 | const checkedKeys = computed({ | 39 | const checkedKeys = computed({ |
| 40 | get: () => modelValue.value ?? [], | 40 | get: () => modelValue.value ?? [], |
| 41 | set: (keys: string[]) => { | 41 | set: (keys: string[]) => { |
| 42 | - modelValue.value = normalizeMenuKeysWithParents(keys); | 42 | + modelValue.value = normalizeAgainstTree(keys); |
| 43 | }, | 43 | }, |
| 44 | }); | 44 | }); |
| 45 | 45 | ||
| 46 | const treeData = computed(() => { | 46 | const treeData = computed(() => { |
| 47 | if (props.nodes.length > 0) { | 47 | if (props.nodes.length > 0) { |
| 48 | - return mapBackendNodes(props.nodes); | 48 | + const filtered = props.allowedKeys?.length |
| 49 | + ? filterBackendNodes(props.nodes, props.allowedKeys) | ||
| 50 | + : props.nodes; | ||
| 51 | + return mapBackendNodes(filtered); | ||
| 49 | } | 52 | } |
| 50 | 53 | ||
| 51 | const nodes = props.allowedKeys?.length | 54 | const nodes = props.allowedKeys?.length |
| @@ -54,6 +57,54 @@ const treeData = computed(() => { | @@ -54,6 +57,54 @@ const treeData = computed(() => { | ||
| 54 | return mapNodes(nodes); | 57 | return mapNodes(nodes); |
| 55 | }); | 58 | }); |
| 56 | 59 | ||
| 60 | +function normalizeAgainstTree(keys: string[]) { | ||
| 61 | + if (props.nodes.length > 0) { | ||
| 62 | + return normalizeBackendKeysWithParents(keys, props.nodes); | ||
| 63 | + } | ||
| 64 | + return normalizeMenuKeysWithParents(keys); | ||
| 65 | +} | ||
| 66 | + | ||
| 67 | +function normalizeBackendKeysWithParents( | ||
| 68 | + selected: string[], | ||
| 69 | + nodes: PermissionTreeNode[], | ||
| 70 | +): string[] { | ||
| 71 | + const set = new Set(selected); | ||
| 72 | + function walk(list: PermissionTreeNode[]) { | ||
| 73 | + for (const node of list) { | ||
| 74 | + const children = node.children ?? []; | ||
| 75 | + if (children.length) { | ||
| 76 | + walk(children); | ||
| 77 | + if (children.some((c) => set.has(c.key))) { | ||
| 78 | + set.add(node.key); | ||
| 79 | + } | ||
| 80 | + } | ||
| 81 | + } | ||
| 82 | + } | ||
| 83 | + walk(nodes); | ||
| 84 | + return [...set]; | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +function filterBackendNodes( | ||
| 88 | + nodes: PermissionTreeNode[], | ||
| 89 | + allowedKeys: string[], | ||
| 90 | +): PermissionTreeNode[] { | ||
| 91 | + const allowed = new Set(allowedKeys); | ||
| 92 | + function filter(list: PermissionTreeNode[]): PermissionTreeNode[] { | ||
| 93 | + return list | ||
| 94 | + .map((node) => { | ||
| 95 | + const children = node.children ? filter(node.children) : undefined; | ||
| 96 | + const selfOk = allowed.has(node.key); | ||
| 97 | + const childOk = (children?.length ?? 0) > 0; | ||
| 98 | + if (!selfOk && !childOk) { | ||
| 99 | + return null; | ||
| 100 | + } | ||
| 101 | + return { ...node, children }; | ||
| 102 | + }) | ||
| 103 | + .filter(Boolean) as PermissionTreeNode[]; | ||
| 104 | + } | ||
| 105 | + return filter(nodes); | ||
| 106 | +} | ||
| 107 | + | ||
| 57 | function mapNodes(nodes: SaasMenuTreeNode[]) { | 108 | function mapNodes(nodes: SaasMenuTreeNode[]) { |
| 58 | return nodes.map((node) => ({ | 109 | return nodes.map((node) => ({ |
| 59 | children: node.children?.length ? mapNodes(node.children) : undefined, | 110 | children: node.children?.length ? mapNodes(node.children) : undefined, |
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs
项目相关文档/美国版服务器采购配置清单.md
0 → 100644
| 1 | +# 美国版服务器采购配置清单 | ||
| 2 | + | ||
| 3 | +> 适用范围:美国版 Web 管理端 + UniApp 员工端 | ||
| 4 | +> 技术栈:.NET 8 API + MySQL + React 静态站 | ||
| 5 | +> 原则:独立环境,不与泰额版共用资源 | ||
| 6 | + | ||
| 7 | +--- | ||
| 8 | + | ||
| 9 | +## 1. 环境定位 | ||
| 10 | + | ||
| 11 | +| 项 | 说明 | | ||
| 12 | +|---|---| | ||
| 13 | +| 用户区域 | 美国 | | ||
| 14 | +| 后端 | `Yi.Abp.Web`(.NET 8 + SqlSugar) | | ||
| 15 | +| 前端 | React 18 + Vite 静态托管 | | ||
| 16 | +| 移动端 | UniApp(调用同一套 API) | | ||
| 17 | +| 文件存储现状 | 本地磁盘(图片、批量导入模板) | | ||
| 18 | +| Redis | 配置存在,当前默认关闭 | | ||
| 19 | + | ||
| 20 | +--- | ||
| 21 | + | ||
| 22 | +## 2. 必买清单 | ||
| 23 | + | ||
| 24 | +| 序号 | 项 | 建议规格 | 备注 | | ||
| 25 | +|---|---|---|---| | ||
| 26 | +| 1 | 云厂商 + 地域 | AWS / Azure / GCP,**美东或美西** | 就近访问、合规 | | ||
| 27 | +| 2 | 应用服务器 | **4 核 8G**,系统盘 **40~80G SSD** ×1(建议预留扩到 2 台) | 运行 API | | ||
| 28 | +| 3 | MySQL(RDS) | **2~4 核 / 4~8G 内存**,存储 **50~100G SSD** | 开启自动备份 7~30 天 | | ||
| 29 | +| 4 | 负载均衡 / 公网 | SLB 或云 LB;带宽 **5~20Mbps** 或按量 | API 对外入口 | | ||
| 30 | +| 5 | 域名 + SSL | 1 个主域名;建议 `api.` / `admin.` 子域 | HTTPS 必需 | | ||
| 31 | +| 6 | 反向代理 | Nginx(可与 API 同机) | 反代 API + 托管 React 静态文件 | | ||
| 32 | +| 7 | 安全组 / 防火墙 | 仅开放 80/443;SSH 限 IP | 数据库不对公网开放 | | ||
| 33 | +| 8 | 备份 + 监控 | RDS 自动备份;CPU / 内存 / 磁盘 / 5xx 告警 | 云厂商自带即可 | | ||
| 34 | + | ||
| 35 | +--- | ||
| 36 | + | ||
| 37 | +## 3. 建议购买(正式上线) | ||
| 38 | + | ||
| 39 | +| 序号 | 项 | 建议规格 | 备注 | | ||
| 40 | +|---|---|---|---| | ||
| 41 | +| 9 | 对象存储 S3 / OSS | **50~100G** + 按量流出 | 图片、导入文件;后期多实例必备 | | ||
| 42 | +| 10 | CDN(可选) | 绑定静态站 / 图片桶 | App 拉图更快 | | ||
| 43 | + | ||
| 44 | +--- | ||
| 45 | + | ||
| 46 | +## 4. 可暂缓 | ||
| 47 | + | ||
| 48 | +| 项 | 说明 | | ||
| 49 | +|---|---| | ||
| 50 | +| Redis | 当前关闭;验证码 / 多实例后再上,**1G** 即可 | | ||
| 51 | +| 第二台 API | 有滚动发布 / 高可用需求再买 | | ||
| 52 | +| 独立日志 / 消息队列 | 当前架构暂不需要 | | ||
| 53 | +| 独立邮件服务 | 已有 Office365 SMTP 可继续使用 | | ||
| 54 | + | ||
| 55 | +--- | ||
| 56 | + | ||
| 57 | +## 5. OSS 结论 | ||
| 58 | + | ||
| 59 | +| 阶段 | 是否购买 | 说明 | | ||
| 60 | +|---|---|---| | ||
| 61 | +| 内测 / 单机试跑 | 可不买 | 本地盘可用 | | ||
| 62 | +| 正式上线 | **建议购买** | 换机不丢文件,方便横向扩容 | | ||
| 63 | +| 容量建议 | 50~100G | 按租户 / 业务前缀隔离目录 | | ||
| 64 | + | ||
| 65 | +未切 OSS 前,本地目录参考: | ||
| 66 | + | ||
| 67 | +- 图片:`/www/wwwroot/FoodLabelingManagementUs/picture` | ||
| 68 | +- 批量导入模板:`/www/wwwroot/FoodLabelingManagementUs/batchImportOfFiles` | ||
| 69 | + | ||
| 70 | +未上 OSS 时,建议系统盘 **≥ 80G**,避免图片打满磁盘。 | ||
| 71 | + | ||
| 72 | +> 说明:购买 OSS 后需改造上传逻辑(如 `PictureAppService`);可先开桶,代码分迭代切换。 | ||
| 73 | + | ||
| 74 | +--- | ||
| 75 | + | ||
| 76 | +## 6. 软件与部署要求 | ||
| 77 | + | ||
| 78 | +| 项 | 内容 | | ||
| 79 | +|---|---| | ||
| 80 | +| 运行时 | .NET 8 Runtime、Nginx | | ||
| 81 | +| API | `Yi.Abp.Web` | | ||
| 82 | +| 前端 | React Vite 构建产物由 Nginx 托管 | | ||
| 83 | +| 数据库访问 | 仅 VPC 内网,不对公网 | | ||
| 84 | +| 发布方式 | 建议预留第二台机器做滚动发布 | | ||
| 85 | + | ||
| 86 | +--- | ||
| 87 | + | ||
| 88 | +## 7. 一页勾选版 | ||
| 89 | + | ||
| 90 | +``` | ||
| 91 | +美国版生产环境 | ||
| 92 | +□ 美区 VPC / 账号 | ||
| 93 | +□ ECS/VM 4C8G ×1(系统盘 80G) | ||
| 94 | +□ MySQL RDS 2~4C / 4~8G / 50~100G + 自动备份 | ||
| 95 | +□ SLB + 公网带宽 | ||
| 96 | +□ 域名 + SSL 证书 | ||
| 97 | +□ Nginx(API 反代 + Web 静态) | ||
| 98 | +□ 安全组:80/443;RDS 仅内网 | ||
| 99 | +□ 监控告警 | ||
| 100 | +□ S3/OSS 50~100G(正式建议勾选) | ||
| 101 | +□(可选)Redis 1G | ||
| 102 | +□(可选)CDN | ||
| 103 | +□(可选)第二台 4C8G(高可用) | ||
| 104 | +``` | ||
| 105 | + | ||
| 106 | +--- | ||
| 107 | + | ||
| 108 | +## 8. 预算量级(仅供参考) | ||
| 109 | + | ||
| 110 | +| 场景 | 粗算月费 | | ||
| 111 | +|---|---| | ||
| 112 | +| 起步(1 台 API + RDS + 小容量 S3) | 约 **$80~200 / 月** | | ||
| 113 | +| 扩容方向 | 优先加 RDS 规格,其次加第二台 API;OSS 按量扩容 | | ||
| 114 | + | ||
| 115 | +--- | ||
| 116 | + | ||
| 117 | +## 9. 采购顺序建议 | ||
| 118 | + | ||
| 119 | +1. 确定云厂商与美区地域 | ||
| 120 | +2. 购买 RDS + 应用机 + 域名证书 | ||
| 121 | +3. 同步开通 S3/OSS 桶(即使第一期仍写本地) | ||
| 122 | +4. 跑通发布后再评估 Redis、第二台 API、CDN |