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 | 21 | { |
| 22 | 22 | #region Properties |
| 23 | 23 | |
| 24 | + private ISqlSugarClient? _sqlSugarClient; | |
| 25 | + private readonly object _clientLock = new(); | |
| 26 | + | |
| 24 | 27 | /// <summary> |
| 25 | - /// SqlSugar客户端实例 | |
| 28 | + /// SqlSugar 客户端(延迟按当前租户解析连接串,避免构造时 CurrentTenant 尚未就绪落到 host 库) | |
| 26 | 29 | /// </summary> |
| 27 | - public ISqlSugarClient SqlSugarClient { get; private set; } | |
| 30 | + public ISqlSugarClient SqlSugarClient => GetOrCreateClient(); | |
| 28 | 31 | |
| 29 | 32 | /// <summary> |
| 30 | 33 | /// 延迟服务提供者 |
| ... | ... | @@ -75,22 +78,36 @@ namespace Yi.Framework.SqlSugarCore |
| 75 | 78 | public SqlSugarDbContextFactory(IAbpLazyServiceProvider lazyServiceProvider) |
| 76 | 79 | { |
| 77 | 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 | 113 | /// <summary> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TenantContextGuard.cs
| 1 | 1 | using Volo.Abp; |
| 2 | 2 | using Volo.Abp.MultiTenancy; |
| 3 | +using Yi.Framework.SqlSugarCore.Abstractions; | |
| 3 | 4 | |
| 4 | 5 | namespace FoodLabeling.Application.Helpers; |
| 5 | 6 | |
| ... | ... | @@ -8,9 +9,15 @@ namespace FoodLabeling.Application.Helpers; |
| 8 | 9 | /// </summary> |
| 9 | 10 | public static class TenantContextGuard |
| 10 | 11 | { |
| 12 | + /// <summary> | |
| 13 | + /// 平台主库登录(JWT/__tenant 均无业务租户)时访问 fl_* 等业务表的友好提示。 | |
| 14 | + /// </summary> | |
| 15 | + public const string PlatformCannotAccessBusinessDataMessage = | |
| 16 | + "当前为平台主库登录,无法访问公司业务数据(标签/产品/成员等)。请选择具体公司登录,或使用平台「公司管理」相关接口。"; | |
| 17 | + | |
| 11 | 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 | 22 | return; |
| 16 | 23 | } |
| ... | ... | @@ -19,6 +26,78 @@ public static class TenantContextGuard |
| 19 | 26 | ? "未识别租户上下文" |
| 20 | 27 | : $"{operation}:未识别租户上下文"; |
| 21 | 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 | 5 | using Microsoft.AspNetCore.Http; |
| 2 | 6 | using Volo.Abp.MultiTenancy; |
| 3 | 7 | using Volo.Abp.Security.Claims; |
| ... | ... | @@ -16,20 +20,75 @@ public class JwtClaimTenantResolveContributor : TenantResolveContributorBase |
| 16 | 20 | |
| 17 | 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 | 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 | 38 | context.TenantIdOrName = tenantClaim; |
| 31 | 39 | } |
| 32 | 40 | |
| 33 | 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 | 4 | using FoodLabeling.Application.Contracts.IServices; |
| 5 | 5 | using FoodLabeling.Application.Services.DbModels; |
| 6 | 6 | using Microsoft.AspNetCore.Mvc; |
| 7 | +using Microsoft.Extensions.Options; | |
| 7 | 8 | using QuestPDF.Fluent; |
| 8 | 9 | using QuestPDF.Helpers; |
| 9 | 10 | using QuestPDF.Infrastructure; |
| ... | ... | @@ -25,16 +26,22 @@ public class GroupAppService : ApplicationService, IGroupAppService |
| 25 | 26 | |
| 26 | 27 | private readonly ISqlSugarDbContext _dbContext; |
| 27 | 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 | 36 | _dbContext = dbContext; |
| 32 | 37 | _guidGenerator = guidGenerator; |
| 38 | + _dbConnOptions = dbConnOptions.Value; | |
| 33 | 39 | } |
| 34 | 40 | |
| 35 | 41 | /// <inheritdoc /> |
| 36 | 42 | public async Task<PagedResultWithPageDto<GroupGetListOutputDto>> GetListAsync(GroupGetListInputVo input) |
| 37 | 43 | { |
| 44 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Region"); | |
| 38 | 45 | RefAsync<int> total = 0; |
| 39 | 46 | var query = await BuildGroupJoinedQueryAsync(input); |
| 40 | 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 | using FoodLabeling.Application.Contracts.IServices; |
| 7 | 7 | using FoodLabeling.Application.Services.DbModels; |
| 8 | 8 | using FoodLabeling.Domain.Entities; |
| 9 | +using Microsoft.Extensions.Options; | |
| 9 | 10 | using SqlSugar; |
| 10 | 11 | using Volo.Abp; |
| 11 | 12 | using Volo.Abp.Application.Services; |
| ... | ... | @@ -22,15 +23,21 @@ public class LabelAppService : ApplicationService, ILabelAppService |
| 22 | 23 | { |
| 23 | 24 | private readonly ISqlSugarDbContext _dbContext; |
| 24 | 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 | 33 | _dbContext = dbContext; |
| 29 | 34 | _guidGenerator = guidGenerator; |
| 35 | + _dbConnOptions = dbConnOptions.Value; | |
| 30 | 36 | } |
| 31 | 37 | |
| 32 | 38 | public async Task<PagedResultWithPageDto<LabelGetListOutputDto>> GetListAsync(LabelGetListInputVo input) |
| 33 | 39 | { |
| 40 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Label"); | |
| 34 | 41 | RefAsync<int> total = 0; |
| 35 | 42 | |
| 36 | 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 | 24 | private readonly ISqlSugarRepository<LocationAggregateRoot, Guid> _locationRepository; |
| 25 | 25 | private readonly ISqlSugarDbContext _dbContext; |
| 26 | 26 | private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions; |
| 27 | + private readonly DbConnOptions _dbConnOptions; | |
| 27 | 28 | |
| 28 | 29 | public LocationAppService( |
| 29 | 30 | ISqlSugarRepository<LocationAggregateRoot, Guid> locationRepository, |
| 30 | 31 | ISqlSugarDbContext dbContext, |
| 31 | - IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions) | |
| 32 | + IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions, | |
| 33 | + IOptions<DbConnOptions> dbConnOptions) | |
| 32 | 34 | { |
| 33 | 35 | _locationRepository = locationRepository; |
| 34 | 36 | _dbContext = dbContext; |
| 35 | 37 | _batchImportOptions = batchImportOptions; |
| 38 | + _dbConnOptions = dbConnOptions.Value; | |
| 36 | 39 | } |
| 37 | 40 | |
| 38 | 41 | /// <inheritdoc /> |
| 39 | 42 | public async Task<PagedResultWithPageDto<LocationGetListOutputDto>> GetListAsync([FromQuery] LocationGetListInputVo input) |
| 40 | 43 | { |
| 44 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Location"); | |
| 41 | 45 | RefAsync<int> total = 0; |
| 42 | 46 | |
| 43 | 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 | 4 | using FoodLabeling.Application.Contracts.IServices; |
| 5 | 5 | using FoodLabeling.Application.Services.DbModels; |
| 6 | 6 | using Microsoft.AspNetCore.Mvc; |
| 7 | +using Microsoft.Extensions.Options; | |
| 7 | 8 | using QuestPDF.Fluent; |
| 8 | 9 | using QuestPDF.Helpers; |
| 9 | 10 | using QuestPDF.Infrastructure; |
| ... | ... | @@ -25,16 +26,22 @@ public class PartnerAppService : ApplicationService, IPartnerAppService |
| 25 | 26 | |
| 26 | 27 | private readonly ISqlSugarDbContext _dbContext; |
| 27 | 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 | 36 | _dbContext = dbContext; |
| 32 | 37 | _guidGenerator = guidGenerator; |
| 38 | + _dbConnOptions = dbConnOptions.Value; | |
| 33 | 39 | } |
| 34 | 40 | |
| 35 | 41 | /// <inheritdoc /> |
| 36 | 42 | public async Task<PagedResultWithPageDto<PartnerGetListOutputDto>> GetListAsync(PartnerGetListInputVo input) |
| 37 | 43 | { |
| 44 | + EnsureBusinessTenantContext("查询 Company"); | |
| 38 | 45 | RefAsync<int> total = 0; |
| 39 | 46 | var query = await BuildPartnerListQueryAsync(input); |
| 40 | 47 | |
| ... | ... | @@ -46,6 +53,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService |
| 46 | 53 | /// <inheritdoc /> |
| 47 | 54 | public async Task<PartnerGetOutputDto> GetAsync(Guid id) |
| 48 | 55 | { |
| 56 | + EnsureBusinessTenantContext("查询 Company"); | |
| 49 | 57 | if (id == Guid.Empty) |
| 50 | 58 | { |
| 51 | 59 | throw new UserFriendlyException("Partner id is required."); |
| ... | ... | @@ -66,6 +74,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService |
| 66 | 74 | [UnitOfWork] |
| 67 | 75 | public async Task<PartnerGetOutputDto> CreateAsync(PartnerCreateInputVo input) |
| 68 | 76 | { |
| 77 | + EnsureBusinessTenantContext("创建 Company"); | |
| 69 | 78 | var name = input.PartnerName?.Trim(); |
| 70 | 79 | if (string.IsNullOrWhiteSpace(name)) |
| 71 | 80 | { |
| ... | ... | @@ -102,6 +111,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService |
| 102 | 111 | [UnitOfWork] |
| 103 | 112 | public async Task<PartnerGetOutputDto> UpdateAsync(Guid id, PartnerUpdateInputVo input) |
| 104 | 113 | { |
| 114 | + EnsureBusinessTenantContext("更新 Company"); | |
| 105 | 115 | if (id == Guid.Empty) |
| 106 | 116 | { |
| 107 | 117 | throw new UserFriendlyException("Partner id is required."); |
| ... | ... | @@ -143,6 +153,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService |
| 143 | 153 | [UnitOfWork] |
| 144 | 154 | public async Task DeleteAsync(Guid id) |
| 145 | 155 | { |
| 156 | + EnsureBusinessTenantContext("删除 Company"); | |
| 146 | 157 | if (id == Guid.Empty) |
| 147 | 158 | { |
| 148 | 159 | throw new UserFriendlyException("Partner id is required."); |
| ... | ... | @@ -166,6 +177,7 @@ public class PartnerAppService : ApplicationService, IPartnerAppService |
| 166 | 177 | [HttpGet] |
| 167 | 178 | public async Task<IActionResult> ExportPdfAsync([FromQuery] PartnerGetListInputVo input) |
| 168 | 179 | { |
| 180 | + EnsureBusinessTenantContext("导出 Company"); | |
| 169 | 181 | QuestPDF.Settings.License = LicenseType.Community; |
| 170 | 182 | |
| 171 | 183 | var count = await (await BuildPartnerListQueryAsync(input)).CountAsync(); |
| ... | ... | @@ -340,6 +352,11 @@ public class PartnerAppService : ApplicationService, IPartnerAppService |
| 340 | 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 | 360 | private static string? TrimToNull(string? value) |
| 344 | 361 | { |
| 345 | 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 | 34 | private readonly ISqlSugarDbContext _dbContext; |
| 35 | 35 | private readonly IGuidGenerator _guidGenerator; |
| 36 | 36 | private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions; |
| 37 | + private readonly DbConnOptions _dbConnOptions; | |
| 37 | 38 | |
| 38 | 39 | public TeamMemberAppService( |
| 39 | 40 | ISqlSugarRepository<UserAggregateRoot, Guid> userRepository, |
| 40 | 41 | UserManager userManager, |
| 41 | 42 | ISqlSugarDbContext dbContext, |
| 42 | 43 | IGuidGenerator guidGenerator, |
| 43 | - IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions) | |
| 44 | + IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions, | |
| 45 | + IOptions<DbConnOptions> dbConnOptions) | |
| 44 | 46 | { |
| 45 | 47 | _userRepository = userRepository; |
| 46 | 48 | _userManager = userManager; |
| 47 | 49 | _dbContext = dbContext; |
| 48 | 50 | _guidGenerator = guidGenerator; |
| 49 | 51 | _batchImportOptions = batchImportOptions; |
| 52 | + _dbConnOptions = dbConnOptions.Value; | |
| 50 | 53 | } |
| 51 | 54 | |
| 52 | 55 | /// <inheritdoc /> |
| 53 | 56 | public async Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input) |
| 54 | 57 | { |
| 58 | + TenantContextGuard.EnsureBusinessTenantIfSaas(CurrentTenant, _dbConnOptions, "查询 Team Member"); | |
| 55 | 59 | var pageIndex = PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount); |
| 56 | 60 | var pageSize = input.MaxResultCount; |
| 57 | 61 | RefAsync<int> total = 0; | ... | ... |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThSaasMenuPermissionTreeNodeDto.cs
| 1 | 1 | namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; |
| 2 | 2 | |
| 3 | 3 | /// <summary> |
| 4 | -/// SaaS 菜单权限树节点 | |
| 4 | +/// SaaS / 平台分配菜单树节点 | |
| 5 | 5 | /// </summary> |
| 6 | 6 | public class ThSaasMenuPermissionTreeNodeDto |
| 7 | 7 | { |
| 8 | - /// <summary>权限 Key(如 labeling:labels)</summary> | |
| 8 | + /// <summary>菜单 Id(与主库/租户库 Menu.Id 一致)</summary> | |
| 9 | 9 | public string Key { get; set; } = string.Empty; |
| 10 | 10 | |
| 11 | 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 | 1 | using FoodLabeling.Application; |
| 2 | 2 | using FoodLabeling.Th.Application.Contracts; |
| 3 | 3 | using FoodLabeling.Th.Application.Contracts.Options; |
| 4 | +using FoodLabeling.Th.Application.Filters; | |
| 4 | 5 | using FoodLabeling.Th.Domain; |
| 6 | +using Microsoft.AspNetCore.Mvc; | |
| 5 | 7 | using Microsoft.Extensions.Configuration; |
| 6 | 8 | using Microsoft.Extensions.DependencyInjection; |
| 7 | 9 | using Yi.Framework.Ddd.Application; |
| ... | ... | @@ -31,5 +33,10 @@ public class FoodLabelingThApplicationModule : AbpModule |
| 31 | 33 | |
| 32 | 34 | Configure<FoodLabelingThTenantSelectCryptoOptions>( |
| 33 | 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 | 1 | using FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy; |
| 2 | +using Yi.Framework.Rbac.Domain.Entities; | |
| 2 | 3 | |
| 3 | 4 | namespace FoodLabeling.Th.Application.MultiTenancy; |
| 4 | 5 | |
| 5 | 6 | /// <summary> |
| 6 | -/// 泰额版 SaaS 菜单权限目录(与前端 saas-menu-tree 一致) | |
| 7 | +/// 平台可分配给公司的菜单:以主库 Menu 为准;并兼容历史 SaaS Key → 菜单 Id 映射。 | |
| 7 | 8 | /// </summary> |
| 8 | 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 | 11 | /// <summary> |
| 17 | - /// 菜单权限树 | |
| 12 | + /// 历史静态 SaaS Key → 固定菜单 Guid(th-tenant-menu-seed) | |
| 18 | 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 | 44 | /// <summary> |
| 22 | - /// 全部合法 permission key(含父节点) | |
| 45 | + /// 是否为仅平台端菜单(不可分配给公司) | |
| 23 | 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 | 64 | /// <summary> |
| 27 | - /// 校验 key 是否合法;返回非法 key 列表 | |
| 65 | + /// 将历史 SaaS Key / PermissionCode / 菜单 Id 统一为菜单 Id 字符串 | |
| 28 | 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 | 71 | if (keys == null) |
| 32 | 72 | { |
| 33 | 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 | 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 | 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 | 14 | /// <summary>平台登录成功时 th-web-auth 返回的 tenantName(无 TenantId Claim)</summary> |
| 15 | 15 | public const string PlatformTenantDisplayName = "Platform"; |
| 16 | 16 | |
| 17 | - /// <summary>平台账号误选业务租户时的登录拒绝提示</summary> | |
| 17 | + /// <summary>平台邮箱账号误选业务租户时的登录拒绝提示</summary> | |
| 18 | 18 | public const string PlatformAccountMustUseDefaultMessage = |
| 19 | - "登录失败:平台管理员账号请选择 Default 选项登录,不能使用业务租户登录"; | |
| 19 | + "登录失败:平台管理员邮箱请选择 Default 选项登录,不能使用业务租户登录"; | |
| 20 | 20 | |
| 21 | 21 | /// <summary> |
| 22 | 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 | 46 | private readonly ISqlSugarRepository<TenantAggregateRoot, Guid> _tenantRepository; |
| 47 | 47 | private readonly ISqlSugarRepository<ThTenantAdminCredentialEntity, Guid> _credentialRepository; |
| 48 | 48 | private readonly ISqlSugarRepository<ThTenantMenuPermissionEntity, string> _menuPermissionRepository; |
| 49 | + private readonly ISqlSugarRepository<MenuAggregateRoot, Guid> _menuRepository; | |
| 49 | 50 | private readonly TenantSelectCredentialCipher _credentialCipher; |
| 50 | 51 | private readonly RbacOptions _rbacOptions; |
| 51 | 52 | private readonly FoodLabelingThTenantDatabaseOptions _tenantDatabaseOptions; |
| ... | ... | @@ -57,6 +58,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 57 | 58 | ISqlSugarRepository<TenantAggregateRoot, Guid> tenantRepository, |
| 58 | 59 | ISqlSugarRepository<ThTenantAdminCredentialEntity, Guid> credentialRepository, |
| 59 | 60 | ISqlSugarRepository<ThTenantMenuPermissionEntity, string> menuPermissionRepository, |
| 61 | + ISqlSugarRepository<MenuAggregateRoot, Guid> menuRepository, | |
| 60 | 62 | TenantSelectCredentialCipher credentialCipher, |
| 61 | 63 | IOptions<RbacOptions> rbacOptions, |
| 62 | 64 | IOptions<FoodLabelingThTenantDatabaseOptions> tenantDatabaseOptions, |
| ... | ... | @@ -67,6 +69,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 67 | 69 | _tenantRepository = tenantRepository; |
| 68 | 70 | _credentialRepository = credentialRepository; |
| 69 | 71 | _menuPermissionRepository = menuPermissionRepository; |
| 72 | + _menuRepository = menuRepository; | |
| 70 | 73 | _credentialCipher = credentialCipher; |
| 71 | 74 | _rbacOptions = rbacOptions.Value; |
| 72 | 75 | _tenantDatabaseOptions = tenantDatabaseOptions.Value; |
| ... | ... | @@ -333,33 +336,17 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 333 | 336 | } |
| 334 | 337 | |
| 335 | 338 | /// <summary> |
| 336 | - /// 获取 SaaS 菜单权限树 | |
| 339 | + /// 获取可分配给公司的平台菜单树(主库 Menu,排除仅平台端菜单) | |
| 337 | 340 | /// </summary> |
| 338 | 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 | 343 | /// </remarks> |
| 354 | - /// <returns>SaaS 菜单权限树</returns> | |
| 355 | - /// <response code="200">成功返回权限树</response> | |
| 356 | - /// <response code="401">未登录</response> | |
| 357 | - /// <response code="500">服务器错误</response> | |
| 358 | 344 | [Authorize] |
| 359 | 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 | 352 | /// <summary> |
| ... | ... | @@ -441,8 +428,17 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 441 | 428 | |
| 442 | 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 | 442 | if (invalidKeys.Count > 0) |
| 447 | 443 | { |
| 448 | 444 | throw new UserFriendlyException($"存在非法菜单权限 Key:{string.Join(", ", invalidKeys)}"); |
| ... | ... | @@ -454,6 +450,8 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 454 | 450 | |
| 455 | 451 | if (normalizedKeys.Count == 0) |
| 456 | 452 | { |
| 453 | + // 清空公司开通菜单时,同步清空租户管理员角色菜单 | |
| 454 | + await SyncTenantAdminRoleMenusAsync(input.TenantId, Array.Empty<Guid>()); | |
| 457 | 455 | return; |
| 458 | 456 | } |
| 459 | 457 | |
| ... | ... | @@ -468,6 +466,12 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 468 | 466 | |
| 469 | 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 | 477 | /// <summary> |
| ... | ... | @@ -590,6 +594,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 590 | 594 | |
| 591 | 595 | var tenant = await EnsureTenantExistsAsync(input.TenantId); |
| 592 | 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 | 621 | var (connectionString, dbType) = await ResolveTenantBusinessConnectionAsync(tenant); |
| 594 | 622 | using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(connectionString, dbType); |
| 595 | 623 | TenantBusinessDatabaseAccessor.EnsureDatabaseReachable(tenantDb, input.TenantId); |
| ... | ... | @@ -905,14 +933,93 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 905 | 933 | |
| 906 | 934 | private async Task<List<string>> LoadMenuPermissionKeysAsync(Guid tenantId) |
| 907 | 935 | { |
| 936 | + List<string> rawKeys; | |
| 908 | 937 | using (UseHostTenantScope()) |
| 909 | 938 | { |
| 910 | - return await _menuPermissionRepository._DbQueryable | |
| 939 | + rawKeys = await _menuPermissionRepository._DbQueryable | |
| 911 | 940 | .Where(x => x.TenantId == tenantId) |
| 912 | 941 | .OrderBy(x => x.CreationTime) |
| 913 | 942 | .Select(x => x.PermissionKey) |
| 914 | 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 | 1025 | private async Task<Dictionary<Guid, List<string>>> LoadMenuPermissionMapAsync( |
| ... | ... | @@ -923,32 +1030,30 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 923 | 1030 | return new Dictionary<Guid, List<string>>(); |
| 924 | 1031 | } |
| 925 | 1032 | |
| 1033 | + List<ThTenantMenuPermissionEntity> list; | |
| 926 | 1034 | using (UseHostTenantScope()) |
| 927 | 1035 | { |
| 928 | - var list = await _menuPermissionRepository._DbQueryable | |
| 1036 | + list = await _menuPermissionRepository._DbQueryable | |
| 929 | 1037 | .Where(x => tenantIds.Contains(x.TenantId)) |
| 930 | 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 | 1059 | private static List<Guid> ParseMenuIds(IEnumerable<string>? menuIds) |
| ... | ... | @@ -981,17 +1086,6 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe |
| 981 | 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 | 1089 | private static void EnsureTenantDeletable(Guid tenantId, string? tenantName) |
| 996 | 1090 | { |
| 997 | 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 | 49 | /// 校验租户后在租户独立库验证账号,签发含 TenantId 与 RBAC 权限的 JWT。 |
| 50 | 50 | /// |
| 51 | 51 | /// 泰额 H5 与公司 Web 共用此接口。选 **Default** 或 tenantId 为空时,若邮箱账号存在于主库则走**平台登录**(JWT 无 TenantId); |
| 52 | - /// 选具体公司 tenantId 时在该公司业务库校验;若账号已存在于主库(平台管理员),则拒绝登录并提示改用 Default。 | |
| 52 | + /// 选具体公司 tenantId 时在该公司业务库校验;若登录标识为邮箱且已存在于主库(平台管理员邮箱),则拒绝并提示改用 Default。 | |
| 53 | + /// 租户默认账号 UserName=admin 与主库同名时允许业务租户登录。 | |
| 53 | 54 | /// Default 业务库(如 US)仅在主库无该邮箱时作为回落。 |
| 54 | 55 | /// |
| 55 | 56 | /// 示例请求(平台,选 Default): |
| ... | ... | @@ -162,7 +163,8 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService |
| 162 | 163 | } |
| 163 | 164 | |
| 164 | 165 | /// <summary> |
| 165 | - /// 业务租户登录前校验:主库已存在的账号(平台管理员)不得落入公司业务库。 | |
| 166 | + /// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。 | |
| 167 | + /// 仅拦截邮箱形态(与 TryPlatformLoginAsync 一致);租户默认 UserName=admin 与主库 admin 同名时允许走租户库。 | |
| 166 | 168 | /// </summary> |
| 167 | 169 | private async Task EnsureNotHostPlatformAccountAsync(string userName) |
| 168 | 170 | { |
| ... | ... | @@ -171,6 +173,12 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService |
| 171 | 173 | return; |
| 172 | 174 | } |
| 173 | 175 | |
| 176 | + // 租户管理员默认账号常为 admin,与主库平台 UserName 同名;不可按用户名拦截 | |
| 177 | + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(userName)) | |
| 178 | + { | |
| 179 | + return; | |
| 180 | + } | |
| 181 | + | |
| 174 | 182 | using (CurrentTenant.Change(null)) |
| 175 | 183 | { |
| 176 | 184 | var hostUser = await FindActiveUserByEmailAsync(userName.Trim()); | ... | ... |
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Domain/Authorization/RefreshTokenMiddleware.cs
| 1 | 1 | using System; |
| 2 | 2 | using System.Collections.Generic; |
| 3 | 3 | using System.Diagnostics; |
| 4 | +using System.IdentityModel.Tokens.Jwt; | |
| 4 | 5 | using System.Linq; |
| 5 | 6 | using System.Text; |
| 6 | 7 | using System.Threading.Tasks; |
| ... | ... | @@ -9,6 +10,7 @@ using Microsoft.AspNetCore.Authentication; |
| 9 | 10 | using Microsoft.AspNetCore.Builder; |
| 10 | 11 | using Microsoft.AspNetCore.Http; |
| 11 | 12 | using Volo.Abp.DependencyInjection; |
| 13 | +using Volo.Abp.MultiTenancy; | |
| 12 | 14 | using Volo.Abp.Security.Claims; |
| 13 | 15 | using Yi.Framework.Rbac.Domain.Managers; |
| 14 | 16 | using Yi.Framework.Rbac.Domain.Shared.Consts; |
| ... | ... | @@ -18,11 +20,13 @@ namespace Yi.Framework.Rbac.Domain.Authorization |
| 18 | 20 | [DebuggerStepThrough] |
| 19 | 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 | 28 | _accountManager = accountManager; |
| 29 | + _currentTenant = currentTenant; | |
| 26 | 30 | } |
| 27 | 31 | |
| 28 | 32 | public async Task InvokeAsync(HttpContext context, RequestDelegate next) |
| ... | ... | @@ -36,18 +40,75 @@ namespace Yi.Framework.Rbac.Domain.Authorization |
| 36 | 40 | if (authResult.Succeeded) |
| 37 | 41 | { |
| 38 | 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 | 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 | 14 | import { Empty, Select, Tag, message } from 'ant-design-vue'; |
| 15 | 15 | |
| 16 | 16 | import { |
| 17 | + thCompanyMenus, | |
| 17 | 18 | thCompanyRoles, |
| 18 | 19 | thMenuPermissionTree, |
| 19 | 20 | thUpdateCompanyRoleMenus, |
| ... | ... | @@ -26,6 +27,7 @@ const roles = ref<ThCompanyRoleItemDto[]>([]); |
| 26 | 27 | const selectedRoleId = ref<string>(); |
| 27 | 28 | const menuKeys = ref<string[]>([]); |
| 28 | 29 | const menuTree = ref<ThSaasMenuPermissionTreeNodeDto[]>([]); |
| 30 | +const companyMenuKeys = ref<string[]>([]); | |
| 29 | 31 | |
| 30 | 32 | const roleOptions = computed(() => |
| 31 | 33 | roles.value.map((role) => ({ |
| ... | ... | @@ -56,11 +58,13 @@ const [BasicDrawer, drawerApi] = useVbenDrawer({ |
| 56 | 58 | |
| 57 | 59 | drawerApi.drawerLoading(true); |
| 58 | 60 | try { |
| 59 | - const [tree, roleList] = await Promise.all([ | |
| 61 | + const [tree, menus, roleList] = await Promise.all([ | |
| 60 | 62 | thMenuPermissionTree(), |
| 63 | + thCompanyMenus(found.id), | |
| 61 | 64 | thCompanyRoles(found.id), |
| 62 | 65 | ]); |
| 63 | 66 | menuTree.value = tree; |
| 67 | + companyMenuKeys.value = menus.menuPermissionKeys ?? []; | |
| 64 | 68 | roles.value = roleList; |
| 65 | 69 | selectedRoleId.value = roleList[0]?.id; |
| 66 | 70 | syncSelectedRoleMenus(); |
| ... | ... | @@ -76,6 +80,7 @@ function resetState() { |
| 76 | 80 | selectedRoleId.value = undefined; |
| 77 | 81 | menuKeys.value = []; |
| 78 | 82 | menuTree.value = []; |
| 83 | + companyMenuKeys.value = []; | |
| 79 | 84 | } |
| 80 | 85 | |
| 81 | 86 | function syncSelectedRoleMenus() { |
| ... | ... | @@ -122,6 +127,7 @@ async function handleSave() { |
| 122 | 127 | <MenuPermissionTree |
| 123 | 128 | v-if="selectedRole" |
| 124 | 129 | v-model="menuKeys" |
| 130 | + :allowed-keys="companyMenuKeys" | |
| 125 | 131 | :height="430" |
| 126 | 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 | 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 | 8 | import MenuPermissionTree from './menu-permission-tree.vue'; |
| 3 | 9 | |
| 4 | 10 | const modelValue = defineModel<string[]>({ default: () => [] }); |
| 5 | 11 | |
| 6 | -defineProps<{ | |
| 12 | +const props = defineProps<{ | |
| 7 | 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 | 25 | </script> |
| 10 | 26 | |
| 11 | 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 | 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 | 39 | const checkedKeys = computed({ |
| 40 | 40 | get: () => modelValue.value ?? [], |
| 41 | 41 | set: (keys: string[]) => { |
| 42 | - modelValue.value = normalizeMenuKeysWithParents(keys); | |
| 42 | + modelValue.value = normalizeAgainstTree(keys); | |
| 43 | 43 | }, |
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | 46 | const treeData = computed(() => { |
| 47 | 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 | 54 | const nodes = props.allowedKeys?.length |
| ... | ... | @@ -54,6 +57,54 @@ const treeData = computed(() => { |
| 54 | 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 | 108 | function mapNodes(nodes: SaasMenuTreeNode[]) { |
| 58 | 109 | return nodes.map((node) => ({ |
| 59 | 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 | ... | ... |