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