Commit 3fc119e5821e6e02e8cdbe380e41cfb52587ae43

Authored by 李曜臣
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 &#39;@vben/locales&#39;; @@ -14,6 +14,7 @@ import { $t } from &#39;@vben/locales&#39;;
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&lt;ThCompanyRoleItemDto[]&gt;([]); @@ -26,6 +27,7 @@ const roles = ref&lt;ThCompanyRoleItemDto[]&gt;([]);
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&lt;string[]&gt;({ default: () =&gt; [] }); @@ -39,13 +39,16 @@ const modelValue = defineModel&lt;string[]&gt;({ default: () =&gt; [] });
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(() =&gt; { @@ -54,6 +57,54 @@ const treeData = computed(() =&gt; {
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
1 -using FoodLabeling.Application.Services.DbModels; 1 +using FoodLabeling.Application.Services.DbModels;
2 using SqlSugar; 2 using SqlSugar;
3 3
4 namespace FoodLabeling.Application.Helpers; 4 namespace FoodLabeling.Application.Helpers;
项目相关文档/美国版服务器采购配置清单.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