Commit aa01fd36272d7b1baa032b7cb695af1876b63cf7

Authored by 李曜臣
1 parent 79c9b19f

2026-08-07代码优化

Showing 27 changed files with 877 additions and 453 deletions
泰额版/Food Labeling Management Code/Yi.Abp.Net8/framework/Yi.Framework.SqlSugarCore/SqlSugarDbContextFactory.cs
... ... @@ -82,25 +82,51 @@ namespace Yi.Framework.SqlSugarCore
82 82  
83 83 private ISqlSugarClient GetOrCreateClient()
84 84 {
85   - if (_sqlSugarClient is not null)
  85 + // 必须每次按当前租户解析连接串:MultiTenancyMiddleware 查 YiTenant 时会先 Change(null)
  86 + // 创建主库客户端;若缓存不切换,后续业务会误连 host。
  87 + var tenantConfiguration =
  88 + AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync());
  89 + var connectionString = tenantConfiguration.GetCurrentConnectionString();
  90 + var dbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName());
  91 +
  92 + if (_sqlSugarClient is not null &&
  93 + string.Equals(
  94 + _sqlSugarClient.CurrentConnectionConfig?.ConnectionString,
  95 + connectionString,
  96 + StringComparison.Ordinal))
86 97 {
87 98 return _sqlSugarClient;
88 99 }
89 100  
90 101 lock (_clientLock)
91 102 {
92   - if (_sqlSugarClient is not null)
  103 + if (_sqlSugarClient is not null &&
  104 + string.Equals(
  105 + _sqlSugarClient.CurrentConnectionConfig?.ConnectionString,
  106 + connectionString,
  107 + StringComparison.Ordinal))
93 108 {
94 109 return _sqlSugarClient;
95 110 }
96 111  
97   - var tenantConfiguration =
98   - AsyncHelper.RunSync(async () => await TenantConfigurationWrapper.GetAsync());
  112 + if (_sqlSugarClient is not null)
  113 + {
  114 + try
  115 + {
  116 + _sqlSugarClient.Dispose();
  117 + }
  118 + catch
  119 + {
  120 + // ignore dispose race
  121 + }
  122 +
  123 + _sqlSugarClient = null;
  124 + }
99 125  
100 126 var connectionConfig = BuildConnectionConfig(options =>
101 127 {
102   - options.ConnectionString = tenantConfiguration.GetCurrentConnectionString();
103   - options.DbType = GetCurrentDbType(tenantConfiguration.GetCurrentConnectionName());
  128 + options.ConnectionString = connectionString;
  129 + options.DbType = dbType;
104 130 });
105 131  
106 132 var client = new SqlSugarClient(connectionConfig);
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
... ... @@ -37,6 +37,11 @@ public class ProductCreateInputVo
37 37 public string? AvailabilityType { get; set; }
38 38  
39 39 /// <summary>
  40 + /// 适用 Company:ALL / SPECIFIED(产品 Company 仅支持 SPECIFIED + 单选具体 Guid)。
  41 + /// </summary>
  42 + public string? AppliedPartnerType { get; set; }
  43 +
  44 + /// <summary>
40 45 /// 适用 Company(<c>fl_partner.Id</c>,UI 称 Company);展开该公司下全部门店后与 Region/门店合并写入 <c>fl_location_product</c>。
41 46 /// 与 <see cref="CompanyIds"/> 二选一或同值;产品 Company <b>仅支持单选</b>,<b>不支持 ALL</b>。
42 47 /// 兼容前端误传数组:<c>"partnerId":["{guid}"]</c> 与字符串等价。
... ... @@ -45,11 +50,19 @@ public class ProductCreateInputVo
45 50 public string? PartnerId { get; set; }
46 51  
47 52 /// <summary>
  53 + /// 适用 Company Id 列表(与 <see cref="CompanyIds"/> 同义,前端常用字段)。<b>仅支持单选</b>。
  54 + /// </summary>
  55 + public List<string>? PartnerIds { get; set; }
  56 +
  57 + /// <summary>
48 58 /// 适用 Company(推荐前端字段)。<b>仅支持单选</b>:数组最多 1 个具体 Guid;<b>不支持 ALL</b>。
49 59 /// 传多个 Guid 或含 <c>ALL</c> 将报错;与 <see cref="PartnerId"/> 同时传时须一致。
50 60 /// </summary>
51 61 public List<string>? CompanyIds { get; set; }
52 62  
  63 + /// <summary>适用 Region:ALL / SPECIFIED(与 <see cref="AvailabilityType"/> 独立,用于前端显式声明)</summary>
  64 + public string? AppliedRegionType { get; set; }
  65 +
53 66 /// <summary>
54 67 /// 适用 Region(<c>fl_group.Id</c>,UI 称 Region;库字段为 <c>location.GroupName</c>)。
55 68 /// 与 <see cref="RegionIds"/> 合并;含 <c>ALL</c> 且无具体门店时,有具体 Company 则展开该公司门店,否则归档全局 ALL。
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileDto.cs
... ... @@ -19,10 +19,31 @@ public class TrainingFileDto
19 19  
20 20 public string AppliedPartnerType { get; set; } = "ALL";
21 21  
  22 + /// <summary>Company 展示(ALL 时为 All Companies)</summary>
  23 + public string Company { get; set; } = string.Empty;
  24 +
  25 + public List<string> PartnerIds { get; set; } = new();
  26 +
  27 + /// <summary>与 <see cref="PartnerIds"/> 相同</summary>
  28 + public List<string> CompanyIds { get; set; } = new();
  29 +
22 30 public string AppliedRegionType { get; set; } = "ALL";
23 31  
  32 + /// <summary>Region 展示</summary>
  33 + public string Region { get; set; } = string.Empty;
  34 +
  35 + public List<string> RegionIds { get; set; } = new();
  36 +
  37 + /// <summary>与 <see cref="RegionIds"/> 相同</summary>
  38 + public List<string> GroupIds { get; set; } = new();
  39 +
24 40 public string AvailabilityType { get; set; } = "ALL";
25 41  
  42 + /// <summary>Location 展示</summary>
  43 + public string Location { get; set; } = string.Empty;
  44 +
  45 + public List<string> LocationIds { get; set; } = new();
  46 +
26 47 public DateTime CreationTime { get; set; }
27 48  
28 49 public DateTime? LastModificationTime { get; set; }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileScopeInputVo.cs
... ... @@ -2,7 +2,7 @@ using FoodLabeling.Application.Contracts.Dtos.Common;
2 2  
3 3 namespace FoodLabeling.Application.Contracts.Dtos.Training;
4 4  
5   -public class TrainingFileScopeInputVo : ILabelEntityPartnerScopeInput
  5 +public class TrainingFileScopeInputVo : ITrainingFileScopeInput
6 6 {
7 7 public string? AppliedPartnerType { get; set; }
8 8  
... ... @@ -20,5 +20,8 @@ public class TrainingFileScopeInputVo : ILabelEntityPartnerScopeInput
20 20 /// <summary>适用 Location:ALL / SPECIFIED</summary>
21 21 public string? AvailabilityType { get; set; }
22 22  
  23 + /// <summary>适用 Location:ALL / SPECIFIED(<see cref="AvailabilityType"/> 别名)</summary>
  24 + public string? AppliedLocationType { get; set; }
  25 +
23 26 public List<string>? LocationIds { get; set; }
24 27 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUpdateInputVo.cs
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +
1 3 namespace FoodLabeling.Application.Contracts.Dtos.Training;
2 4  
3   -public class TrainingFileUpdateInputVo
  5 +public class TrainingFileUpdateInputVo : ITrainingFileScopeInput
4 6 {
5 7 public string FileName { get; set; } = string.Empty;
6 8  
7 9 public int OrderNum { get; set; }
  10 +
  11 + /// <summary>适用 Company:ALL / SPECIFIED</summary>
  12 + public string? AppliedPartnerType { get; set; }
  13 +
  14 + /// <summary>适用 Company(<c>fl_partner.Id</c>);可含 <c>ALL</c></summary>
  15 + public List<string>? PartnerIds { get; set; }
  16 +
  17 + /// <summary>与 <see cref="PartnerIds"/> 相同;可含 <c>ALL</c></summary>
  18 + public List<string>? CompanyIds { get; set; }
  19 +
  20 + /// <summary>适用 Region:ALL / SPECIFIED</summary>
  21 + public string? AppliedRegionType { get; set; }
  22 +
  23 + /// <summary>适用 Region(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并;可含 <c>ALL</c></summary>
  24 + public List<string>? RegionIds { get; set; }
  25 +
  26 + /// <summary>与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c></summary>
  27 + public List<string>? GroupIds { get; set; }
  28 +
  29 + /// <summary>适用 Location:ALL / SPECIFIED</summary>
  30 + public string? AvailabilityType { get; set; }
  31 +
  32 + /// <summary>适用 Location:ALL / SPECIFIED(<see cref="AvailabilityType"/> 别名)</summary>
  33 + public string? AppliedLocationType { get; set; }
  34 +
  35 + /// <summary>适用门店(<c>location.Id</c>);可含 <c>ALL</c></summary>
  36 + public List<string>? LocationIds { get; set; }
8 37 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Training/TrainingFileUploadInputVo.cs
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
1 2 using Microsoft.AspNetCore.Http;
2 3 using Microsoft.AspNetCore.Mvc;
3 4  
4 5 namespace FoodLabeling.Application.Contracts.Dtos.Training;
5 6  
6   -public class TrainingFileUploadInputVo
  7 +public class TrainingFileUploadInputVo : ITrainingFileScopeInput
7 8 {
8 9 [FromForm(Name = "file")]
9 10 public IFormFile File { get; set; } = default!;
... ... @@ -11,5 +12,42 @@ public class TrainingFileUploadInputVo
11 12 [FromForm(Name = "categoryId")]
12 13 public string CategoryId { get; set; } = string.Empty;
13 14  
  15 + [FromForm(Name = "orderNum")]
14 16 public int OrderNum { get; set; }
  17 +
  18 + /// <summary>适用 Company:ALL / SPECIFIED</summary>
  19 + [FromForm(Name = "appliedPartnerType")]
  20 + public string? AppliedPartnerType { get; set; }
  21 +
  22 + /// <summary>适用 Company(<c>fl_partner.Id</c>);可含 <c>ALL</c></summary>
  23 + [FromForm(Name = "partnerIds")]
  24 + public List<string>? PartnerIds { get; set; }
  25 +
  26 + /// <summary>与 <see cref="PartnerIds"/> 相同;可含 <c>ALL</c></summary>
  27 + [FromForm(Name = "companyIds")]
  28 + public List<string>? CompanyIds { get; set; }
  29 +
  30 + /// <summary>适用 Region:ALL / SPECIFIED</summary>
  31 + [FromForm(Name = "appliedRegionType")]
  32 + public string? AppliedRegionType { get; set; }
  33 +
  34 + /// <summary>适用 Region(<c>fl_group.Id</c>);与 <see cref="GroupIds"/> 合并;可含 <c>ALL</c></summary>
  35 + [FromForm(Name = "regionIds")]
  36 + public List<string>? RegionIds { get; set; }
  37 +
  38 + /// <summary>与 <see cref="RegionIds"/> 相同;可含 <c>ALL</c></summary>
  39 + [FromForm(Name = "groupIds")]
  40 + public List<string>? GroupIds { get; set; }
  41 +
  42 + /// <summary>适用 Location:ALL / SPECIFIED</summary>
  43 + [FromForm(Name = "availabilityType")]
  44 + public string? AvailabilityType { get; set; }
  45 +
  46 + /// <summary>适用 Location:ALL / SPECIFIED(<see cref="AvailabilityType"/> 别名)</summary>
  47 + [FromForm(Name = "appliedLocationType")]
  48 + public string? AppliedLocationType { get; set; }
  49 +
  50 + /// <summary>适用门店(<c>location.Id</c>);可含 <c>ALL</c></summary>
  51 + [FromForm(Name = "locationIds")]
  52 + public List<string>? LocationIds { get; set; }
15 53 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITrainingAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.Training;
  2 +using Volo.Abp.Application.Services;
2 3  
3 4 namespace FoodLabeling.Application.Contracts.IServices;
4 5  
5   -public interface ITrainingAppService
  6 +public interface ITrainingAppService : IApplicationService
6 7 {
7 8 Task<List<TrainingCategoryTreeNodeDto>> GetCategoryTreeAsync(TrainingCategoryTreeInputVo input);
8 9  
... ... @@ -12,7 +13,7 @@ public interface ITrainingAppService
12 13  
13 14 Task DeleteCategoryAsync(string id);
14 15  
15   - Task<TrainingFileUploadOutputDto> UploadFileAsync(TrainingFileUploadInputVo input);
  16 + Task<TrainingFileDto> UploadFileAsync(TrainingFileUploadInputVo input);
16 17  
17 18 Task<TrainingFileDto> UpdateFileAsync(string id, TrainingFileUpdateInputVo input);
18 19  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/UsAppJwtClaims.cs
... ... @@ -10,4 +10,7 @@ public static class UsAppJwtClaims
10 10  
11 11 /// <summary>美国版移动端 App</summary>
12 12 public const string ClientKindUsApp = "us-app";
  13 +
  14 + /// <summary>泰额版 App(us-app-auth 转发 th-app-auth 签发)</summary>
  15 + public const string ClientKindThApp = "th_app";
13 16 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/FoodLabeling.Application.csproj
... ... @@ -27,6 +27,9 @@
27 27 <EmbeddedResource Include="..\scripts\fl_training.sql">
28 28 <LogicalName>FoodLabeling.TenantMigrations.fl_training.sql</LogicalName>
29 29 </EmbeddedResource>
  30 + <EmbeddedResource Include="..\scripts\fl_label_alert_timer.sql">
  31 + <LogicalName>FoodLabeling.TenantMigrations.fl_label_alert_timer.sql</LogicalName>
  32 + </EmbeddedResource>
30 33 </ItemGroup>
31 34  
32 35 <ItemGroup>
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs
... ... @@ -454,6 +454,8 @@ public static class AllScopeBindingHelper
454 454 var locationHasAll = HasAllScopeSentinelSelection(normalizedLocationIds);
455 455 var regionHasAllSentinel = HasAllScopeSentinelSelection(mergedRegionIds);
456 456 var hasConcretePartner = LocationScopeBindingHelper.NormalizeIds(partnerIdsForContext).Count > 0;
  457 + // 用户显式传具体门店 Id 时一律 SPECIFIED,不因「恰好覆盖 Region 全集」归档 ALL
  458 + var userExplicitLocations = explicitLocationIds.Count > 0 && !locationHasAll;
457 459  
458 460 // 前端 Select All Region 常展开为全量 Guid,需识别为 Region=ALL
459 461 var regionHasAll = regionHasAllSentinel;
... ... @@ -467,7 +469,7 @@ public static class AllScopeBindingHelper
467 469  
468 470 // location ALL 哨兵,或具体 Region 下「空选/全选门店」:有 Company/Region 上下文则展开 SPECIFIED
469 471 var locationCoversConcreteRegions = false;
470   - if (!locationHasAll && regionIds.Count > 0 && !regionHasAll)
  472 + if (!locationHasAll && !userExplicitLocations && regionIds.Count > 0 && !regionHasAll)
471 473 {
472 474 if (explicitLocationIds.Count == 0)
473 475 {
... ... @@ -484,7 +486,7 @@ public static class AllScopeBindingHelper
484 486 }
485 487 }
486 488  
487   - if (locationHasAll || locationCoversConcreteRegions)
  489 + if (!userExplicitLocations && (locationHasAll || locationCoversConcreteRegions))
488 490 {
489 491 var expandRegions = regionHasAll ? null : (regionIds.Count > 0 ? regionIds : null);
490 492 var expanded = await LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync(
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs
... ... @@ -67,6 +67,18 @@ public static class ReportsPrintLogExpiryHelper
67 67 public static string ExtractFormattedExpiryText(string? printInputJson) =>
68 68 ExtractFormattedExpiryText(printInputJson, null, null, null);
69 69  
  70 + /// <summary>与 Print Log Expiration 同源:解析绝对过期时刻。</summary>
  71 + public static bool TryResolveExpiryDateTime(
  72 + string? printInputJson,
  73 + string? renderTemplateJson,
  74 + DateTime? baseTime,
  75 + DateTime? printedAt,
  76 + out DateTime expiresAt)
  77 + {
  78 + var reference = baseTime ?? printedAt ?? DateTime.Now;
  79 + return TryResolveExpiryDateTime(printInputJson, renderTemplateJson, reference, out expiresAt);
  80 + }
  81 +
70 82 private static bool TryResolveExpiryDateTime(
71 83 string? printInputJson,
72 84 string? renderTemplateJson,
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsRoleHelper.cs
  1 +using SqlSugar;
1 2 using Volo.Abp.Users;
2 3 using Yi.Framework.Rbac.Domain.Shared.Consts;
3 4  
... ... @@ -66,4 +67,28 @@ public static class ReportsRoleHelper
66 67  
67 68 return false;
68 69 }
  70 +
  71 + /// <summary>
  72 + /// App JWT 未写入角色声明时,按业务库 UserRole/Role 判断是否管理员。
  73 + /// </summary>
  74 + public static async Task<bool> IsAdminRoleFromDbAsync(ISqlSugarClient db, Guid userId)
  75 + {
  76 + var rows = await db.Ado.SqlQueryAsync<AdminRoleCodeRow>(
  77 + """
  78 + SELECT r.RoleCode
  79 + FROM UserRole ur
  80 + INNER JOIN Role r ON ur.RoleId = r.Id
  81 + WHERE ur.UserId = @UserId AND r.IsDeleted = 0 AND r.State = 1
  82 + """,
  83 + new { UserId = userId });
  84 +
  85 + return rows.Any(r =>
  86 + !string.IsNullOrWhiteSpace(r.RoleCode) &&
  87 + string.Equals(r.RoleCode.Trim(), UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase));
  88 + }
  89 +
  90 + private sealed class AdminRoleCodeRow
  91 + {
  92 + public string? RoleCode { get; set; }
  93 + }
69 94 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TrainingFileScopeHelper.cs
... ... @@ -187,6 +187,24 @@ public static class TrainingFileScopeHelper
187 187 }
188 188  
189 189 /// <summary>
  190 + /// 构建多个培训文件的适用范围回显(列表/树批量用)。
  191 + /// </summary>
  192 + public static async Task<Dictionary<string, TrainingFileScopeDisplay>> BuildScopeDisplayMapAsync(
  193 + ISqlSugarClient db,
  194 + IReadOnlyList<FlTrainingFileDbEntity> files)
  195 + {
  196 + if (files.Count == 0)
  197 + {
  198 + return new Dictionary<string, TrainingFileScopeDisplay>(StringComparer.Ordinal);
  199 + }
  200 +
  201 + var tasks = files.Select(async file =>
  202 + (file.Id, Display: await BuildScopeDisplayAsync(db, file)));
  203 + var results = await Task.WhenAll(tasks);
  204 + return results.ToDictionary(x => x.Id, x => x.Display, StringComparer.Ordinal);
  205 + }
  206 +
  207 + /// <summary>
190 208 /// 删除培训文件全部范围关联。
191 209 /// </summary>
192 210 public static async Task DeleteScopeRowsAsync(ISqlSugarClient db, string trainingFileId)
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/UsAppAuthScopeHelper.cs
... ... @@ -27,7 +27,10 @@ public static class UsAppAuthScopeHelper
27 27 }
28 28  
29 29 var kind = currentUser.FindClaim(UsAppJwtClaims.ClientKind)?.Value;
30   - if (!string.Equals(kind, UsAppJwtClaims.ClientKindUsApp, StringComparison.Ordinal))
  30 + // 美国版 us-app;泰额版 us-app-auth 转发 th-app-auth 后签发 th_app
  31 + var isAppToken = string.Equals(kind, UsAppJwtClaims.ClientKindUsApp, StringComparison.Ordinal)
  32 + || string.Equals(kind, UsAppJwtClaims.ClientKindThApp, StringComparison.Ordinal);
  33 + if (!isAppToken)
31 34 {
32 35 throw new UserFriendlyException("请使用 App 登录令牌调用该接口");
33 36 }
... ... @@ -53,7 +56,13 @@ public static class UsAppAuthScopeHelper
53 56 return true;
54 57 }
55 58  
56   - return await TeamMemberRoleHelper.IsCompanyAdminUserAsync(db, currentUser);
  59 + if (await TeamMemberRoleHelper.IsCompanyAdminUserAsync(db, currentUser))
  60 + {
  61 + return true;
  62 + }
  63 +
  64 + // App JWT 可能未写 role claim:回退查库 RoleCode=admin
  65 + return await ReportsRoleHelper.IsAdminRoleFromDbAsync(db, currentUser.Id.Value);
57 66 }
58 67  
59 68 public static async Task<List<AuthScopeCompanyOptionDto>> ListCompaniesAsync(ISqlSugarClient db)
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/MultiTenancy/FoodLabelingTenantMigrationScriptNames.cs
... ... @@ -18,6 +18,8 @@ public static class FoodLabelingTenantMigrationScriptNames
18 18  
19 19 public const string Training = "FoodLabeling.TenantMigrations.fl_training.sql";
20 20  
  21 + public const string LabelAlertTimer = "FoodLabeling.TenantMigrations.fl_label_alert_timer.sql";
  22 +
21 23 /// <summary>CodeFirst 之后按序执行的脚本资源名。</summary>
22 24 public static readonly IReadOnlyList<string> TenantInitializationOrder =
23 25 [
... ... @@ -26,6 +28,7 @@ public static class FoodLabelingTenantMigrationScriptNames
26 28 ProductCategoryPartnerScope,
27 29 UserLocation,
28 30 TeamMemberScope,
29   - Training
  31 + Training,
  32 + LabelAlertTimer
30 33 ];
31 34 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
... ... @@ -193,11 +193,10 @@ public class ProductAppService : ApplicationService, IProductAppService
193 193 partnerIds,
194 194 groupIds,
195 195 locationIds,
196   - new ScopeAllEchoHelper.ScopeAllEchoOptions
197   - {
198   - IsRegionAll = AllScopeBindingHelper.IsDeclaredAll(entity.AppliedRegionType),
199   - IsLocationAll = false
200   - });
  196 + ScopeAllEchoHelper.ForLabelEntityScope(
  197 + appliedPartnerType: null,
  198 + appliedRegionType: entity.AppliedRegionType,
  199 + availabilityType: entity.AvailabilityType));
201 200 }
202 201  
203 202 List<string> companyIdsForEcho;
... ... @@ -972,6 +971,7 @@ public class ProductAppService : ApplicationService, IProductAppService
972 971  
973 972 private static bool HasProductScopeBinding(ProductCreateInputVo input) =>
974 973 !string.IsNullOrWhiteSpace(input.PartnerId) ||
  974 + input.PartnerIds is not null ||
975 975 input.CompanyIds is not null ||
976 976 input.GroupIds is not null ||
977 977 input.RegionIds is not null ||
... ... @@ -1004,7 +1004,18 @@ public class ProductAppService : ApplicationService, IProductAppService
1004 1004 /// </summary>
1005 1005 private static string? ResolveSinglePartnerIdForSave(ProductCreateInputVo input)
1006 1006 {
1007   - var companyIds = LocationScopeBindingHelper.NormalizeIds(input.CompanyIds);
  1007 + var merged = new HashSet<string>(StringComparer.Ordinal);
  1008 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.CompanyIds))
  1009 + {
  1010 + merged.Add(id);
  1011 + }
  1012 +
  1013 + foreach (var id in LocationScopeBindingHelper.NormalizeIds(input.PartnerIds))
  1014 + {
  1015 + merged.Add(id);
  1016 + }
  1017 +
  1018 + var companyIds = merged.OrderBy(x => x, StringComparer.Ordinal).ToList();
1008 1019 if (AllScopeBindingHelper.HasAllScopeSentinelSelection(companyIds)
1009 1020 || LocationScopeBindingHelper.IsAllScopeSentinel(input.PartnerId))
1010 1021 {
... ... @@ -1045,159 +1056,18 @@ public class ProductAppService : ApplicationService, IProductAppService
1045 1056 }
1046 1057  
1047 1058 var partnerId = ResolveSinglePartnerIdForSave(input);
1048   - var normalizedLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds);
1049   - var normalizedGroupIds = NormalizeProductRegionIds(input);
1050   - var locationHasAll = AllScopeBindingHelper.HasAllScopeSentinelSelection(normalizedLocationIds);
1051   - var regionHasAllSentinel = AllScopeBindingHelper.HasAllScopeSentinelSelection(normalizedGroupIds);
1052   - var groupIds = LocationScopeBindingHelper.FilterConcreteScopeIds(normalizedGroupIds);
1053   - var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(normalizedLocationIds);
1054   - var hasConcretePartner = !string.IsNullOrWhiteSpace(partnerId);
1055   - var partnerContext = hasConcretePartner ? new[] { partnerId! } : null;
1056   -
1057   - var regionHasAll = regionHasAllSentinel;
1058   - if (!regionHasAll && groupIds.Count > 0)
1059   - {
1060   - var allRegions = await AllScopeBindingHelper.ResolveAllRegionIdsAsync(
1061   - _dbContext.SqlSugarClient, partnerContext);
1062   - regionHasAll = allRegions.Count > 0
1063   - && AllScopeBindingHelper.IsFullIdSelection(groupIds, allRegions);
1064   - }
1065   -
1066   - // Location ALL 哨兵,或具体 Region 下空选/全选门店(前端常展开为 Guid)
1067   - var locationCoversConcreteRegions = false;
1068   - if (!locationHasAll && groupIds.Count > 0 && !regionHasAll)
1069   - {
1070   - if (explicitLocationIds.Count == 0)
1071   - {
1072   - locationCoversConcreteRegions = true;
1073   - }
1074   - else
1075   - {
1076   - var regionUniverse = await AllScopeBindingHelper.ResolveAllLocationIdsAsync(
1077   - _dbContext.SqlSugarClient, partnerContext, groupIds);
1078   - locationCoversConcreteRegions = regionUniverse.Count > 0
1079   - && AllScopeBindingHelper.IsFullIdSelection(
1080   - explicitLocationIds, regionUniverse);
1081   - }
1082   - }
1083   -
1084   - if (locationHasAll || locationCoversConcreteRegions)
1085   - {
1086   - var expandRegions = regionHasAll ? null : (groupIds.Count > 0 ? groupIds : null);
1087   - var expanded = await LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync(
1088   - _dbContext.SqlSugarClient,
1089   - partnerContext,
1090   - expandRegions);
1091   - if (expanded is not null)
1092   - {
1093   - return (
1094   - AllScopeBindingHelper.ScopeSpecified,
1095   - regionHasAll || groupIds.Count == 0
1096   - ? AllScopeBindingHelper.ScopeAll
1097   - : AllScopeBindingHelper.ScopeSpecified,
1098   - expanded);
1099   - }
1100   -
1101   - return (AllScopeBindingHelper.ScopeAll, AllScopeBindingHelper.ScopeAll, new List<string>());
1102   - }
1103   -
1104   - // Region=ALL + 具体门店:保留门店快照
1105   - if (regionHasAll && explicitLocationIds.Count > 0)
1106   - {
1107   - await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(
1108   - _dbContext.SqlSugarClient, explicitLocationIds);
1109   - return (
1110   - AllScopeBindingHelper.ScopeSpecified,
1111   - AllScopeBindingHelper.ScopeAll,
1112   - explicitLocationIds);
1113   - }
  1059 + var mergedRegionIds = NormalizeProductRegionIds(input);
  1060 + var partnerContext = !string.IsNullOrWhiteSpace(partnerId) ? new[] { partnerId! } : null;
1114 1061  
1115   - if (explicitLocationIds.Count > 0)
1116   - {
1117   - await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(
1118   - _dbContext.SqlSugarClient, explicitLocationIds);
1119   - return (
1120   - AllScopeBindingHelper.ScopeSpecified,
1121   - AllScopeBindingHelper.ScopeSpecified,
1122   - explicitLocationIds);
1123   - }
1124   -
1125   - if (regionHasAll)
1126   - {
1127   - var expanded = await LocationScopeBindingHelper.ExpandScopedAllLocationsForSaveAsync(
1128   - _dbContext.SqlSugarClient,
1129   - partnerContext,
1130   - regionIds: null);
1131   - if (expanded is not null)
1132   - {
1133   - return (
1134   - AllScopeBindingHelper.ScopeSpecified,
1135   - AllScopeBindingHelper.ScopeAll,
1136   - expanded);
1137   - }
1138   -
1139   - return (AllScopeBindingHelper.ScopeAll, AllScopeBindingHelper.ScopeAll, new List<string>());
1140   - }
1141   -
1142   - if (await AllScopeBindingHelper.ShouldTreatProductLocationScopeAsAllAsync(
1143   - _dbContext.SqlSugarClient,
1144   - input.AvailabilityType,
1145   - partnerId,
1146   - groupIds,
1147   - explicitLocationIds,
1148   - HasProductScopeBinding(input)))
1149   - {
1150   - return (AllScopeBindingHelper.ScopeAll, AllScopeBindingHelper.ScopeAll, new List<string>());
1151   - }
1152   -
1153   - if (!HasProductScopeBinding(input))
1154   - {
1155   - return (AllScopeBindingHelper.ScopeSpecified, AllScopeBindingHelper.ScopeAll, new List<string>());
1156   - }
1157   -
1158   - var locIds = await ResolveProductLocationIdsForSaveAsync(partnerId, groupIds, explicitLocationIds);
1159   - return (
1160   - AllScopeBindingHelper.ScopeSpecified,
1161   - groupIds.Count > 0 ? AllScopeBindingHelper.ScopeSpecified : AllScopeBindingHelper.ScopeAll,
1162   - locIds);
1163   - }
1164   -
1165   - /// <summary>
1166   - /// 合并 Company(partnerId)、Region(groupIds)、门店(locationIds)并校验存在性。
1167   - /// 有具体门店时以门店为准,不再与 Region/Company 并集。
1168   - /// </summary>
1169   - private async Task<List<string>> ResolveProductLocationIdsForSaveAsync(
1170   - string? partnerId,
1171   - IReadOnlyList<string> groupIds,
1172   - IReadOnlyList<string> locationIds)
1173   - {
1174   - if (LocationScopeBindingHelper.IsAllScopeSentinel(partnerId)
1175   - || AllScopeBindingHelper.HasAllScopeSentinelSelection(groupIds)
1176   - || AllScopeBindingHelper.HasAllScopeSentinelSelection(locationIds))
1177   - {
1178   - throw new UserFriendlyException("适用范围为全选(ALL),无需解析具体门店");
1179   - }
1180   -
1181   - var concreteLocations = LocationScopeBindingHelper.FilterConcreteScopeIds(locationIds);
1182   - if (concreteLocations.Count > 0)
1183   - {
1184   - await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(
1185   - _dbContext.SqlSugarClient, concreteLocations);
1186   - return concreteLocations;
1187   - }
1188   -
1189   - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
  1062 + var locScope = await AllScopeBindingHelper.ResolveLabelEntityRegionLocationForSaveAsync(
1190 1063 _dbContext.SqlSugarClient,
1191   - partnerId,
1192   - groupIds,
1193   - locationIds);
1194   - if (merged.Count == 0)
1195   - {
1196   - throw new UserFriendlyException("指定适用 Company、Region 或门店时,至少需要匹配到一个有效门店");
1197   - }
  1064 + input.AvailabilityType,
  1065 + partnerContext,
  1066 + mergedRegionIds,
  1067 + input.LocationIds,
  1068 + HasProductScopeBinding(input));
1198 1069  
1199   - await LocationScopeBindingHelper.ValidateLocationIdsExistAsync(_dbContext.SqlSugarClient, merged);
1200   - return merged;
  1070 + return (locScope.AvailabilityType, locScope.AppliedRegionType, locScope.LocationIds);
1201 1071 }
1202 1072  
1203 1073 /// <summary>
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TrainingAppService.cs
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
1 2 using FoodLabeling.Application.Contracts.Dtos.Training;
2 3 using FoodLabeling.Application.Contracts.IServices;
3 4 using FoodLabeling.Application.Helpers;
... ... @@ -125,6 +126,11 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
125 126 }
126 127 }
127 128  
  129 + var allFileEntities = filesByCategory.Values.SelectMany(x => x).ToList();
  130 + var scopeDisplayMap = await TrainingFileScopeHelper.BuildScopeDisplayMapAsync(
  131 + _dbContext.SqlSugarClient,
  132 + allFileEntities);
  133 +
128 134 var level1 = categories.Where(x => string.IsNullOrWhiteSpace(x.ParentId)).ToList();
129 135 var level2Map = categories
130 136 .Where(x => !string.IsNullOrWhiteSpace(x.ParentId))
... ... @@ -157,7 +163,7 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
157 163 continue;
158 164 }
159 165  
160   - childNodes.Add(MapCategoryNode(l2, fileRows));
  166 + childNodes.Add(MapCategoryNode(l2, fileRows, scopeDisplayMap));
161 167 }
162 168  
163 169 var l1NameMatch = string.IsNullOrWhiteSpace(keyword)
... ... @@ -338,11 +344,21 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
338 344 }
339 345  
340 346 /// <summary>
341   - /// 上传培训文件到二级分类
  347 + /// 上传培训文件到二级分类(可同时提交 Company / Region / Location 适用范围)
342 348 /// </summary>
343 349 /// <remarks>
344   - /// multipart/form-data:file、categoryId、orderNum
  350 + /// multipart/form-data:file、categoryId、orderNum,及可选 scope 字段
345 351 /// 支持常见图片与 pdf/doc/docx/xlsx 等,单文件最大 20MB。
  352 + ///
  353 + /// 示例 form 字段:
  354 + /// - file: 文件
  355 + /// - categoryId: 二级分类 Id
  356 + /// - appliedPartnerType: ALL / SPECIFIED
  357 + /// - partnerIds: 可重复传值或含 ALL
  358 + /// - appliedRegionType: ALL / SPECIFIED
  359 + /// - regionIds / groupIds: 可含 ALL
  360 + /// - availabilityType 或 appliedLocationType: ALL / SPECIFIED
  361 + /// - locationIds: 可含 ALL
346 362 /// </remarks>
347 363 /// <param name="input">上传表单</param>
348 364 /// <returns>文件信息</returns>
... ... @@ -352,7 +368,7 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
352 368 [HttpPost]
353 369 [Consumes("multipart/form-data")]
354 370 [Route("/api/app/training/file/upload")]
355   - public async Task<TrainingFileUploadOutputDto> UploadFileAsync([FromForm] TrainingFileUploadInputVo input)
  371 + public async Task<TrainingFileDto> UploadFileAsync([FromForm] TrainingFileUploadInputVo input)
356 372 {
357 373 if (input.File is null || input.File.Length <= 0)
358 374 {
... ... @@ -395,6 +411,20 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
395 411  
396 412 var now = DateTime.Now;
397 413 var currentUserId = CurrentUser?.Id?.ToString();
  414 + var hasScopeInput = HasScopeInput(input);
  415 + var scope = hasScopeInput
  416 + ? await TrainingFileScopeHelper.ResolveScopeForSaveAsync(
  417 + _dbContext.SqlSugarClient,
  418 + input.AppliedPartnerType,
  419 + input.PartnerIds,
  420 + input.CompanyIds,
  421 + input.AppliedRegionType,
  422 + input.RegionIds,
  423 + input.GroupIds,
  424 + ResolveLocationTypeInput(input),
  425 + input.LocationIds)
  426 + : null;
  427 +
398 428 var entity = new FlTrainingFileDbEntity
399 429 {
400 430 Id = YitIdHelper.NextId().ToString(),
... ... @@ -404,9 +434,9 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
404 434 FileType = ResolveFileType(ext),
405 435 FileSize = input.File.Length,
406 436 OrderNum = input.OrderNum,
407   - AppliedPartnerType = AllScopeBindingHelper.ScopeAll,
408   - AppliedRegionType = AllScopeBindingHelper.ScopeAll,
409   - AvailabilityType = AllScopeBindingHelper.ScopeAll,
  437 + AppliedPartnerType = scope?.AppliedPartnerType ?? AllScopeBindingHelper.ScopeAll,
  438 + AppliedRegionType = scope?.AppliedRegionType ?? AllScopeBindingHelper.ScopeAll,
  439 + AvailabilityType = scope?.AvailabilityType ?? AllScopeBindingHelper.ScopeAll,
410 440 IsDeleted = false,
411 441 CreationTime = now,
412 442 CreatorId = currentUserId,
... ... @@ -416,28 +446,42 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
416 446 };
417 447  
418 448 await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
419   -
420   - return new TrainingFileUploadOutputDto
  449 + if (scope is not null)
421 450 {
422   - Id = entity.Id,
423   - FileName = entity.FileName,
424   - FileUrl = entity.FileUrl,
425   - FileType = entity.FileType,
426   - FileSize = entity.FileSize
427   - };
  451 + await TrainingFileScopeHelper.SaveScopeAsync(
  452 + _dbContext.SqlSugarClient,
  453 + entity.Id,
  454 + scope,
  455 + currentUserId,
  456 + now);
  457 + }
  458 +
  459 + var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity);
  460 + return MapFileDto(entity, display);
428 461 }
429 462  
430 463 /// <summary>
431   - /// 编辑培训文件元数据
  464 + /// 编辑培训文件元数据及适用范围
432 465 /// </summary>
433 466 /// <remarks>
434 467 /// 示例请求:
435 468 /// ```json
436 469 /// {
437 470 /// "fileName": "操作手册.pdf",
438   - /// "orderNum": 100
  471 + /// "orderNum": 100,
  472 + /// "appliedPartnerType": "SPECIFIED",
  473 + /// "partnerIds": ["p1"],
  474 + /// "appliedRegionType": "ALL",
  475 + /// "availabilityType": "SPECIFIED",
  476 + /// "locationIds": ["loc1"]
439 477 /// }
440 478 /// ```
  479 + ///
  480 + /// 参数说明:
  481 + /// - fileName / orderNum: 文件元数据
  482 + /// - appliedPartnerType / partnerIds / companyIds: Company 范围,Id 可含 ALL
  483 + /// - appliedRegionType / regionIds / groupIds: Region 范围,Id 可含 ALL
  484 + /// - availabilityType 或 appliedLocationType / locationIds: Location 范围,Id 可含 ALL
441 485 /// </remarks>
442 486 /// <param name="id">文件Id</param>
443 487 /// <param name="input">文件元数据</param>
... ... @@ -460,7 +504,14 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
460 504 entity.LastModifierId = CurrentUser?.Id?.ToString();
461 505  
462 506 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
463   - return MapFileDto(entity);
  507 +
  508 + if (HasScopeInput(input))
  509 + {
  510 + entity = await ApplyFileScopeAsync(entity, input);
  511 + }
  512 +
  513 + var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity);
  514 + return MapFileDto(entity, display);
464 515 }
465 516  
466 517 /// <summary>
... ... @@ -538,13 +589,15 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
538 589 }
539 590  
540 591 /// <summary>
541   - /// 获取培训文件权限范围
  592 + /// 获取培训文件权限范围(兼容独立查询;主路径为 create/update 携带 scope)
542 593 /// </summary>
543 594 /// <param name="id">文件Id</param>
544 595 /// <returns>权限范围</returns>
545 596 /// <response code="200">成功</response>
546 597 /// <response code="400">文件不存在</response>
547 598 /// <response code="500">服务器错误</response>
  599 + [HttpGet]
  600 + [Route("/api/app/training/file-scope/{id}")]
548 601 public async Task<TrainingFileScopeOutputDto> GetFileScopeAsync(string id)
549 602 {
550 603 var entity = await GetFileOrThrowAsync(id);
... ... @@ -553,7 +606,7 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
553 606 }
554 607  
555 608 /// <summary>
556   - /// 设置培训文件权限范围
  609 + /// 设置培训文件权限范围(兼容独立编辑;主路径为 create/update 携带 scope)
557 610 /// </summary>
558 611 /// <remarks>
559 612 /// 示例请求:
... ... @@ -573,9 +626,20 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
573 626 /// <response code="200">设置成功</response>
574 627 /// <response code="400">参数无效</response>
575 628 /// <response code="500">服务器错误</response>
  629 + [HttpPut]
  630 + [Route("/api/app/training/file-scope/{id}")]
576 631 public async Task<TrainingFileScopeOutputDto> SetFileScopeAsync(string id, TrainingFileScopeInputVo input)
577 632 {
578 633 var entity = await GetFileOrThrowAsync(id);
  634 + entity = await ApplyFileScopeAsync(entity, input);
  635 + var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity);
  636 + return MapScopeOutput(display);
  637 + }
  638 +
  639 + private async Task<FlTrainingFileDbEntity> ApplyFileScopeAsync(
  640 + FlTrainingFileDbEntity entity,
  641 + ITrainingFileScopeInput input)
  642 + {
579 643 var scope = await TrainingFileScopeHelper.ResolveScopeForSaveAsync(
580 644 _dbContext.SqlSugarClient,
581 645 input.AppliedPartnerType,
... ... @@ -584,7 +648,7 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
584 648 input.AppliedRegionType,
585 649 input.RegionIds,
586 650 input.GroupIds,
587   - input.AvailabilityType,
  651 + ResolveLocationTypeInput(input),
588 652 input.LocationIds);
589 653  
590 654 entity.AppliedPartnerType = scope.AppliedPartnerType;
... ... @@ -601,29 +665,44 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
601 665 entity.LastModifierId,
602 666 entity.LastModificationTime ?? DateTime.Now);
603 667  
604   - var display = await TrainingFileScopeHelper.BuildScopeDisplayAsync(_dbContext.SqlSugarClient, entity);
605   - return MapScopeOutput(display);
  668 + return entity;
606 669 }
607 670  
608   - private async Task<FlTrainingCategoryDbEntity> GetCategoryOrThrowAsync(string id)
  671 + private static bool HasScopeInput(ITrainingFileScopeInput input) =>
  672 + !string.IsNullOrWhiteSpace(input.AppliedPartnerType)
  673 + || input.PartnerIds is not null
  674 + || input.CompanyIds is not null
  675 + || !string.IsNullOrWhiteSpace(input.AppliedRegionType)
  676 + || input.RegionIds is not null
  677 + || input.GroupIds is not null
  678 + || !string.IsNullOrWhiteSpace(input.AvailabilityType)
  679 + || !string.IsNullOrWhiteSpace(input.AppliedLocationType)
  680 + || input.LocationIds is not null;
  681 +
  682 + private static string? ResolveLocationTypeInput(ITrainingFileScopeInput input) =>
  683 + string.IsNullOrWhiteSpace(input.AvailabilityType)
  684 + ? input.AppliedLocationType
  685 + : input.AvailabilityType;
  686 +
  687 + private async Task<FlTrainingFileDbEntity> GetFileOrThrowAsync(string id)
609 688 {
610   - var entity = await _dbContext.SqlSugarClient.Queryable<FlTrainingCategoryDbEntity>()
  689 + var entity = await _dbContext.SqlSugarClient.Queryable<FlTrainingFileDbEntity>()
611 690 .FirstAsync(x => x.Id == id && !x.IsDeleted);
612 691 if (entity is null)
613 692 {
614   - throw new UserFriendlyException("分类不存在");
  693 + throw new UserFriendlyException("培训文件不存在");
615 694 }
616 695  
617 696 return entity;
618 697 }
619 698  
620   - private async Task<FlTrainingFileDbEntity> GetFileOrThrowAsync(string id)
  699 + private async Task<FlTrainingCategoryDbEntity> GetCategoryOrThrowAsync(string id)
621 700 {
622   - var entity = await _dbContext.SqlSugarClient.Queryable<FlTrainingFileDbEntity>()
  701 + var entity = await _dbContext.SqlSugarClient.Queryable<FlTrainingCategoryDbEntity>()
623 702 .FirstAsync(x => x.Id == id && !x.IsDeleted);
624 703 if (entity is null)
625 704 {
626   - throw new UserFriendlyException("培训文件不存在");
  705 + throw new UserFriendlyException("分类不存在");
627 706 }
628 707  
629 708 return entity;
... ... @@ -642,7 +721,8 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
642 721  
643 722 private static TrainingCategoryTreeNodeDto MapCategoryNode(
644 723 FlTrainingCategoryDbEntity entity,
645   - List<FlTrainingFileDbEntity> files) =>
  724 + List<FlTrainingFileDbEntity> files,
  725 + IReadOnlyDictionary<string, TrainingFileScopeHelper.TrainingFileScopeDisplay> scopeDisplayMap) =>
646 726 new()
647 727 {
648 728 Id = entity.Id,
... ... @@ -650,10 +730,16 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
650 730 ParentId = entity.ParentId,
651 731 OrderNum = entity.OrderNum,
652 732 Children = new List<TrainingCategoryTreeNodeDto>(),
653   - Files = files.Select(MapFileDto).ToList()
  733 + Files = files.Select(file =>
  734 + {
  735 + scopeDisplayMap.TryGetValue(file.Id, out var display);
  736 + return MapFileDto(file, display);
  737 + }).ToList()
654 738 };
655 739  
656   - private static TrainingFileDto MapFileDto(FlTrainingFileDbEntity entity) =>
  740 + private static TrainingFileDto MapFileDto(
  741 + FlTrainingFileDbEntity entity,
  742 + TrainingFileScopeHelper.TrainingFileScopeDisplay? display = null) =>
657 743 new()
658 744 {
659 745 Id = entity.Id,
... ... @@ -663,9 +749,17 @@ public class TrainingAppService : ApplicationService, ITrainingAppService
663 749 FileType = entity.FileType,
664 750 FileSize = entity.FileSize,
665 751 OrderNum = entity.OrderNum,
666   - AppliedPartnerType = entity.AppliedPartnerType,
667   - AppliedRegionType = entity.AppliedRegionType,
668   - AvailabilityType = entity.AvailabilityType,
  752 + AppliedPartnerType = display?.AppliedPartnerType ?? entity.AppliedPartnerType,
  753 + Company = display?.Company ?? string.Empty,
  754 + PartnerIds = display?.PartnerIds ?? new List<string>(),
  755 + CompanyIds = display?.PartnerIds ?? new List<string>(),
  756 + AppliedRegionType = display?.AppliedRegionType ?? entity.AppliedRegionType,
  757 + Region = display?.Region ?? string.Empty,
  758 + RegionIds = display?.RegionIds ?? new List<string>(),
  759 + GroupIds = display?.RegionIds ?? new List<string>(),
  760 + AvailabilityType = display?.AvailabilityType ?? entity.AvailabilityType,
  761 + Location = display?.Location ?? string.Empty,
  762 + LocationIds = display?.LocationIds ?? new List<string>(),
669 763 CreationTime = entity.CreationTime,
670 764 LastModificationTime = entity.LastModificationTime
671 765 };
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppAuthAppService.cs
1   -using System;
  1 +using System;
2 2 using System.Collections.Generic;
3 3 using System.Globalization;
4 4 using System.IdentityModel.Tokens.Jwt;
... ... @@ -98,12 +98,7 @@ public class UsAppAuthAppService : ApplicationService, IUsAppAuthAppService
98 98 [AllowAnonymous]
99 99 public virtual async Task<UsAppLoginOutputDto> LoginAsync(UsAppLoginInputVo input)
100 100 {
101   - if (_dbConnOptions.EnabledSaasMultiTenancy && !CurrentTenant.Id.HasValue)
102   - {
103   - throw new UserFriendlyException(
104   - "多租户模式下请使用泰额 App 登录接口 /api/app/th-app-auth/login(须传 tenantId),勿使用 us-app-auth。");
105   - }
106   -
  101 + // 泰额 SaaS:由 ThUsAppAuthAppService 覆盖本方法,按邮箱反查租户后登录;此处仅单库模式。
107 102 if (string.IsNullOrWhiteSpace(input.Password) || string.IsNullOrWhiteSpace(input.Email))
108 103 {
109 104 throw new UserFriendlyException("请输入合理数据!");
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
... ... @@ -541,6 +541,10 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
541 541 {
542 542 var existedBatchId = existed.First().BatchId;
543 543 var existedTaskIds = existed.Select(x => x.Id).ToList();
  544 + await LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync(
  545 + _dbContext.SqlSugarClient,
  546 + existedBatchId ?? string.Empty,
  547 + CurrentUser?.Id?.ToString());
544 548 return new UsAppLabelPrintOutputDto
545 549 {
546 550 TaskId = existedTaskIds.FirstOrDefault() ?? string.Empty,
... ... @@ -689,6 +693,11 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
689 693 }
690 694 }
691 695  
  696 + await LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync(
  697 + _dbContext.SqlSugarClient,
  698 + batchId,
  699 + currentUserId);
  700 +
692 701 return new UsAppLabelPrintOutputDto
693 702 {
694 703 TaskId = taskIds.FirstOrDefault() ?? string.Empty,
... ... @@ -736,6 +745,10 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
736 745 {
737 746 var existedBatchId = existed.First().BatchId;
738 747 var existedTaskIds = existed.Select(x => x.Id).ToList();
  748 + await LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync(
  749 + _dbContext.SqlSugarClient,
  750 + existedBatchId ?? string.Empty,
  751 + CurrentUser?.Id?.ToString());
739 752 return new UsAppLabelPrintOutputDto
740 753 {
741 754 TaskId = existedTaskIds.FirstOrDefault() ?? string.Empty,
... ... @@ -846,6 +859,11 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
846 859 }
847 860 }
848 861  
  862 + await LabelAlertTimerWriteHelper.TryCreateFromPrintBatchAsync(
  863 + _dbContext.SqlSugarClient,
  864 + batchId,
  865 + currentUserId);
  866 +
849 867 return new UsAppLabelPrintOutputDto
850 868 {
851 869 TaskId = taskIds.FirstOrDefault() ?? string.Empty,
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/Auth/ThAppLoginInputVo.cs
... ... @@ -3,13 +3,14 @@ using System.ComponentModel.DataAnnotations;
3 3 namespace FoodLabeling.Th.Application.Contracts.Dtos.Auth;
4 4  
5 5 /// <summary>
6   -/// 泰额版 App 登录(须指定租户,用户数据在租户独立库
  6 +/// 泰额版 App 登录(用户数据在租户独立库;tenantId 可空,按邮箱反查公司
7 7 /// </summary>
8 8 public class ThAppLoginInputVo
9 9 {
10   - /// <summary>租户 Id(平台主库 yitenant.Id)</summary>
11   - [Required]
12   - public Guid TenantId { get; set; }
  10 + /// <summary>
  11 + /// 租户 Id(平台主库 YiTenant.Id)。可空:与 th-web-auth 一致,按邮箱/用户名反查公司租户。
  12 + /// </summary>
  13 + public Guid? TenantId { get; set; }
13 14  
14 15 /// <summary>登录邮箱</summary>
15 16 [Required]
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/IServices/IThAppAuthAppService.cs
... ... @@ -10,7 +10,8 @@ namespace FoodLabeling.Th.Application.Contracts.IServices;
10 10 public interface IThAppAuthAppService : IApplicationService
11 11 {
12 12 /// <summary>
13   - /// 登录:校验租户存在后,在租户业务库验证账号并签发 Token(Claim 含 TenantId)
  13 + /// 登录:校验租户存在后,在租户业务库验证账号并签发 Token(Claim 含 TenantId)。
  14 + /// tenantId 可空,按邮箱/用户名反查公司租户(与 th-web-auth 一致)。
14 15 /// </summary>
15 16 Task<ThAppLoginOutputDto> LoginAsync(ThAppLoginInputVo input);
16 17  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Filters/FoodLabelingBusinessTenantActionFilter.cs
... ... @@ -87,6 +87,7 @@ public class FoodLabelingBusinessTenantActionFilter : IAsyncActionFilter, ITrans
87 87 {
88 88 "/th-web-auth",
89 89 "/th-app-auth",
  90 + "/us-app-auth",
90 91 "/th-multi-tenancy",
91 92 "/th-tenant-provisioning",
92 93 "/th-tenant-select",
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThTenantHostDataAccessor.cs
... ... @@ -74,8 +74,10 @@ internal static class ThTenantHostDataAccessor
74 74 LIMIT 1
75 75 """;
76 76  
77   - var rows = await client.Ado.SqlQueryAsync<string>(sql, new { LoginAccount = normalized });
78   - if (rows.Count == 0 || !Guid.TryParse(rows[0], out var tenantId))
  77 + // SqlSugar SqlQueryAsync<string> 对单列映射不稳定,使用 DTO
  78 + var rows = await client.Ado.SqlQueryAsync<TenantIdSqlRow>(sql, new { LoginAccount = normalized });
  79 + var raw = rows.FirstOrDefault()?.TenantId;
  80 + if (string.IsNullOrWhiteSpace(raw) || !Guid.TryParse(raw.Trim(), out var tenantId))
79 81 {
80 82 return null;
81 83 }
... ... @@ -117,6 +119,7 @@ internal static class ThTenantHostDataAccessor
117 119 {
118 120 var tenantDbType = tenant.DbType == default ? hostDbType : tenant.DbType;
119 121 using var tenantClient = TenantBusinessDatabaseAccessor.CreateClient(connectionString, tenantDbType);
  122 + tenantClient.Ado.CommandTimeOut = 8;
120 123  
121 124 const string sql = """
122 125 SELECT CAST(`Id` AS CHAR(36)) AS UserId
... ... @@ -130,8 +133,8 @@ internal static class ThTenantHostDataAccessor
130 133 LIMIT 1
131 134 """;
132 135  
133   - var rows = await tenantClient.Ado.SqlQueryAsync<string>(sql, new { LoginAccount = normalized });
134   - if (rows.Count > 0)
  136 + var rows = await tenantClient.Ado.SqlQueryAsync<UserIdSqlRow>(sql, new { LoginAccount = normalized });
  137 + if (rows.Count > 0 && !string.IsNullOrWhiteSpace(rows[0].UserId))
135 138 {
136 139 return tenant.Id;
137 140 }
... ... @@ -198,6 +201,15 @@ internal static class ThTenantHostDataAccessor
198 201 DbType = dbType,
199 202 ConnectionString = connectionString,
200 203 IsAutoCloseConnection = true,
  204 + MoreSettings = new ConnMoreSettings
  205 + {
  206 + // 避免反查登录租户时长时间卡在不可达库
  207 + IsAutoRemoveDataCache = true
  208 + },
  209 + AopEvents = new AopEvents
  210 + {
  211 + OnError = _ => { }
  212 + },
201 213 // 与 DefaultSqlSugarDbContext 一致:主库 menu 等表无 ExtraProperties 列
202 214 ConfigureExternalServices = new ConfigureExternalServices
203 215 {
... ... @@ -214,6 +226,20 @@ internal static class ThTenantHostDataAccessor
214 226 }
215 227 }
216 228 }
  229 + },
  230 + db =>
  231 + {
  232 + db.Ado.CommandTimeOut = 8;
217 233 });
218 234 }
  235 +
  236 + private sealed class TenantIdSqlRow
  237 + {
  238 + public string? TenantId { get; set; }
  239 + }
  240 +
  241 + private sealed class UserIdSqlRow
  242 + {
  243 + public string? UserId { get; set; }
  244 + }
219 245 }
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThAppAuthAppService.cs
... ... @@ -11,6 +11,7 @@ using FoodLabeling.Th.Application.Contracts.IServices;
11 11 using Lazy.Captcha.Core;
12 12 using Microsoft.AspNetCore.Authorization;
13 13 using Microsoft.AspNetCore.Mvc;
  14 +using Microsoft.Extensions.Logging;
14 15 using Microsoft.Extensions.Options;
15 16 using Microsoft.IdentityModel.Tokens;
16 17 using SqlSugar;
... ... @@ -28,68 +29,106 @@ using Yi.Framework.SqlSugarCore.Abstractions;
28 29 namespace FoodLabeling.Th.Application.Services;
29 30  
30 31 /// <summary>
31   -/// 泰额版 App 登录:先解析租户,再在租户独立库校验用户,JWT 写入 TenantId
  32 +/// 泰额版 App 登录:解析租户后在租户独立库校验用户,JWT 写入 TenantId
32 33 /// </summary>
33 34 public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
34 35 {
35 36 private readonly IAccountManager _accountManager;
36 37 private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository;
37 38 private readonly ISugarDbContextProvider<ISqlSugarDbContext> _dbContextProvider;
  39 + private readonly TenantAdminAccountBootstrapper _adminAccountBootstrapper;
38 40 private readonly ICaptcha _captcha;
39 41 private readonly RbacOptions _rbacOptions;
40 42 private readonly JwtOptions _jwtOptions;
41 43 private readonly ITenantStore _tenantStore;
  44 + private readonly DbConnOptions _dbConnOptions;
42 45  
43 46 public ThAppAuthAppService(
44 47 IAccountManager accountManager,
45 48 ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
46 49 ISugarDbContextProvider<ISqlSugarDbContext> dbContextProvider,
  50 + TenantAdminAccountBootstrapper adminAccountBootstrapper,
47 51 ICaptcha captcha,
48 52 IOptions<JwtOptions> jwtOptions,
49 53 IOptions<RbacOptions> rbacOptions,
50   - ITenantStore tenantStore)
  54 + ITenantStore tenantStore,
  55 + IOptions<DbConnOptions> dbConnOptions)
51 56 {
52 57 _accountManager = accountManager;
53 58 _userRepository = userRepository;
54 59 _dbContextProvider = dbContextProvider;
  60 + _adminAccountBootstrapper = adminAccountBootstrapper;
55 61 _captcha = captcha;
56 62 _jwtOptions = jwtOptions.Value;
57 63 _rbacOptions = rbacOptions.Value;
58 64 _tenantStore = tenantStore;
  65 + _dbConnOptions = dbConnOptions.Value;
59 66 }
60 67  
61   - /// <inheritdoc />
  68 + /// <summary>
  69 + /// App 登录:校验租户后在租户业务库验证账号并签发 Token(Claim 含 TenantId)
  70 + /// </summary>
  71 + /// <remarks>
  72 + /// <paramref name="input.TenantId"/> 可空:与 <c>th-web-auth/login</c> 一致,按邮箱/用户名反查公司租户
  73 + /// (开通邮箱 <c>fl_th_tenant_admin_credential</c> 或各租户业务库 <c>user</c> 表)。
  74 + ///
  75 + /// 示例(可不传 tenantId):
  76 + /// ```json
  77 + /// { "email": "mai@123.com", "password": "123456" }
  78 + /// ```
  79 + /// </remarks>
  80 + /// <param name="input">登录参数</param>
  81 + /// <returns>Token、RefreshToken、租户信息与绑定门店</returns>
  82 + /// <response code="200">登录成功</response>
  83 + /// <response code="400">参数缺失、验证码错误或账号密码错误</response>
  84 + /// <response code="500">服务器错误</response>
62 85 [AllowAnonymous]
63 86 [HttpPost("th-app-auth/login")]
64 87 public virtual async Task<ThAppLoginOutputDto> LoginAsync(ThAppLoginInputVo input)
65 88 {
66   - if (input.TenantId == Guid.Empty
67   - || string.IsNullOrWhiteSpace(input.Password)
  89 + if (string.IsNullOrWhiteSpace(input.Password)
68 90 || string.IsNullOrWhiteSpace(input.Email))
69 91 {
70   - throw new UserFriendlyException("请输入租户、邮箱与密码!");
  92 + throw new UserFriendlyException("请输入邮箱与密码!");
71 93 }
72 94  
73 95 ValidationImageCaptcha(input.Uuid, input.Code);
74 96  
75   - var tenantConfig = await TenantResolveHelper.ResolveTenantAsync(
76   - _tenantStore, CurrentTenant, input.TenantId);
  97 + var tenantId = await ResolveTenantIdForLoginAsync(input);
  98 + if (!tenantId.HasValue || tenantId.Value == Guid.Empty)
  99 + {
  100 + throw new UserFriendlyException("登录失败:未找到该邮箱对应的公司,请确认邮箱或联系管理员");
  101 + }
77 102  
78   - UserAggregateRoot user;
79   - List<UsAppBoundLocationDto> locations;
80   - using (CurrentTenant.Change(input.TenantId, tenantConfig.Name))
  103 + if (ThWebPlatformLoginHelper.IsBusinessTenantLogin(tenantId, null))
81 104 {
82   - user = await FindActiveUserByEmailAsync(input.Email.Trim())
  105 + await EnsureNotHostPlatformAccountAsync(input.Email);
  106 + }
  107 +
  108 + var tenantConfig = await TenantResolveHelper.ResolveTenantAsync(
  109 + _tenantStore, CurrentTenant, tenantId.Value);
  110 +
  111 + // 与 th-web-auth 对齐:开通邮箱已在主库凭据中、但租户库仍为 Seed admin 时,登录前补同步
  112 + await EnsureTenantAdminLoginSyncedAsync(tenantId.Value, input.Email.Trim());
  113 +
  114 + // 优先直连租户库查用户,避免 CurrentTenant.Change 后仓储仍连主库导致「邮箱不存在」
  115 + var user = await FindActiveUserByEmailInTenantDbAsync(tenantId.Value, input.Email.Trim())
83 116 ?? throw new UserFriendlyException("登录失败!邮箱不存在!");
84   - if (!UserPasswordHelper.VerifyPlainPassword(user, input.Password))
85   - {
86   - throw new UserFriendlyException(UserConst.Login_Error);
87   - }
  117 + if (!UserPasswordHelper.VerifyPlainPassword(user, input.Password)
  118 + && !user.JudgePassword(input.Password))
  119 + {
  120 + throw new UserFriendlyException(UserConst.Login_Error);
  121 + }
88 122  
  123 + List<UsAppBoundLocationDto> locations;
  124 + List<string> roleCodes;
  125 + using (CurrentTenant.Change(tenantId.Value, tenantConfig.Name))
  126 + {
89 127 locations = await LoadBoundLocationsAsync(user.Id);
  128 + roleCodes = await LoadUserRoleCodesAsync(user.Id);
90 129 }
91 130  
92   - var accessToken = CreateAppAccessToken(user, input.TenantId);
  131 + var accessToken = CreateAppAccessToken(user, tenantId.Value, roleCodes);
93 132 var refreshToken = _accountManager.CreateRefreshToken(user.Id);
94 133  
95 134 // 不发布 LoginEvent:事件在 UoW 完成时于「当前连接」写 LoginLog;泰额 App 校验在租户库,
... ... @@ -99,7 +138,7 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
99 138 {
100 139 Token = accessToken,
101 140 RefreshToken = refreshToken,
102   - TenantId = input.TenantId,
  141 + TenantId = tenantId.Value,
103 142 TenantName = tenantConfig.Name ?? string.Empty,
104 143 Locations = locations
105 144 };
... ... @@ -126,6 +165,95 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
126 165 }
127 166 }
128 167  
  168 + /// <summary>
  169 + /// 解析登录租户:显式 tenantId 优先;否则按邮箱反查(与 th-web-auth 对齐)。
  170 + /// </summary>
  171 + private async Task<Guid?> ResolveTenantIdForLoginAsync(ThAppLoginInputVo input)
  172 + {
  173 + if (input.TenantId.HasValue && input.TenantId.Value != Guid.Empty)
  174 + {
  175 + if (input.TenantId.Value == ThWebPlatformLoginHelper.DefaultTenantId)
  176 + {
  177 + return await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync(
  178 + _dbConnOptions,
  179 + input.Email,
  180 + Logger);
  181 + }
  182 +
  183 + return input.TenantId.Value;
  184 + }
  185 +
  186 + return await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync(
  187 + _dbConnOptions,
  188 + input.Email,
  189 + Logger);
  190 + }
  191 +
  192 + /// <summary>
  193 + /// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。
  194 + /// </summary>
  195 + private async Task EnsureNotHostPlatformAccountAsync(string email)
  196 + {
  197 + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(email))
  198 + {
  199 + return;
  200 + }
  201 +
  202 + using (CurrentTenant.Change(null))
  203 + {
  204 + var hostUser = await FindActiveUserByEmailAsync(email.Trim());
  205 + if (hostUser != null)
  206 + {
  207 + throw new UserFriendlyException(ThWebPlatformLoginHelper.PlatformAccountMustUseDefaultMessage);
  208 + }
  209 + }
  210 + }
  211 +
  212 + /// <summary>
  213 + /// 开通邮箱已写入主库凭据、但业务库仍为 Seed 的 admin 时,登录前补同步一次(与 th-web-auth 一致)。
  214 + /// </summary>
  215 + private async Task EnsureTenantAdminLoginSyncedAsync(Guid tenantId, string loginAccount)
  216 + {
  217 + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(loginAccount))
  218 + {
  219 + return;
  220 + }
  221 +
  222 + var credentialAccount = await _adminAccountBootstrapper.GetProvisionedLoginAccountAsync(tenantId);
  223 + if (string.IsNullOrWhiteSpace(credentialAccount)
  224 + || !string.Equals(credentialAccount, loginAccount.Trim(), StringComparison.OrdinalIgnoreCase))
  225 + {
  226 + return;
  227 + }
  228 +
  229 + try
  230 + {
  231 + using (CurrentTenant.Change(tenantId))
  232 + {
  233 + var existing = await FindActiveUserByEmailAsync(loginAccount);
  234 + if (existing != null)
  235 + {
  236 + await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId);
  237 + return;
  238 + }
  239 + }
  240 + }
  241 + catch (Exception ex)
  242 + {
  243 + Logger.LogWarning(ex, "租户 {TenantId} App 登录前探测用户失败,尝试同步开通邮箱", tenantId);
  244 + }
  245 +
  246 + try
  247 + {
  248 + await _adminAccountBootstrapper.ApplyLoginEmailAsync(tenantId);
  249 + await _adminAccountBootstrapper.EnsureTenantAdminRoleAsync(tenantId);
  250 + }
  251 + catch (Exception ex)
  252 + {
  253 + Logger.LogWarning(ex, "租户 {TenantId} App 登录前同步管理员邮箱失败", tenantId);
  254 + }
  255 + }
  256 +
129 257 private void ValidationImageCaptcha(string? uuid, string? code)
130 258 {
131 259 if (!_rbacOptions.EnableCaptcha)
... ... @@ -142,7 +270,9 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
142 270 private async Task<UserAggregateRoot?> FindActiveUserByEmailAsync(string email)
143 271 {
144 272 var normalized = email.Trim().ToLowerInvariant();
145   - var users = await _userRepository._DbQueryable
  273 + // Change 租户后必须用 Provider 取当前库连接;仓储可能仍缓存主库连接
  274 + var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient;
  275 + var users = await db.Queryable<UserAggregateRoot>()
146 276 .Where(u => !u.IsDeleted && u.State)
147 277 .Where(u =>
148 278 (u.Email != null && SqlFunc.ToLower(u.Email) == normalized) ||
... ... @@ -154,7 +284,39 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
154 284 ?? users.FirstOrDefault();
155 285 }
156 286  
157   - private string CreateAppAccessToken(UserAggregateRoot user, Guid tenantId)
  287 + /// <summary>
  288 + /// 直连租户业务库查用户(绕过 CurrentTenant 连接切换不稳定问题)。
  289 + /// </summary>
  290 + private async Task<UserAggregateRoot?> FindActiveUserByEmailInTenantDbAsync(Guid tenantId, string email)
  291 + {
  292 + var tenant = await ThTenantHostDataAccessor.LoadTenantAsync(_dbConnOptions, tenantId);
  293 + if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantConnectionString))
  294 + {
  295 + return null;
  296 + }
  297 +
  298 + var dbType = tenant.DbType == default
  299 + ? (_dbConnOptions.DbType ?? SqlSugar.DbType.MySql)
  300 + : tenant.DbType;
  301 + using var tenantDb = TenantBusinessDatabaseAccessor.CreateClient(
  302 + tenant.TenantConnectionString.Trim(),
  303 + dbType);
  304 + tenantDb.Ado.CommandTimeOut = 15;
  305 +
  306 + var normalized = email.Trim().ToLowerInvariant();
  307 + var users = await tenantDb.Queryable<UserAggregateRoot>()
  308 + .Where(u => !u.IsDeleted && u.State)
  309 + .Where(u =>
  310 + (u.Email != null && SqlFunc.ToLower(u.Email) == normalized) ||
  311 + SqlFunc.ToLower(u.UserName) == normalized)
  312 + .ToListAsync();
  313 + return users.FirstOrDefault(u =>
  314 + u.Email != null &&
  315 + string.Equals(u.Email.Trim(), normalized, StringComparison.OrdinalIgnoreCase))
  316 + ?? users.FirstOrDefault();
  317 + }
  318 +
  319 + private string CreateAppAccessToken(UserAggregateRoot user, Guid tenantId, IReadOnlyList<string> roleCodes)
158 320 {
159 321 var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.SecurityKey));
160 322 var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
... ... @@ -174,6 +336,21 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
174 336 claims.Add(new Claim(AbpClaimTypes.Email, user.Email));
175 337 }
176 338  
  339 + var isAdmin = roleCodes.Any(r =>
  340 + string.Equals(r, UserConst.AdminRolesCode, StringComparison.OrdinalIgnoreCase));
  341 + if (isAdmin || string.Equals(user.UserName, UserConst.Admin, StringComparison.OrdinalIgnoreCase))
  342 + {
  343 + claims.Add(new Claim(TokenTypeConst.Permission, UserConst.AdminPermissionCode));
  344 + claims.Add(new Claim(TokenTypeConst.Roles, UserConst.AdminRolesCode));
  345 + }
  346 + else
  347 + {
  348 + foreach (var role in roleCodes.Where(r => !string.IsNullOrWhiteSpace(r)).Distinct(StringComparer.OrdinalIgnoreCase))
  349 + {
  350 + claims.Add(new Claim(AbpClaimTypes.Role, role));
  351 + }
  352 + }
  353 +
177 354 var token = new JwtSecurityToken(
178 355 _jwtOptions.Issuer,
179 356 _jwtOptions.Audience,
... ... @@ -185,6 +362,29 @@ public class ThAppAuthAppService : ApplicationService, IThAppAuthAppService
185 362 return new JwtSecurityTokenHandler().WriteToken(token);
186 363 }
187 364  
  365 + private async Task<List<string>> LoadUserRoleCodesAsync(Guid userId)
  366 + {
  367 + var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient;
  368 + var rows = await db.Ado.SqlQueryAsync<RoleCodeRow>(
  369 + """
  370 + SELECT r.RoleCode
  371 + FROM UserRole ur
  372 + INNER JOIN Role r ON ur.RoleId = r.Id
  373 + WHERE ur.UserId = @UserId AND r.IsDeleted = 0 AND r.State = 1
  374 + """,
  375 + new { UserId = userId });
  376 + return rows
  377 + .Select(r => r.RoleCode?.Trim())
  378 + .Where(r => !string.IsNullOrWhiteSpace(r))
  379 + .Cast<string>()
  380 + .ToList();
  381 + }
  382 +
  383 + private sealed class RoleCodeRow
  384 + {
  385 + public string? RoleCode { get; set; }
  386 + }
  387 +
188 388 private async Task<List<UsAppBoundLocationDto>> LoadBoundLocationsAsync(Guid userId)
189 389 {
190 390 var db = (await _dbContextProvider.GetDbContextAsync()).SqlSugarClient;
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs
... ... @@ -107,7 +107,10 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService
107 107 return platformLogin;
108 108 }
109 109  
110   - var companyTenantId = await TryResolveCompanyTenantIdByLoginAsync(input.UserName);
  110 + var companyTenantId = await ThLoginTenantResolver.TryResolveCompanyTenantIdByLoginAsync(
  111 + _dbConnOptions,
  112 + input.UserName,
  113 + Logger);
111 114 if (companyTenantId.HasValue)
112 115 {
113 116 input.TenantId = companyTenantId;
... ... @@ -203,40 +206,6 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService
203 206 }
204 207  
205 208 /// <summary>
206   - /// 未选公司时:先查开通管理员凭据,再扫描各租户业务库 user(Team Member 等)。
207   - /// </summary>
208   - private async Task<Guid?> TryResolveCompanyTenantIdByLoginAsync(string? userName)
209   - {
210   - if (string.IsNullOrWhiteSpace(userName))
211   - {
212   - return null;
213   - }
214   -
215   - try
216   - {
217   - if (ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(userName))
218   - {
219   - var fromCredential = await ThTenantHostDataAccessor.FindActiveTenantIdByLoginAccountAsync(
220   - _dbConnOptions,
221   - userName);
222   - if (fromCredential.HasValue)
223   - {
224   - return fromCredential;
225   - }
226   - }
227   -
228   - return await ThTenantHostDataAccessor.FindActiveTenantIdByBusinessUserLoginAsync(
229   - _dbConnOptions,
230   - userName);
231   - }
232   - catch (Exception ex)
233   - {
234   - Logger.LogWarning(ex, "按登录账号反查公司租户失败:{UserName}", userName);
235   - return null;
236   - }
237   - }
238   -
239   - /// <summary>
240 209 /// 业务租户登录前校验:主库平台邮箱账号不得落入公司业务库。
241 210 /// 仅拦截邮箱形态(与 TryPlatformLoginAsync 一致);租户默认 UserName=admin 与主库 admin 同名时允许走租户库。
242 211 /// </summary>
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json
... ... @@ -67,7 +67,7 @@
67 67 "DbList": [ "Sqlite", "Mysql", "Sqlserver", "Oracle", "PostgreSQL" ],
68 68  
69 69 "DbConnOptions": {
70   - "Url": "server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;port=3306;database=antis-foodlabeling-host;uid=javateam;pwd=javateam2026;CharSet=utf8mb4;",
  70 + "Url": "server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;port=3306;database=antis-foodlabeling-host;uid=netteam;pwd=netteam;CharSet=utf8mb4;",
71 71 // 高权限:仅建库/授权;租户连接串仍用 FoodLabeling:TenantDatabase 的 netteam
72 72 "AdminConnectionString": "server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;port=3306;database=antis-foodlabeling-host;uid=javateam;pwd=javateam2026;CharSet=utf8mb4;",
73 73 "DbType": "MySql",
... ...
项目相关文档/培训接口文档.md
1   -# 培训接口文档(泰额版后端
  1 +# 培训接口文档(泰额版
2 2  
3 3 > 模块:培训 / 资料中心
4   -> 范围:仅泰额版后端;需使用**租户业务库**(非 `antis-foodlabeling-host`)
5   -> 认证:管理端 / APP 接口均需登录 Token(`Authorization: Bearer {token}`)
6   -> 约定:JSON 字段一般为 camelCase
  4 +> 范围:泰额版后端(租户业务库,非 `antis-foodlabeling-host`)
  5 +> 认证:`Authorization: Bearer {token}`;业务请求建议带 `__tenant: {tenantId}`
  6 +> JSON:camelCase
  7 +> Base URL 示例:`http://127.0.0.1:19002`(以实际部署为准)
  8 +> 更新日期:2026-08-07
7 9  
8 10 ---
9 11  
... ... @@ -13,11 +15,27 @@
13 15 |------|------|
14 16 | 一级分类 | `parentId` 为空 |
15 17 | 二级分类 | `parentId` 指向一级分类 Id;**仅两级** |
16   -| 培训文件 | 只能挂在**二级分类**下 |
17   -| 权限 | 落在**文件**上:Company / Region / Location,各为 `ALL` 或 `SPECIFIED` |
18   -| Region | 对应 `fl_group`(`groupId` / `regionIds`) |
19   -| Company | 对应 `fl_partner`(`partnerIds` / `companyIds` 等价) |
20   -| Location | 对应 `location` |
  18 +| 培训文件 | **只能挂在二级分类下** |
  19 +| 文件权限 | 落在**文件**上:Company / Region / Location,各为 `ALL` 或 `SPECIFIED` |
  20 +| Company | `fl_partner`;字段 `partnerIds` / `companyIds` 等价 |
  21 +| Region | `fl_group`;字段 `regionIds` / `groupIds` 等价 |
  22 +| Location | `location`;字段 `locationIds` |
  23 +
  24 +### 权限约定(推荐做法)
  25 +
  26 +**主路径:上传 / 编辑文件时直接传公司、区域、门店范围**(可传 `ALL` 或具体多值 Id)。
  27 +独立「编辑文件权限」接口仅作兼容保留,前端可不使用。
  28 +
  29 +| 维度 | 类型字段 | Id 数组 | 说明 |
  30 +|------|----------|---------|------|
  31 +| Company | `appliedPartnerType` | `partnerIds` / `companyIds` | `ALL` 或 `SPECIFIED` + Guid 列表(可含哨兵 `"ALL"`) |
  32 +| Region | `appliedRegionType` | `regionIds` / `groupIds` | 同上 |
  33 +| Location | `availabilityType`(别名 `appliedLocationType`) | `locationIds` | 同上 |
  34 +
  35 +- 上传时**不传**任何 scope 字段 → 默认三维度均为 `ALL`
  36 +- 编辑时**不传**任何 scope 字段 → **不改**原权限(仅改文件名/排序)
  37 +- 编辑时传入任一 scope 字段 → 整套权限按入参覆盖保存
  38 +- `SPECIFIED` 且仅选当前上下文「恰好全集」时,回显保持具体 Guid / 声明类型,不误折成 `["ALL"]`(与标签模板等一致)
21 39  
22 40 ### 文件存储
23 41  
... ... @@ -28,13 +46,13 @@
28 46 | 库中 `fileUrl` | `/training/{存储文件名}` |
29 47  
30 48 - 单文件最大 **20MB**
31   -- 支持扩展名:`.jpg/.jpeg/.png/.webp/.gif/.bmp`、`.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx/.txt/.csv`
  49 +- 扩展名:`.jpg/.jpeg/.png/.webp/.gif/.bmp`、`.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx/.txt/.csv`
32 50 - `fileType`:`image` / `doc` / `other`
33 51  
34   -### 建表 SQL
  52 +### 建表
35 53  
36 54 - 脚本:`module/food-labeling-us/scripts/fl_training.sql`
37   -- **在每个租户业务库执行**;新租户创建时会自动执行该脚本
  55 +- 在每个**租户业务库**执行;新租户开通时会自动执行
38 56  
39 57 ---
40 58  
... ... @@ -48,14 +66,15 @@
48 66 | 新增分类 | POST | `/api/app/training/category` |
49 67 | 编辑分类 | PUT | `/api/app/training/category/{id}` |
50 68 | 删除分类 | DELETE | `/api/app/training/category/{id}` |
51   -| 上传文件 | POST | `/api/app/training/file/upload` |
52   -| 编辑文件 | PUT | `/api/app/training/file/{id}` |
53   -| 删除文件 | DELETE | `/api/app/training/file/{id}` |
  69 +| 上传文件(含权限) | POST | `/api/app/training/file/upload` |
  70 +| 编辑文件(含权限) | PUT | `/api/app/training/{id}/file` |
  71 +| 删除文件 | DELETE | `/api/app/training/{id}/file` |
54 72 | 文件排序 | PUT | `/api/app/training/sort-files` |
55   -| 获取文件权限 | GET | `/api/app/training/file-scope/{id}` |
56   -| 设置文件权限 | PUT | `/api/app/training/file-scope/{id}` |
  73 +| 获取文件权限(兼容) | GET | `/api/app/training/file-scope/{id}` |
  74 +| 设置文件权限(兼容) | PUT | `/api/app/training/file-scope/{id}` |
57 75  
58   -> 除上传接口为显式路由外,其余为 ABP 约定路由。若与 Swagger 不一致,以 Swagger 为准。
  76 +> 上传、`file-scope` 为显式路由;其余多为 ABP 约定路由。以 Swagger 为准。
  77 +> 另有约定路径 `GET/PUT /api/app/training/{id}/file-scope`,与 `file-scope/{id}` 等价兼容。
59 78  
60 79 ### 2.2 APP `UsAppTrainingAppService`
61 80  
... ... @@ -79,36 +98,44 @@
79 98 | locationId | string | 否 | 按门店过滤可见文件;不传则不过滤权限 |
80 99 | includeFiles | bool | 否 | 是否返回文件,默认 `true` |
81 100  
82   -**响应示例**
  101 +**响应要点**:一级 `children` 为二级;二级 `files` 为 `TrainingFileDto`(含完整 scope 回显字段)。
83 102  
84 103 ```json
85 104 [
86 105 {
87 106 "id": "1001",
88   - "categoryName": "Training",
  107 + "categoryName": "分类1",
89 108 "parentId": null,
90 109 "orderNum": 100,
91 110 "children": [
92 111 {
93 112 "id": "1002",
94   - "categoryName": "BOH",
  113 + "categoryName": "子1",
95 114 "parentId": "1001",
96 115 "orderNum": 90,
97 116 "children": [],
98 117 "files": [
99 118 {
100   - "id": "2001",
  119 + "id": "23148558702612480",
101 120 "categoryId": "1002",
102   - "fileName": "手册.pdf",
103   - "fileUrl": "/training/20260804120000_123.pdf",
  121 + "fileName": "学情测评试题.pdf",
  122 + "fileUrl": "/training/20260807120000_xxx.pdf",
104 123 "fileType": "doc",
105 124 "fileSize": 102400,
106 125 "orderNum": 100,
107 126 "appliedPartnerType": "ALL",
108   - "appliedRegionType": "ALL",
109   - "availabilityType": "ALL",
110   - "creationTime": "2026-08-04T12:00:00",
111   - "lastModificationTime": "2026-08-04T12:00:00"
  127 + "company": "All Companies",
  128 + "partnerIds": ["ALL"],
  129 + "companyIds": ["ALL"],
  130 + "appliedRegionType": "SPECIFIED",
  131 + "region": "武侯区",
  132 + "regionIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"],
  133 + "groupIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"],
  134 + "availabilityType": "SPECIFIED",
  135 + "location": "分店2",
  136 + "locationIds": ["3a22e2f6-e944-2038-ffd8-fc82d9d388ac"],
  137 + "creationTime": "2026-08-07T12:00:00",
  138 + "lastModificationTime": "2026-08-07T12:00:00"
112 139 }
113 140 ]
114 141 }
... ... @@ -124,8 +151,6 @@
124 151  
125 152 `POST /api/app/training/category`
126 153  
127   -**Body**
128   -
129 154 ```json
130 155 {
131 156 "categoryName": "Training",
... ... @@ -136,27 +161,13 @@
136 161  
137 162 | 字段 | 说明 |
138 163 |------|------|
139   -| categoryName | 分类名称,同级不可重名 |
140   -| parentId | 空/不传 = 一级;传一级 Id = 二级 |
141   -| orderNum | 排序,数值越大越靠前(按实现排序) |
  164 +| categoryName | 必填;同级不可重名 |
  165 +| parentId | 空 = 一级;传一级 Id = 二级 |
  166 +| orderNum | 排序 |
142 167  
143   -**响应**:`TrainingCategoryGetOutputDto`
144   -
145   -```json
146   -{
147   - "id": "1001",
148   - "categoryName": "Training",
149   - "parentId": null,
150   - "orderNum": 100,
151   - "creationTime": "2026-08-04T12:00:00",
152   - "lastModificationTime": "2026-08-04T12:00:00"
153   -}
154   -```
  168 +**响应**:`TrainingCategoryGetOutputDto`(`id` / `categoryName` / `parentId` / `orderNum` / 时间字段)
155 169  
156   -**常见错误**
157   -- 分类名称为空
158   -- 父级不存在 / 在二级下再建子级
159   -- 同级名称已存在
  170 +常见错误:名称为空、父级不存在、在二级下再建子级、同级重名。
160 171  
161 172 ---
162 173  
... ... @@ -164,8 +175,6 @@
164 175  
165 176 `PUT /api/app/training/category/{id}`
166 177  
167   -**Body**
168   -
169 178 ```json
170 179 {
171 180 "categoryName": "Training(更新)",
... ... @@ -173,7 +182,7 @@
173 182 }
174 183 ```
175 184  
176   -> 不可通过本接口改层级(`parentId` 不可改)。
  185 +不可改层级(`parentId` 不可改)。
177 186  
178 187 ---
179 188  
... ... @@ -181,13 +190,11 @@
181 190  
182 191 `DELETE /api/app/training/category/{id}`
183 192  
184   -软删除。规则:
185   -- 一级下仍有二级 → 不可删
186   -- 二级下仍有文件 → 不可删
  193 +软删除。规则:一级下仍有二级不可删;二级下仍有文件不可删。
187 194  
188 195 ---
189 196  
190   -### 3.5 上传文件
  197 +### 3.5 上传文件(主路径,含权限)
191 198  
192 199 `POST /api/app/training/file/upload`
193 200 `Content-Type: multipart/form-data`
... ... @@ -197,43 +204,96 @@
197 204 | file | file | 是 | 文件本体 |
198 205 | categoryId | string | 是 | **二级**分类 Id |
199 206 | orderNum | int | 否 | 排序 |
  207 +| appliedPartnerType | string | 否 | `ALL` / `SPECIFIED` |
  208 +| partnerIds | string[] | 否 | 可重复传多个 form 字段;可含 `ALL` |
  209 +| companyIds | string[] | 否 | 同 partnerIds |
  210 +| appliedRegionType | string | 否 | `ALL` / `SPECIFIED` |
  211 +| regionIds | string[] | 否 | 可含 `ALL` |
  212 +| groupIds | string[] | 否 | 同 regionIds |
  213 +| availabilityType | string | 否 | Location:`ALL` / `SPECIFIED` |
  214 +| appliedLocationType | string | 否 | `availabilityType` 别名 |
  215 +| locationIds | string[] | 否 | 可含 `ALL` |
  216 +
  217 +**curl 示例(全 ALL)**
200 218  
201   -**响应**
  219 +```bash
  220 +curl -X POST "http://127.0.0.1:19002/api/app/training/file/upload" \
  221 + -H "Authorization: Bearer <token>" \
  222 + -H "__tenant: <tenantId>" \
  223 + -F "file=@./手册.pdf" \
  224 + -F "categoryId=<二级分类Id>" \
  225 + -F "orderNum=100" \
  226 + -F "appliedPartnerType=ALL" \
  227 + -F "partnerIds=ALL" \
  228 + -F "appliedRegionType=ALL" \
  229 + -F "regionIds=ALL" \
  230 + -F "availabilityType=ALL" \
  231 + -F "locationIds=ALL"
  232 +```
202 233  
203   -```json
204   -{
205   - "id": "2001",
206   - "fileName": "手册.pdf",
207   - "fileUrl": "/training/20260804120000_123.pdf",
208   - "fileType": "doc",
209   - "fileSize": 102400
210   -}
  234 +**curl 示例(指定公司 + 门店)**
  235 +
  236 +```bash
  237 +curl -X POST "http://127.0.0.1:19002/api/app/training/file/upload" \
  238 + -H "Authorization: Bearer <token>" \
  239 + -H "__tenant: <tenantId>" \
  240 + -F "file=@./手册.pdf" \
  241 + -F "categoryId=<二级分类Id>" \
  242 + -F "appliedPartnerType=SPECIFIED" \
  243 + -F "partnerIds=<partnerGuid>" \
  244 + -F "appliedRegionType=SPECIFIED" \
  245 + -F "regionIds=<groupGuid>" \
  246 + -F "availabilityType=SPECIFIED" \
  247 + -F "locationIds=<locationGuid1>" \
  248 + -F "locationIds=<locationGuid2>"
211 249 ```
212 250  
213   -> 新上传文件默认权限:Company / Region / Location 均为 `ALL`
  251 +**响应**:完整 `TrainingFileDto`(含 scope 回显,结构见 3.1)
214 252  
215 253 ---
216 254  
217   -### 3.6 编辑文件元数据
  255 +### 3.6 编辑文件(主路径,含权限)
218 256  
219   -`PUT /api/app/training/file/{id}`
  257 +`PUT /api/app/training/{id}/file`
220 258  
221 259 ```json
222 260 {
223 261 "fileName": "操作手册.pdf",
224   - "orderNum": 100
  262 + "orderNum": 100,
  263 + "appliedPartnerType": "SPECIFIED",
  264 + "partnerIds": ["3a22e2f5-a785-ba39-9847-de45bdd47e50"],
  265 + "companyIds": ["3a22e2f5-a785-ba39-9847-de45bdd47e50"],
  266 + "appliedRegionType": "ALL",
  267 + "regionIds": ["ALL"],
  268 + "groupIds": ["ALL"],
  269 + "availabilityType": "SPECIFIED",
  270 + "locationIds": ["3a22e2f6-e944-2038-ffd8-fc82d9d388ac"]
225 271 }
226 272 ```
227 273  
228   -不更换物理文件,仅改展示名与排序。
  274 +| 字段 | 说明 |
  275 +|------|------|
  276 +| fileName | 必填;展示名(不换物理文件) |
  277 +| orderNum | 排序 |
  278 +| scope 各字段 | 见第 1 节;**只要传了任一 scope 字段即整套覆盖**;全不传则保持原权限 |
  279 +
  280 +**响应**:完整 `TrainingFileDto`。
  281 +
  282 +```bash
  283 +curl -X PUT "http://127.0.0.1:19002/api/app/training/<fileId>/file" \
  284 + -H "Authorization: Bearer <token>" \
  285 + -H "__tenant: <tenantId>" \
  286 + -H "Content-Type: application/json" \
  287 + -d "{\"fileName\":\"学情测评试题.pdf\",\"orderNum\":100,\"appliedPartnerType\":\"ALL\",\"partnerIds\":[\"ALL\"],\"appliedRegionType\":\"ALL\",\"availabilityType\":\"ALL\",\"locationIds\":[\"ALL\"]}"
  288 +```
229 289  
230 290 ---
231 291  
232 292 ### 3.7 删除文件
233 293  
234   -`DELETE /api/app/training/file/{id}`
  294 +`DELETE /api/app/training/{id}/file`
235 295  
236   -软删除,并清理权限关联行。
  296 +软删除,并清理 scope 关联行。
237 297  
238 298 ---
239 299  
... ... @@ -244,156 +304,139 @@
244 304 ```json
245 305 {
246 306 "items": [
247   - { "id": "2001", "orderNum": 100 },
248   - { "id": "2002", "orderNum": 90 }
  307 + { "id": "23148558702612480", "orderNum": 100 },
  308 + { "id": "23148558702612481", "orderNum": 90 }
249 309 ]
250 310 }
251 311 ```
252 312  
253 313 ---
254 314  
255   -### 3.9 获取文件权限
  315 +### 3.9 获取 / 设置文件权限(兼容,非推荐主路径)
256 316  
257   -`GET /api/app/training/file-scope/{id}`
  317 +| 方法 | 路由 |
  318 +|------|------|
  319 +| GET | `/api/app/training/file-scope/{id}` |
  320 +| PUT | `/api/app/training/file-scope/{id}` |
  321 +
  322 +**推荐**:权限在 **上传 / 编辑文件** 中一并提交,不必单独调本接口。
  323 +本接口与 create/update 共用同一套 scope 保存逻辑;PUT body 与 scope 字段相同(无 `fileName`)。
258 324  
259   -**响应示例**
  325 +**GET 响应**(`TrainingFileScopeOutputDto`)
260 326  
261 327 ```json
262 328 {
263 329 "appliedPartnerType": "SPECIFIED",
264   - "company": "公司A",
265   - "partnerIds": ["p1"],
266   - "companyIds": ["p1"],
267   - "appliedRegionType": "ALL",
268   - "region": "All Regions",
269   - "regionIds": [],
270   - "groupIds": [],
  330 + "company": "成都分店",
  331 + "partnerIds": ["3a22e2f5-a785-ba39-9847-de45bdd47e50"],
  332 + "companyIds": ["3a22e2f5-a785-ba39-9847-de45bdd47e50"],
  333 + "appliedRegionType": "SPECIFIED",
  334 + "region": "武侯区",
  335 + "regionIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"],
  336 + "groupIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"],
271 337 "availabilityType": "SPECIFIED",
272   - "location": "门店1",
273   - "locationIds": ["loc1"]
  338 + "location": "分店2",
  339 + "locationIds": ["3a22e2f6-e944-2038-ffd8-fc82d9d388ac"]
274 340 }
275 341 ```
276 342  
277   ----
278   -
279   -### 3.10 设置文件权限
280   -
281   -`PUT /api/app/training/file-scope/{id}`
  343 +**PUT body 示例**
282 344  
283 345 ```json
284 346 {
285   - "appliedPartnerType": "SPECIFIED",
286   - "partnerIds": ["p1"],
287   - "companyIds": ["p1"],
288   - "appliedRegionType": "ALL",
289   - "regionIds": [],
290   - "groupIds": [],
  347 + "appliedPartnerType": "ALL",
  348 + "partnerIds": ["ALL"],
  349 + "appliedRegionType": "SPECIFIED",
  350 + "regionIds": ["3a22e2f6-0679-9a5b-19b4-fc6db2d92dcc"],
291 351 "availabilityType": "SPECIFIED",
292   - "locationIds": ["loc1"]
  352 + "locationIds": ["3a22e2f6-e944-2038-ffd8-fc82d9d388ac"]
293 353 }
294 354 ```
295 355  
296   -| 字段 | 说明 |
297   -|------|------|
298   -| appliedPartnerType | `ALL` / `SPECIFIED` |
299   -| partnerIds / companyIds | 公司 Id,二者等价,合并去重 |
300   -| appliedRegionType | `ALL` / `SPECIFIED` |
301   -| regionIds / groupIds | 区域 Id(`fl_group.Id`),二者等价 |
302   -| availabilityType | `ALL` / `SPECIFIED`(门店维度) |
303   -| locationIds | 门店 Id |
304   -
305   -**过滤规则(APP / 带 locationId 的树)**
306   -三个维度均为:`ALL` **或** `SPECIFIED` 且命中该门店对应的公司 / 区域 / 门店;三维度 **同时满足** 才可见。
307   -
308 356 ---
309 357  
310   -## 4. APP 接口详情
  358 +## 4. APP 接口
311 359  
312   -### 4.1 按门店获取可见分类树
  360 +### 4.1 门店可见分类树
313 361  
314 362 `GET /api/app/us-app-training/tree`
315 363  
316   -**Query**
317   -
318 364 | 参数 | 类型 | 必填 | 说明 |
319 365 |------|------|------|------|
320   -| locationId | string | 是 | 当前门店 Id |
  366 +| locationId | string | 是 | 当前门店 Id;校验用户可访问该门店 |
321 367 | keyword | string | 否 | 匹配分类名或文件名 |
322 368  
323   -**说明**
324   -- 校验当前用户是否可访问该门店
325   -- 仅返回对当前门店有权限的文件;无可见文件的空分支会按实现裁剪
326   -- 响应结构与管理端分类树相同:`List<TrainingCategoryTreeNodeDto>`
327   -
328   ----
329   -
330   -## 5. 公共 DTO 字段说明
  369 +按门店过滤文件:Company / Region / Location 三维度 `ALL` 或 `SPECIFIED` 命中该门店才可见。
  370 +响应结构同管理端分类树(`includeFiles=true`)。
331 371  
332   -### TrainingCategoryTreeNodeDto
  372 +```bash
  373 +curl -G "http://127.0.0.1:19002/api/app/us-app-training/tree" \
  374 + --data-urlencode "locationId=<locationGuid>" \
  375 + -H "Authorization: Bearer <token>" \
  376 + -H "__tenant: <tenantId>"
  377 +```
333 378  
334   -| 字段 | 类型 | 说明 |
335   -|------|------|------|
336   -| id | string | 分类 Id |
337   -| categoryName | string | 名称 |
338   -| parentId | string? | 父级;一级为 null |
339   -| orderNum | int | 排序 |
340   -| children | array | 子分类 |
341   -| files | array | 文件列表(一般挂在二级) |
  379 +---
342 380  
343   -### TrainingFileDto
  381 +## 5. TrainingFileDto 字段说明
344 382  
345   -| 字段 | 类型 | 说明 |
346   -|------|------|------|
347   -| id | string | 文件 Id |
348   -| categoryId | string | 二级分类 Id |
349   -| fileName | string | 展示文件名 |
350   -| fileUrl | string | 相对路径,如 `/training/xxx.pdf` |
351   -| fileType | string | `image` / `doc` / `other` |
352   -| fileSize | long | 字节 |
353   -| orderNum | int | 排序 |
354   -| appliedPartnerType | string | ALL / SPECIFIED |
355   -| appliedRegionType | string | ALL / SPECIFIED |
356   -| availabilityType | string | ALL / SPECIFIED |
357   -| creationTime | datetime | 创建时间 |
358   -| lastModificationTime | datetime? | 最后修改时间 |
  383 +| 字段 | 说明 |
  384 +|------|------|
  385 +| id | 文件 Id |
  386 +| categoryId | 所属二级分类 |
  387 +| fileName / fileUrl / fileType / fileSize | 文件信息 |
  388 +| orderNum | 排序 |
  389 +| appliedPartnerType | Company:`ALL` / `SPECIFIED` |
  390 +| company | Company 展示文案 |
  391 +| partnerIds / companyIds | Company Id 列表(可含 `ALL`) |
  392 +| appliedRegionType | Region:`ALL` / `SPECIFIED` |
  393 +| region | Region 展示文案 |
  394 +| regionIds / groupIds | Region Id 列表 |
  395 +| availabilityType | Location:`ALL` / `SPECIFIED` |
  396 +| location | Location 展示文案 |
  397 +| locationIds | 门店 Id 列表 |
  398 +| creationTime / lastModificationTime | 时间 |
359 399  
360 400 ---
361 401  
362   -## 6. curl 示例
  402 +## 6. 联调检查清单
363 403  
364   -```bash
365   -# 分类树
366   -curl -G "{{baseUrl}}/api/app/training/category-tree" \
367   - -H "Authorization: Bearer {{token}}" \
368   - --data-urlencode "includeFiles=true"
369   -
370   -# 新增一级分类
371   -curl -X POST "{{baseUrl}}/api/app/training/category" \
372   - -H "Authorization: Bearer {{token}}" \
373   - -H "Content-Type: application/json" \
374   - -d '{"categoryName":"Training","parentId":null,"orderNum":100}'
  404 +- [ ] 分类仅两级;文件只能挂二级
  405 +- [ ] 上传可不传 scope → 默认三维度 ALL
  406 +- [ ] 上传/编辑传 `ALL` 或具体多值 Guid → 库表 type 字段 + 关联表正确
  407 +- [ ] 分类树 `files[]` 含完整 scope 回显
  408 +- [ ] `PUT .../file` 不传 scope 时不改权限;传了则覆盖
  409 +- [ ] `GET/PUT /api/app/training/file-scope/{id}` 不再 404(兼容路径)
  410 +- [ ] APP `us-app-training/tree` 按 locationId 过滤可见文件
  411 +- [ ] 文件 ≤20MB、扩展名合法
375 412  
376   -# 上传文件
377   -curl -X POST "{{baseUrl}}/api/app/training/file/upload" \
378   - -H "Authorization: Bearer {{token}}" \
379   - -F "file=@./手册.pdf" \
380   - -F "categoryId=1002" \
381   - -F "orderNum=100"
  413 +查库(租户业务库):
  414 +
  415 +```sql
  416 +SELECT Id, FileName, AppliedPartnerType, AppliedRegionType, AvailabilityType
  417 +FROM fl_training_file WHERE IsDeleted = 0;
382 418  
383   -# APP 门店树
384   -curl -G "{{baseUrl}}/api/app/us-app-training/tree" \
385   - -H "Authorization: Bearer {{token}}" \
386   - --data-urlencode "locationId=loc1"
  419 +SELECT * FROM fl_training_file_partner WHERE TrainingFileId = '<fileId>';
  420 +SELECT * FROM fl_training_file_region WHERE TrainingFileId = '<fileId>';
  421 +SELECT * FROM fl_training_file_location WHERE TrainingFileId = '<fileId>';
387 422 ```
388 423  
389 424 ---
390 425  
391 426 ## 7. 相关代码
392 427  
393   -| 项 | 路径 |
394   -|----|------|
  428 +| 说明 | 路径 |
  429 +|------|------|
395 430 | 管理端服务 | `FoodLabeling.Application/Services/TrainingAppService.cs` |
396 431 | APP 服务 | `FoodLabeling.Application/Services/UsAppTrainingAppService.cs` |
397   -| 权限 Helper | `FoodLabeling.Application/Helpers/TrainingFileScopeHelper.cs` |
398   -| SQL | `module/food-labeling-us/scripts/fl_training.sql` |
399   -| 新租户迁移 | `FoodLabelingTenantMigrationScriptNames.Training` |
  432 +| Scope 辅助 | `FoodLabeling.Application/Helpers/TrainingFileScopeHelper.cs` |
  433 +| 建表脚本 | `module/food-labeling-us/scripts/fl_training.sql` |
  434 +
  435 +---
  436 +
  437 +## 8. 变更记录
  438 +
  439 +| 日期 | 说明 |
  440 +|------|------|
  441 +| 2026-08-07 | 重写文档:权限并入上传/编辑;file-scope 改为兼容路径;补全 TrainingFileDto scope 回显与 curl 示例 |
  442 +| (历史) | 初版:分类树、文件 CRUD、独立 file-scope、APP tree |
... ...