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 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 &#39;@vben/locales&#39;;
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&lt;ThCompanyRoleItemDto[]&gt;([]);
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&lt;string[]&gt;({ default: () =&gt; [] });
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(() =&gt; {
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
1   -using FoodLabeling.Application.Services.DbModels;
  1 +using FoodLabeling.Application.Services.DbModels;
2 2 using SqlSugar;
3 3  
4 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
... ...