Commit aa01fd36272d7b1baa032b7cb695af1876b63cf7
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
泰额版/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 | | ... | ... |