Commit acf259d52f25691ed733e95dc0d8db0fbf0ba5b2

Authored by 李曜臣
1 parent 899b71eb

2026-07-24

Showing 40 changed files with 3004 additions and 84 deletions
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/AuthSessionAppService.cs
... ... @@ -40,7 +40,12 @@ public class AuthSessionAppService : ApplicationService, IAuthSessionAppService
40 40 /// <inheritdoc />
41 41 public virtual async Task<CurrentUserMenuPermissionsOutputDto> GetMyMenusAsync()
42 42 {
43   - TenantContextGuard.EnsureTenantResolved(CurrentTenant, "获取菜单权限");
  43 + // 平台 Token(JWT 无 TenantId)读主库;公司 Token 须已解析租户上下文
  44 + if (CurrentTenant.Id.HasValue)
  45 + {
  46 + TenantContextGuard.EnsureTenantResolved(CurrentTenant, "获取菜单权限");
  47 + }
  48 +
44 49 if (!CurrentUser.Id.HasValue)
45 50 {
46 51 throw new UserFriendlyException("用户未登录");
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application.Contracts/Dtos/MultiTenancy/ThTenantSelectDto.cs
1 1 namespace FoodLabeling.Th.Application.Contracts.Dtos.MultiTenancy;
2 2  
3 3 /// <summary>
4   -/// 登录页租户下拉项(不含租户 Id 与连接串)
  4 +/// 登录页租户下拉项(含 Id 供 H5 传 tenantId;不含连接串)
5 5 /// </summary>
6 6 public class ThTenantSelectDto
7 7 {
  8 + /// <summary>租户 Id(登录 th-web-auth/login 时作为 tenantId)</summary>
  9 + public Guid Id { get; set; }
  10 +
8 11 /// <summary>租户名称</summary>
9 12 public string Name { get; set; } = string.Empty;
10 13  
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/MultiTenancy/ThWebPlatformLoginHelper.cs 0 → 100644
  1 +namespace FoodLabeling.Th.Application.MultiTenancy;
  2 +
  3 +/// <summary>
  4 +/// 泰额 H5 同一登录框:Default / 空租户走主库平台登录;具体公司 Guid 走租户业务库。
  5 +/// </summary>
  6 +public static class ThWebPlatformLoginHelper
  7 +{
  8 + /// <summary>主库 Default 租户 Id(业务库可能指向 US,平台登录不得落入该库)</summary>
  9 + public static readonly Guid DefaultTenantId = Guid.Parse("11111111-1111-1111-1111-111111111111");
  10 +
  11 + /// <summary>Default 租户显示名</summary>
  12 + public const string DefaultTenantName = "Default";
  13 +
  14 + /// <summary>平台登录成功时 th-web-auth 返回的 tenantName(无 TenantId Claim)</summary>
  15 + public const string PlatformTenantDisplayName = "Platform";
  16 +
  17 + /// <summary>
  18 + /// 是否应优先尝试主库平台登录(与前端选 Default / 空 tenantId 一致)
  19 + /// </summary>
  20 + public static bool ShouldTryPlatformLogin(Guid? tenantId, string? tenantName)
  21 + {
  22 + if (!tenantId.HasValue || tenantId.Value == Guid.Empty)
  23 + {
  24 + return true;
  25 + }
  26 +
  27 + if (tenantId.Value == DefaultTenantId)
  28 + {
  29 + return true;
  30 + }
  31 +
  32 + if (!string.IsNullOrWhiteSpace(tenantName)
  33 + && string.Equals(tenantName.Trim(), DefaultTenantName, StringComparison.OrdinalIgnoreCase))
  34 + {
  35 + return true;
  36 + }
  37 +
  38 + return false;
  39 + }
  40 +
  41 + /// <summary>
  42 + /// 平台账号须为邮箱(与 account/login 一致)
  43 + /// </summary>
  44 + public static bool IsPlausiblePlatformEmail(string userName)
  45 + {
  46 + var email = userName.Trim();
  47 + return email.Contains('@')
  48 + && !email.StartsWith('@')
  49 + && !email.EndsWith('@');
  50 + }
  51 +}
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs
... ... @@ -83,12 +83,13 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe
83 83 /// 获取登录页可选租户列表(含默认管理员加密凭据)
84 84 /// </summary>
85 85 /// <remarks>
86   - /// 供 Web 登录页租户下拉使用;匿名可访问。不返回租户 Id 与数据库连接串
  86 + /// 供 Web 登录页租户下拉使用;匿名可访问。返回租户 Id 与名称(不含数据库连接串)
87 87 ///
88 88 /// 示例响应:
89 89 /// ```json
90 90 /// [
91 91 /// {
  92 + /// "id": "11111111-1111-1111-1111-111111111111",
92 93 /// "name": "Default",
93 94 /// "creationTime": "2026-01-01T08:00:00",
94 95 /// "loginAccount": "admin",
... ... @@ -99,6 +100,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe
99 100 /// ```
100 101 ///
101 102 /// 字段说明:
  103 + /// - id: 租户 Id,登录 th-web-auth/login 时传 tenantId;选 Default 且用平台邮箱则走主库平台登录
102 104 /// - name: 租户名称,登录时可传 tenantName
103 105 /// - creationTime: 租户创建时间
104 106 /// - loginAccount: 管理员登录账号,优先读凭据表,否则为 admin
... ... @@ -126,6 +128,7 @@ public class ThMultiTenancyAppService : ApplicationService, IThMultiTenancyAppSe
126 128 var resolved = ResolveEncryptedCredential(credential);
127 129 return new ThTenantSelectDto
128 130 {
  131 + Id = x.Id,
129 132 Name = x.Name,
130 133 CreationTime = x.CreationTime,
131 134 LoginAccount = resolved.LoginAccount,
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs
... ... @@ -48,14 +48,15 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService
48 48 /// <remarks>
49 49 /// 校验租户后在租户独立库验证账号,签发含 TenantId 与 RBAC 权限的 JWT。
50 50 ///
51   - /// 示例请求(tenant-select 不再返回 Id 时使用 tenantName):
  51 + /// 泰额 H5 与公司 Web 共用此接口。选 **Default** 或 tenantId 为空时,若邮箱账号存在于主库则走**平台登录**(JWT 无 TenantId);
  52 + /// 选具体公司 tenantId 时在该公司业务库校验。Default 业务库(如 US)仅在主库无该邮箱时作为回落。
  53 + ///
  54 + /// 示例请求(平台,选 Default):
52 55 /// ```json
53 56 /// {
54   - /// "tenantName": "Default",
55   - /// "userName": "admin",
56   - /// "password": "123456",
57   - /// "uuid": null,
58   - /// "code": null
  57 + /// "tenantId": "11111111-1111-1111-1111-111111111111",
  58 + /// "userName": "admin@example.com",
  59 + /// "password": "123456"
59 60 /// }
60 61 /// ```
61 62 ///
... ... @@ -82,6 +83,21 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService
82 83  
83 84 ValidationImageCaptcha(input.Uuid, input.Code);
84 85  
  86 + // H5 登录页选 Default / 空 tenantId:优先主库平台账号(admin@example.com),避免误入 Default 业务库(如 US)
  87 + if (ThWebPlatformLoginHelper.ShouldTryPlatformLogin(input.TenantId, input.TenantName))
  88 + {
  89 + var platformLogin = await TryPlatformLoginAsync(input);
  90 + if (platformLogin != null)
  91 + {
  92 + return platformLogin;
  93 + }
  94 +
  95 + if (!input.TenantId.HasValue || input.TenantId.Value == Guid.Empty)
  96 + {
  97 + input.TenantId = ThWebPlatformLoginHelper.DefaultTenantId;
  98 + }
  99 + }
  100 +
85 101 TenantConfiguration tenantConfig;
86 102 try
87 103 {
... ... @@ -140,6 +156,43 @@ public class ThWebAuthAppService : ApplicationService, IThWebAuthAppService
140 156 }
141 157 }
142 158  
  159 + /// <summary>
  160 + /// 主库平台登录:CurrentTenant 为空签发无 TenantId 的 JWT,供 Vue3Router/vben5 使用。
  161 + /// 主库无对应邮箱用户时返回 null,由调用方回落 Default/公司租户库登录。
  162 + /// </summary>
  163 + private async Task<ThWebLoginOutputDto?> TryPlatformLoginAsync(ThWebLoginInputVo input)
  164 + {
  165 + if (!ThWebPlatformLoginHelper.IsPlausiblePlatformEmail(input.UserName))
  166 + {
  167 + return null;
  168 + }
  169 +
  170 + using (CurrentTenant.Change(null))
  171 + {
  172 + var user = await FindActiveUserByEmailAsync(input.UserName.Trim());
  173 + if (user == null)
  174 + {
  175 + return null;
  176 + }
  177 +
  178 + if (!user.JudgePassword(input.Password))
  179 + {
  180 + throw new UserFriendlyException("登录失败:账号或密码错误");
  181 + }
  182 +
  183 + var accessToken = await _accountManager.GetTokenByUserIdAsync(user.Id);
  184 + var refreshToken = _accountManager.CreateRefreshToken(user.Id);
  185 +
  186 + return new ThWebLoginOutputDto
  187 + {
  188 + Token = accessToken,
  189 + RefreshToken = refreshToken,
  190 + TenantId = Guid.Empty,
  191 + TenantName = ThWebPlatformLoginHelper.PlatformTenantDisplayName
  192 + };
  193 + }
  194 + }
  195 +
143 196 private void ValidationImageCaptcha(string? uuid, string? code)
144 197 {
145 198 if (!_rbacOptions.EnableCaptcha)
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/rbac/Yi.Framework.Rbac.Application/Services/AccountService.cs
... ... @@ -514,9 +514,16 @@ namespace Yi.Framework.Rbac.Application.Services
514 514 }
515 515 else if ( routerType == "vben5")
516 516 {
517   - //将后端菜单转换成前端路由,组件级别需要过滤
518   - output =
519   - ObjectMapper.Map<List<MenuDto>, List<MenuAggregateRoot>>(menus.Where(x=>x.MenuSource==MenuSourceEnum.Vben5).ToList()).Vben5RouterBuild();
  517 + // 泰额 H5 拉 vben5 路由;主库平台菜单多为 MenuSource=Ruoyi,空则回退 Ruoyi 再 Vben5 构建
  518 + var vben5Menus = menus.Where(x => x.MenuSource == MenuSourceEnum.Vben5).ToList();
  519 + if (vben5Menus.Count == 0)
  520 + {
  521 + vben5Menus = menus.Where(x => x.MenuSource == MenuSourceEnum.Ruoyi).ToList();
  522 + }
  523 +
  524 + output = ObjectMapper
  525 + .Map<List<MenuDto>, List<MenuAggregateRoot>>(vben5Menus)
  526 + .Vben5RouterBuild();
520 527 }
521 528  
522 529 return output;
... ...
泰额版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json
... ... @@ -20,7 +20,7 @@
20 20 },
21 21 //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049)
22 22 "App": {
23   - "SelfUrl": "http://192.168.31.248:19002",
  23 + "SelfUrl": "http://192.168.31.88:19002",
24 24 "CorsOrigins": "http://localhost:19002;http://localhost:18000;http://localhost:5666;http://localhost:5173;http://localhost:5174;http://localhost:3000;http://127.0.0.1:19002"
25 25 },
26 26 //配置
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupBatchImportOnlineInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Group;
  2 +
  3 +/// <summary>
  4 +/// Region(Group)JSON 在线批量导入请求体
  5 +/// </summary>
  6 +public class GroupBatchImportOnlineInputVo
  7 +{
  8 + /// <summary>
  9 + /// 待导入行,每元素与单条 <c>POST /api/app/group</c> 的 <see cref="GroupCreateInputVo"/> 一致
  10 + /// </summary>
  11 + public List<GroupCreateInputVo> Items { get; set; } = new();
  12 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Group/GroupBatchImportOnlineResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Group;
  2 +
  3 +/// <summary>
  4 +/// Region(Group)JSON 在线批量导入结果
  5 +/// </summary>
  6 +public class GroupBatchImportOnlineResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public List<GroupBatchImportOnlineErrorDto> Errors { get; set; } = new();
  13 +}
  14 +
  15 +/// <summary>
  16 +/// Region(Group)JSON 在线批量导入单条失败信息
  17 +/// </summary>
  18 +public class GroupBatchImportOnlineErrorDto
  19 +{
  20 + /// <summary>
  21 + /// 在请求 <c>items</c> 数组中的序号(从 0 开始)
  22 + /// </summary>
  23 + public int Index { get; set; }
  24 +
  25 + /// <summary>
  26 + /// Region 名称(<c>groupName</c>),便于定位失败行
  27 + /// </summary>
  28 + public string? GroupName { get; set; }
  29 +
  30 + public string Message { get; set; } = string.Empty;
  31 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportOnlineInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Location;
  2 +
  3 +/// <summary>
  4 +/// 门店 JSON 在线批量导入请求体
  5 +/// </summary>
  6 +public class LocationBatchImportOnlineInputVo
  7 +{
  8 + /// <summary>
  9 + /// 待导入行,每元素与单条 <c>POST /api/app/location</c> 的 <see cref="LocationCreateInputVo"/> 一致
  10 + /// </summary>
  11 + public List<LocationCreateInputVo> Items { get; set; } = new();
  12 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Location/LocationBatchImportOnlineResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Location;
  2 +
  3 +/// <summary>
  4 +/// 门店 JSON 在线批量导入结果
  5 +/// </summary>
  6 +public class LocationBatchImportOnlineResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public List<LocationBatchImportOnlineErrorDto> Errors { get; set; } = new();
  13 +}
  14 +
  15 +/// <summary>
  16 +/// 门店 JSON 在线批量导入单条失败信息
  17 +/// </summary>
  18 +public class LocationBatchImportOnlineErrorDto
  19 +{
  20 + /// <summary>
  21 + /// 在请求 <c>items</c> 数组中的序号(从 0 开始)
  22 + /// </summary>
  23 + public int Index { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 门店编码(<c>locationCode</c>),便于定位失败行
  27 + /// </summary>
  28 + public string? LocationCode { get; set; }
  29 +
  30 + public string Message { get; set; } = string.Empty;
  31 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Product;
  2 +
  3 +/// <summary>
  4 +/// 产品 JSON 在线批量导入请求体
  5 +/// </summary>
  6 +public class ProductBatchImportOnlineInputVo
  7 +{
  8 + /// <summary>
  9 + /// 待导入行,每元素与单条 <c>POST /api/app/product</c> 的 <see cref="ProductCreateInputVo"/> 一致
  10 + /// </summary>
  11 + public List<ProductCreateInputVo> Items { get; set; } = new();
  12 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductBatchImportOnlineResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.Product;
  2 +
  3 +/// <summary>
  4 +/// 产品 JSON 在线批量导入结果
  5 +/// </summary>
  6 +public class ProductBatchImportOnlineResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public List<ProductBatchImportOnlineErrorDto> Errors { get; set; } = new();
  13 +}
  14 +
  15 +/// <summary>
  16 +/// 产品 JSON 在线批量导入单条失败信息
  17 +/// </summary>
  18 +public class ProductBatchImportOnlineErrorDto
  19 +{
  20 + /// <summary>
  21 + /// 在请求 <c>items</c> 数组中的序号(从 0 开始)
  22 + /// </summary>
  23 + public int Index { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 产品名称(<c>productName</c>),便于定位失败行
  27 + /// </summary>
  28 + public string? ProductName { get; set; }
  29 +
  30 + public string Message { get; set; } = string.Empty;
  31 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +/// <summary>
  4 +/// 成员 JSON 在线批量导入请求体
  5 +/// </summary>
  6 +public class TeamMemberBatchImportOnlineInputVo
  7 +{
  8 + /// <summary>
  9 + /// 待导入行,每元素与单条 <c>POST /api/app/team-member</c> 的 <see cref="TeamMemberCreateInputVo"/> 一致
  10 + /// </summary>
  11 + public List<TeamMemberCreateInputVo> Items { get; set; } = new();
  12 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberBatchImportOnlineResultDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +/// <summary>
  4 +/// 成员 JSON 在线批量导入结果
  5 +/// </summary>
  6 +public class TeamMemberBatchImportOnlineResultDto
  7 +{
  8 + public int SuccessCount { get; set; }
  9 +
  10 + public int FailCount { get; set; }
  11 +
  12 + public List<TeamMemberBatchImportOnlineErrorDto> Errors { get; set; } = new();
  13 +}
  14 +
  15 +/// <summary>
  16 +/// 成员 JSON 在线批量导入单条失败信息
  17 +/// </summary>
  18 +public class TeamMemberBatchImportOnlineErrorDto
  19 +{
  20 + /// <summary>
  21 + /// 在请求 <c>items</c> 数组中的序号(从 0 开始)
  22 + /// </summary>
  23 + public int Index { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 登录账号(<c>userName</c>),便于定位失败行
  27 + /// </summary>
  28 + public string? UserName { get; set; }
  29 +
  30 + public string Message { get; set; } = string.Empty;
  31 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs
... ... @@ -28,20 +28,27 @@ public class TeamMemberCreateInputVo
28 28 public List<string>? PartnerIds { get; set; }
29 29  
30 30 /// <summary>
31   - /// 适用 Region 多选(<c>fl_group.Id</c>);Company Admin 仅传 Company 时可省略,后端自动绑定该公司下全部 Region 与门店
  31 + /// 适用 Region 多选(<c>fl_group.Id</c>);可含 <c>ALL</c> 哨兵(大小写不敏感,与具体 Guid 同传时以 ALL 为准)。
  32 + /// Company Admin 仅传 Company 时可省略;<c>locationIds</c> 为空且含 ALL 时展开该公司全部 Region 下门店。
32 33 /// </summary>
33 34 public List<string>? RegionIds { get; set; }
34 35  
35 36 /// <summary>
36   - /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同)
  37 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同);可含 <c>ALL</c>。
37 38 /// </summary>
38 39 public List<string>? GroupIds { get; set; }
39 40  
40 41 /// <summary>
41   - /// 适用门店多选(<c>location.Id</c>);Company Admin 仅传 Company 时可省略,与 Region 合并后写入 <c>userlocation</c>
  42 + /// 适用门店多选(<c>location.Id</c>);可含 <c>ALL</c> 哨兵,按 <see cref="PartnerId"/>/<see cref="PartnerIds"/> 展开该公司全部门店落库。
  43 + /// Company Admin 仅传 Company 时可省略。
42 44 /// </summary>
43 45 public List<string>? LocationIds { get; set; }
44 46  
  47 + /// <summary>
  48 + /// 适用门店多选别名(与 <see cref="LocationIds"/> 合并解析);可含 <c>ALL</c> 或门店 Guid。
  49 + /// </summary>
  50 + public List<string>? Locations { get; set; }
  51 +
45 52 public bool State { get; set; } = true;
46 53 }
47 54  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetOutputDto.cs
... ... @@ -19,12 +19,13 @@ public class TeamMemberGetOutputDto
19 19 /// <summary>适用 Company Id(多选,由绑定门店反推)</summary>
20 20 public List<string> PartnerIds { get; set; } = new();
21 21  
22   - /// <summary>适用 Region Id(多选,<c>fl_group.Id</c>)</summary>
  22 + /// <summary>适用 Region Id(多选,<c>fl_group.Id</c>;覆盖该公司全部 Region 时回显 <c>["ALL"]</c>)</summary>
23 23 public List<string> RegionIds { get; set; } = new();
24 24  
25 25 /// <summary>与 <see cref="RegionIds"/> 相同</summary>
26 26 public List<string> GroupIds { get; set; } = new();
27 27  
  28 + /// <summary>绑定门店 Id;覆盖该公司全部门店时回显 <c>["ALL"]</c>(库中仍存展开 Guid)</summary>
28 29 public List<string> LocationIds { get; set; } = new();
29 30  
30 31 public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new();
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs
... ... @@ -28,20 +28,25 @@ public class TeamMemberUpdateInputVo
28 28 public List<string>? PartnerIds { get; set; }
29 29  
30 30 /// <summary>
31   - /// 适用 Region 多选(<c>fl_group.Id</c>);Company Admin 仅传 Company 时可省略
  31 + /// 适用 Region 多选(<c>fl_group.Id</c>);可含 <c>ALL</c> 哨兵(大小写不敏感,与具体 Guid 同传时以 ALL 为准)。
32 32 /// </summary>
33 33 public List<string>? RegionIds { get; set; }
34 34  
35 35 /// <summary>
36   - /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同)
  36 + /// 适用 Region 多选(与 <see cref="RegionIds"/> 相同);可含 <c>ALL</c>。
37 37 /// </summary>
38 38 public List<string>? GroupIds { get; set; }
39 39  
40 40 /// <summary>
41   - /// 适用门店多选(<c>location.Id</c>);Company Admin 仅传 Company 时可省略
  41 + /// 适用门店多选(<c>location.Id</c>);可含 <c>ALL</c> 哨兵,按 Company 展开全部门店落库。
42 42 /// </summary>
43 43 public List<string>? LocationIds { get; set; }
44 44  
  45 + /// <summary>
  46 + /// 适用门店多选别名(与 <see cref="LocationIds"/> 合并解析);可含 <c>ALL</c> 或门店 Guid。
  47 + /// </summary>
  48 + public List<string>? Locations { get; set; }
  49 +
45 50 public bool State { get; set; } = true;
46 51 }
47 52  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IGroupAppService.cs
... ... @@ -56,4 +56,33 @@ public interface IGroupAppService : IApplicationService
56 56 /// <param name="input">Keyword、PartnerId、State、Sorting;分页字段忽略</param>
57 57 /// <returns>application/pdf</returns>
58 58 Task<IActionResult> ExportPdfAsync(GroupGetListInputVo input);
  59 +
  60 + /// <summary>
  61 + /// JSON 在线批量导入 Region(逐行调用 <see cref="CreateAsync"/>,部分成功)
  62 + /// </summary>
  63 + /// <remarks>
  64 + /// 请求体为 JSON,每行字段与单条新增 <see cref="GroupCreateInputVo"/> 一致。
  65 + ///
  66 + /// 示例请求:
  67 + /// ```json
  68 + /// {
  69 + /// "items": [
  70 + /// {
  71 + /// "groupName": "NC Region",
  72 + /// "partnerId": "PARTNER_ID",
  73 + /// "state": true
  74 + /// }
  75 + /// ]
  76 + /// }
  77 + /// ```
  78 + ///
  79 + /// 参数说明:
  80 + /// - items: 待导入行数组;<c>index</c> 从 0 起;单次最多 <c>MaxImportRows</c> 条(默认 5000)
  81 + /// </remarks>
  82 + /// <param name="input">批量导入请求体</param>
  83 + /// <returns>成功数、失败数及失败明细(<c>index</c>、<c>groupName</c>、<c>message</c>)</returns>
  84 + /// <response code="200">全部或部分行处理完成,见返回体中的计数与 errors</response>
  85 + /// <response code="400">整单校验失败(如 items 为空、超过单次条数上限)</response>
  86 + /// <response code="500">服务器错误</response>
  87 + Task<GroupBatchImportOnlineResultDto> BatchImportOnlineAsync(GroupBatchImportOnlineInputVo input);
59 88 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ILocationAppService.cs
... ... @@ -90,5 +90,36 @@ public interface ILocationAppService : IApplicationService
90 90 /// <response code="400">整单校验失败(如超过单次条数上限、items 为空)</response>
91 91 /// <response code="500">服务器错误</response>
92 92 Task<LocationBulkUpdateResultDto> UpdateLocationsBulkAsync(LocationBulkUpdateInputVo input);
  93 +
  94 + /// <summary>
  95 + /// JSON 在线批量导入门店(逐行调用 <see cref="CreateAsync"/>,部分成功)
  96 + /// </summary>
  97 + /// <remarks>
  98 + /// 请求体为 JSON,每行字段与单条新增 <see cref="LocationCreateInputVo"/> 一致。
  99 + ///
  100 + /// 示例请求:
  101 + /// ```json
  102 + /// {
  103 + /// "items": [
  104 + /// {
  105 + /// "locationCode": "LOC001",
  106 + /// "locationName": "UNCC store",
  107 + /// "partner": "MedVantage Cafe Group",
  108 + /// "groupName": "NC Region",
  109 + /// "state": true
  110 + /// }
  111 + /// ]
  112 + /// }
  113 + /// ```
  114 + ///
  115 + /// 参数说明:
  116 + /// - items: 待导入行数组;<c>index</c> 从 0 起;单次最多 <c>MaxImportRows</c> 条(默认 5000)
  117 + /// </remarks>
  118 + /// <param name="input">批量导入请求体</param>
  119 + /// <returns>成功数、失败数及失败明细(<c>index</c>、<c>locationCode</c>、<c>message</c>)</returns>
  120 + /// <response code="200">全部或部分行处理完成,见返回体中的计数与 errors</response>
  121 + /// <response code="400">整单校验失败(如 items 为空、超过单次条数上限)</response>
  122 + /// <response code="500">服务器错误</response>
  123 + Task<LocationBatchImportOnlineResultDto> BatchImportOnlineAsync(LocationBatchImportOnlineInputVo input);
93 124 }
94 125  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductAppService.cs
... ... @@ -74,5 +74,36 @@ public interface IProductAppService : IApplicationService
74 74 /// 批量编辑产品(JSON 一次提交多行,与单条 <c>PUT</c> 字段一致)
75 75 /// </summary>
76 76 Task<ProductBulkUpdateResultDto> UpdateProductsBulkAsync(ProductBulkUpdateInputVo input);
  77 +
  78 + /// <summary>
  79 + /// JSON 在线批量导入产品(逐行调用 <see cref="CreateAsync"/>,部分成功)
  80 + /// </summary>
  81 + /// <remarks>
  82 + /// 请求体为 JSON,每行字段与单条新增 <see cref="ProductCreateInputVo"/> 一致。
  83 + ///
  84 + /// 示例请求:
  85 + /// ```json
  86 + /// {
  87 + /// "items": [
  88 + /// {
  89 + /// "productName": "Tuna & Bacon Sub",
  90 + /// "categoryId": "CATEGORY_ID",
  91 + /// "productCode": "40001",
  92 + /// "state": true,
  93 + /// "locationIds": ["LOCATION_GUID"]
  94 + /// }
  95 + /// ]
  96 + /// }
  97 + /// ```
  98 + ///
  99 + /// 参数说明:
  100 + /// - items: 待导入行数组;<c>index</c> 从 0 起;单次最多 <c>MaxImportRows</c> 条(默认 5000)
  101 + /// </remarks>
  102 + /// <param name="input">批量导入请求体</param>
  103 + /// <returns>成功数、失败数及失败明细(<c>index</c>、<c>productName</c>、<c>message</c>)</returns>
  104 + /// <response code="200">全部或部分行处理完成,见返回体中的计数与 errors</response>
  105 + /// <response code="400">整单校验失败(如 items 为空、超过单次条数上限)</response>
  106 + /// <response code="500">服务器错误</response>
  107 + Task<ProductBatchImportOnlineResultDto> BatchImportOnlineAsync(ProductBatchImportOnlineInputVo input);
77 108 }
78 109  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs
... ... @@ -14,8 +14,14 @@ public interface ITeamMemberAppService
14 14  
15 15 Task<TeamMemberGetOutputDto> GetAsync(Guid id);
16 16  
  17 + /// <summary>
  18 + /// 新增成员(POST <c>/api/app/team-member</c>)。<c>locationIds</c> / <c>regionIds</c> / <c>groupIds</c> / <c>locations</c> 可传 <c>ALL</c> 哨兵。
  19 + /// </summary>
17 20 Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input);
18 21  
  22 + /// <summary>
  23 + /// 更新成员(PUT <c>/api/app/team-member/{id}</c>)。范围传参规则与 <see cref="CreateAsync"/> 相同。
  24 + /// </summary>
19 25 Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input);
20 26  
21 27 Task DeleteAsync(Guid id);
... ... @@ -39,4 +45,37 @@ public interface ITeamMemberAppService
39 45 /// 批量编辑成员(JSON 一次提交多行)
40 46 /// </summary>
41 47 Task<TeamMemberBulkUpdateResultDto> UpdateTeamMembersBulkAsync(TeamMemberBulkUpdateInputVo input);
  48 +
  49 + /// <summary>
  50 + /// JSON 在线批量导入成员(逐行调用 <see cref="CreateAsync"/>,部分成功)
  51 + /// </summary>
  52 + /// <remarks>
  53 + /// 请求体为 JSON,每行字段与单条新增 <see cref="TeamMemberCreateInputVo"/> 一致;
  54 + /// <c>password</c> 为空时使用配置 <c>TeamMemberImportDefaultPassword</c>。
  55 + ///
  56 + /// 示例请求:
  57 + /// ```json
  58 + /// {
  59 + /// "items": [
  60 + /// {
  61 + /// "fullName": "John Doe",
  62 + /// "userName": "john@example.com",
  63 + /// "email": "john@example.com",
  64 + /// "roleId": "ROLE_GUID",
  65 + /// "locationIds": ["LOCATION_GUID"],
  66 + /// "state": true
  67 + /// }
  68 + /// ]
  69 + /// }
  70 + /// ```
  71 + ///
  72 + /// 参数说明:
  73 + /// - items: 待导入行数组;<c>index</c> 从 0 起;单次最多 <c>MaxImportRows</c> 条(默认 5000)
  74 + /// </remarks>
  75 + /// <param name="input">批量导入请求体</param>
  76 + /// <returns>成功数、失败数及失败明细(<c>index</c>、<c>userName</c>、<c>message</c>)</returns>
  77 + /// <response code="200">全部或部分行处理完成,见返回体中的计数与 errors</response>
  78 + /// <response code="400">整单校验失败(如 items 为空、超过单次条数上限)</response>
  79 + /// <response code="500">服务器错误</response>
  80 + Task<TeamMemberBatchImportOnlineResultDto> BatchImportOnlineAsync(TeamMemberBatchImportOnlineInputVo input);
42 81 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/AllScopeBindingHelper.cs
... ... @@ -23,22 +23,36 @@ public static class AllScopeBindingHelper
23 23 return selected.Count == 0;
24 24 }
25 25  
26   - if (selected.Count != universe.Count)
  26 + var universeSet = new HashSet<string>(
  27 + universe.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()),
  28 + StringComparer.OrdinalIgnoreCase);
  29 + var selectedSet = new HashSet<string>(
  30 + selected.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()),
  31 + StringComparer.OrdinalIgnoreCase);
  32 +
  33 + if (selectedSet.Count != universeSet.Count)
27 34 {
28 35 return false;
29 36 }
30 37  
31   - var set = new HashSet<string>(universe, StringComparer.Ordinal);
32   - return selected.All(id => set.Contains(id));
  38 + return selectedSet.SetEquals(universeSet);
33 39 }
34 40  
35   - /// <summary>解析维度类型:显式 ALL、空数组 ALL、或 Id 列表全选时视为 ALL。</summary>
  41 + /// <summary>
  42 + /// 解析维度类型:显式 ALL、空数组 ALL、或 Id 列表全选时视为 ALL。
  43 + /// 入参显式 <see cref="ScopeSpecified"/> 时保留 SPECIFIED 并落库全部 Id,不因「当前上下文全选」折叠为 ALL。
  44 + /// </summary>
36 45 public static string ResolveDimensionType(
37 46 string? declaredType,
38 47 IReadOnlyList<string> ids,
39 48 bool hasArrayInPayload,
40 49 bool isFullSelection)
41 50 {
  51 + if (IsDeclaredSpecified(declaredType))
  52 + {
  53 + return ScopeSpecified;
  54 + }
  55 +
42 56 if (isFullSelection || (ids.Count == 0 && IsDeclaredAll(declaredType)))
43 57 {
44 58 return ScopeAll;
... ... @@ -61,6 +75,9 @@ public static class AllScopeBindingHelper
61 75 public static bool IsDeclaredAll(string? type) =>
62 76 string.Equals((type ?? ScopeAll).Trim(), ScopeAll, StringComparison.OrdinalIgnoreCase);
63 77  
  78 + public static bool IsDeclaredSpecified(string? type) =>
  79 + string.Equals((type ?? string.Empty).Trim(), ScopeSpecified, StringComparison.OrdinalIgnoreCase);
  80 +
64 81 /// <summary>全部 Company(<c>fl_partner.Id</c>)。</summary>
65 82 public static async Task<List<string>> ResolveAllPartnerIdsAsync(ISqlSugarClient db)
66 83 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeHelper.cs
... ... @@ -63,17 +63,16 @@ public static class LabelTemplateScopeHelper
63 63 ISqlSugarClient db,
64 64 LabelTemplateCreateInputVo input)
65 65 {
66   - var partnerIds = NormalizePartnerIds(input);
67 66 var regionIds = NormalizeRegionIds(input);
68 67 var locationIds = MergeExplicitLocationIds(input);
69 68  
70 69 var (partnerType, partnerIdsForSave) = await AllScopeBindingHelper.NormalizePartnerScopeAsync(
71 70 db,
72 71 input.AppliedPartnerType,
73   - partnerIds,
74   - null,
  72 + input.PartnerIds,
  73 + input.CompanyIds,
75 74 input.PartnerIds is not null || input.CompanyIds is not null);
76   - partnerIds = partnerIdsForSave;
  75 + var partnerIds = partnerIdsForSave;
77 76  
78 77 var partnerContext = string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase)
79 78 ? partnerIds
... ... @@ -300,6 +299,8 @@ public static class LabelTemplateScopeHelper
300 299 .Where(x => templateIds.Contains(x.TemplateId))
301 300 .ToListAsync();
302 301  
  302 + var storedScopeTypes = await LabelTemplateScopeSchemaHelper.GetAppliedScopeTypesMapAsync(db, templateIds);
  303 +
303 304 var partnerIdSet = partnerLinks.Select(x => x.PartnerId).Distinct(StringComparer.Ordinal).ToList();
304 305 var regionIdSet = regionLinks.Select(x => x.GroupId).Distinct(StringComparer.Ordinal).ToList();
305 306 var locationIdSet = locationLinks.Select(x => x.LocationId).Distinct(StringComparer.Ordinal).ToList();
... ... @@ -360,9 +361,16 @@ public static class LabelTemplateScopeHelper
360 361 var partnerType = pIds.Count > 0 ? ScopeSpecified : ScopeAll;
361 362 var regionType = rIds.Count > 0 ? ScopeSpecified : ScopeAll;
362 363  
  364 + if (storedScopeTypes.TryGetValue(template.Id, out var storedTypes))
  365 + {
  366 + partnerType = NormalizeScopeType(storedTypes.PartnerType, partnerType);
  367 + regionType = NormalizeScopeType(storedTypes.RegionType, regionType);
  368 + }
  369 +
363 370 if (hasExtendedScope
364 371 && pIds.Count == 0
365   - && lIds.Count > 0)
  372 + && lIds.Count > 0
  373 + && !string.Equals(partnerType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
366 374 {
367 375 pIds = await LocationScopeBindingHelper.ResolvePartnerIdsFromLocationIdsAsync(db, lIds);
368 376 if (pIds.Count > 0)
... ... @@ -373,7 +381,8 @@ public static class LabelTemplateScopeHelper
373 381  
374 382 if (hasExtendedScope
375 383 && rIds.Count == 0
376   - && lIds.Count > 0)
  384 + && lIds.Count > 0
  385 + && !string.Equals(regionType, ScopeSpecified, StringComparison.OrdinalIgnoreCase))
377 386 {
378 387 rIds = await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(db, lIds);
379 388 if (rIds.Count > 0)
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateScopeSchemaHelper.cs
... ... @@ -315,6 +315,77 @@ public static class LabelTemplateScopeSchemaHelper
315 315 return map.TryGetValue(templateId.Trim(), out var text) ? text : null;
316 316 }
317 317  
  318 + /// <summary>批量读取模板 Company/Region 维度 ALL/SPECIFIED;列不存在时返回空字典。</summary>
  319 + public static async Task<Dictionary<string, (string PartnerType, string RegionType)>> GetAppliedScopeTypesMapAsync(
  320 + ISqlSugarClient db,
  321 + IReadOnlyList<string> templateIds)
  322 + {
  323 + var result = new Dictionary<string, (string PartnerType, string RegionType)>(StringComparer.Ordinal);
  324 + if (templateIds.Count == 0)
  325 + {
  326 + return result;
  327 + }
  328 +
  329 + var status = await GetStatusAsync(db);
  330 + if (!status.HasAppliedPartnerTypeColumn && !status.HasAppliedRegionTypeColumn)
  331 + {
  332 + return result;
  333 + }
  334 +
  335 + var ids = templateIds.Where(x => !string.IsNullOrWhiteSpace(x))
  336 + .Select(x => x.Trim())
  337 + .Distinct(StringComparer.Ordinal)
  338 + .ToArray();
  339 + if (ids.Length == 0)
  340 + {
  341 + return result;
  342 + }
  343 +
  344 + var rows = await db.Ado.SqlQueryAsync<AppliedScopeTypesRow>(
  345 + """
  346 + SELECT Id, AppliedPartnerType, AppliedRegionType
  347 + FROM fl_label_template
  348 + WHERE Id IN (@ids)
  349 + """,
  350 + new { ids });
  351 +
  352 + foreach (var row in rows)
  353 + {
  354 + if (string.IsNullOrWhiteSpace(row.Id))
  355 + {
  356 + continue;
  357 + }
  358 +
  359 + var partnerType = status.HasAppliedPartnerTypeColumn
  360 + ? NormalizeScopeTypeValue(row.AppliedPartnerType)
  361 + : ScopeAll;
  362 + var regionType = status.HasAppliedRegionTypeColumn
  363 + ? NormalizeScopeTypeValue(row.AppliedRegionType)
  364 + : ScopeAll;
  365 + result[row.Id.Trim()] = (partnerType, regionType);
  366 + }
  367 +
  368 + return result;
  369 + }
  370 +
  371 + private static string NormalizeScopeTypeValue(string? type)
  372 + {
  373 + var normalized = (type ?? ScopeAll).Trim().ToUpperInvariant();
  374 + return normalized == ScopeSpecified ? ScopeSpecified : ScopeAll;
  375 + }
  376 +
  377 + private const string ScopeAll = "ALL";
  378 + private const string ScopeSpecified = "SPECIFIED";
  379 +
  380 + private sealed class AppliedScopeTypesRow
  381 + {
  382 + public string Id { get; init; } = string.Empty;
  383 +
  384 + public string? AppliedPartnerType { get; init; }
  385 +
  386 + public string? AppliedRegionType { get; init; }
  387 + }
  388 +
318 389 /// <summary>已迁移 <c>Contents</c> 列时写入(未迁移则 no-op)。</summary>
319 390 public static async Task SetContentsAsync(ISqlSugarClient db, string templateId, string? contents)
320 391 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs
... ... @@ -321,6 +321,94 @@ public static class LocationScopeBindingHelper
321 321 }
322 322  
323 323 /// <summary>
  324 + /// 多选 Id 是否含 <c>ALL</c> 哨兵(大小写不敏感)。与具体 Guid 同传时以 ALL 为准(全选)。
  325 + /// </summary>
  326 + public static bool IsAllScopeSentinel(string? value) =>
  327 + string.Equals(value?.Trim(), AllScopeBindingHelper.ScopeAll, StringComparison.OrdinalIgnoreCase);
  328 +
  329 + /// <summary>Id 列表是否含 <c>ALL</c> 哨兵。</summary>
  330 + public static bool ContainsAllScopeSentinel(IReadOnlyList<string>? ids) =>
  331 + ids?.Any(IsAllScopeSentinel) == true;
  332 +
  333 + /// <summary>去掉 ALL 哨兵,仅保留具体 Id。</summary>
  334 + public static List<string> FilterConcreteScopeIds(IReadOnlyList<string>? ids) =>
  335 + NormalizeIds(ids).Where(x => !IsAllScopeSentinel(x)).ToList();
  336 +
  337 + /// <summary>
  338 + /// Team Member 门店范围落库(不做 partner+region+location 并集)。
  339 + /// 优先级:
  340 + /// 1) <c>locationIds</c> 含 ALL → 公司全部门店;
  341 + /// 2) 有具体 <c>locationIds</c>,且 <c>regionIds</c> 为空或为 ALL → 只绑这些门店(UI 在 Region=ALL 下再收窄门店);
  342 + /// 3) <c>regionIds</c> 含 ALL → 公司全部 Region 下门店;
  343 + /// 4) 具体 <c>regionIds</c> → 按 Region 展开(忽略同传 locationIds,保证多区域能落库);
  344 + /// 5) 仅 <paramref name="partnerIds"/> → 公司全部门店。
  345 + /// </summary>
  346 + public static async Task<List<string>> ResolveTeamMemberLocationIdsForSaveAsync(
  347 + ISqlSugarClient db,
  348 + IReadOnlyList<string>? partnerIds,
  349 + IReadOnlyList<string>? regionIds,
  350 + IReadOnlyList<string>? locationIds)
  351 + {
  352 + var normalizedPartners = NormalizeIds(partnerIds);
  353 + var locationHasAll = ContainsAllScopeSentinel(locationIds);
  354 + var concreteLocations = FilterConcreteScopeIds(locationIds);
  355 + var regionHasAll = ContainsAllScopeSentinel(regionIds);
  356 + var concreteRegions = FilterConcreteScopeIds(regionIds);
  357 +
  358 + // 1. locationIds 含 ALL → 按 partner 展开全部门店
  359 + if (locationHasAll)
  360 + {
  361 + if (normalizedPartners.Count == 0)
  362 + {
  363 + throw new UserFriendlyException("选择全部门店时需指定 Company(partnerId / partnerIds)");
  364 + }
  365 +
  366 + return await ResolveLocationIdsFromPartnerIdsAsync(db, normalizedPartners);
  367 + }
  368 +
  369 + // 2. 具体门店 +(无区域 / 区域为 ALL)→ 只绑这些门店,避免 regionIds=ALL 盖掉单店
  370 + if (concreteLocations.Count > 0 && (regionHasAll || concreteRegions.Count == 0))
  371 + {
  372 + return concreteLocations;
  373 + }
  374 +
  375 + // 3. regionIds 含 ALL(且无具体门店)→ 按 partner 展开全部 Region 下门店
  376 + if (regionHasAll)
  377 + {
  378 + if (normalizedPartners.Count == 0)
  379 + {
  380 + throw new UserFriendlyException("选择全部 Region 时需指定 Company(partnerId / partnerIds)");
  381 + }
  382 +
  383 + return await AllScopeBindingHelper.ResolveAllLocationIdsAsync(db, normalizedPartners, null);
  384 + }
  385 +
  386 + // 4. 具体 regionIds → 按 Region 展开门店(忽略同传 locationIds)
  387 + if (concreteRegions.Count > 0)
  388 + {
  389 + return await MergeToLocationIdsAsync(
  390 + db,
  391 + (IReadOnlyList<string>?)null,
  392 + concreteRegions,
  393 + null);
  394 + }
  395 +
  396 + // 5. 仅有具体 locationIds → 只绑这些门店
  397 + if (concreteLocations.Count > 0)
  398 + {
  399 + return concreteLocations;
  400 + }
  401 +
  402 + // 6. 仅有 partner → 绑该公司全部门店
  403 + if (normalizedPartners.Count > 0)
  404 + {
  405 + return await MergeToLocationIdsAsync(db, normalizedPartners, null, null);
  406 + }
  407 +
  408 + return new List<string>();
  409 + }
  410 +
  411 + /// <summary>
324 412 /// 标签类型/分类/多选项门店范围落库:仅按 Region/Location 合并,不因 Company(partner)展开并集。
325 413 /// </summary>
326 414 public static async Task<List<string>> ResolveEntityLocationIdsForSaveAsync(
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/TeamMemberScopeDisplayHelper.cs
... ... @@ -40,7 +40,7 @@ public static class TeamMemberScopeDisplayHelper
40 40 return assigned.ToList();
41 41 }
42 42  
43   - if (await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(db, roleId) && partnerIds.Count > 0)
  43 + if (partnerIds.Count > 0)
44 44 {
45 45 var allPartnerLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(
46 46 db, partnerIds);
... ... @@ -51,14 +51,6 @@ public static class TeamMemberScopeDisplayHelper
51 51 }
52 52 }
53 53  
54   - var universeLocationIds = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
55   - db, partnerIds, regionIds, null);
56   - if (universeLocationIds.Count > 0 &&
57   - AllScopeBindingHelper.IsFullIdSelection(assignedIds, universeLocationIds))
58   - {
59   - return AllLocationDisplay;
60   - }
61   -
62 54 return assigned.ToList();
63 55 }
64 56  
... ... @@ -92,6 +84,43 @@ public static class TeamMemberScopeDisplayHelper
92 84 : id));
93 85 }
94 86  
  87 + /// <summary>
  88 + /// 编辑回显:库中存展开后的 Guid;若绑定已覆盖 Company 下全部 Region/门店,则将对应 Id 列表折叠为 <c>["ALL"]</c>,供前端勾选 ALL。
  89 + /// </summary>
  90 + public static async Task<(List<string> RegionIds, List<string> LocationIds, List<TeamMemberAssignedLocationDto> Assigned)>
  91 + CollapseScopeIdsToAllSentinelForEditAsync(
  92 + ISqlSugarClient db,
  93 + IReadOnlyList<string> partnerIds,
  94 + IReadOnlyList<string> regionIds,
  95 + IReadOnlyList<string> locationIds,
  96 + IReadOnlyList<TeamMemberAssignedLocationDto> assigned)
  97 + {
  98 + var partners = LocationScopeBindingHelper.NormalizeIds(partnerIds);
  99 + var regions = LocationScopeBindingHelper.NormalizeIds(regionIds);
  100 + var locations = LocationScopeBindingHelper.NormalizeIds(locationIds);
  101 + var assignedList = assigned?.ToList() ?? new List<TeamMemberAssignedLocationDto>();
  102 +
  103 + if (partners.Count == 0)
  104 + {
  105 + return (regions, locations, assignedList);
  106 + }
  107 +
  108 + var allRegionIds = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(db, partners);
  109 + if (allRegionIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(regions, allRegionIds))
  110 + {
  111 + regions = new List<string> { AllScopeBindingHelper.ScopeAll };
  112 + }
  113 +
  114 + var allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(db, partners);
  115 + if (allLocationIds.Count > 0 && AllScopeBindingHelper.IsFullIdSelection(locations, allLocationIds))
  116 + {
  117 + locations = new List<string> { AllScopeBindingHelper.ScopeAll };
  118 + assignedList = AllLocationDisplay;
  119 + }
  120 +
  121 + return (regions, locations, assignedList);
  122 + }
  123 +
95 124 public static string FormatLocationTextForList(IReadOnlyList<TeamMemberAssignedLocationDto> assigned)
96 125 {
97 126 if (assigned.Count == 0)
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/GroupAppService.cs
... ... @@ -2,8 +2,10 @@ using FoodLabeling.Application.Helpers;
2 2 using FoodLabeling.Application.Contracts.Dtos.Common;
3 3 using FoodLabeling.Application.Contracts.Dtos.Group;
4 4 using FoodLabeling.Application.Contracts.IServices;
  5 +using FoodLabeling.Application.Options;
5 6 using FoodLabeling.Application.Services.DbModels;
6 7 using Microsoft.AspNetCore.Mvc;
  8 +using Microsoft.Extensions.Options;
7 9 using QuestPDF.Fluent;
8 10 using QuestPDF.Helpers;
9 11 using QuestPDF.Infrastructure;
... ... @@ -25,11 +27,16 @@ public class GroupAppService : ApplicationService, IGroupAppService
25 27  
26 28 private readonly ISqlSugarDbContext _dbContext;
27 29 private readonly IGuidGenerator _guidGenerator;
  30 + private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions;
28 31  
29   - public GroupAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
  32 + public GroupAppService(
  33 + ISqlSugarDbContext dbContext,
  34 + IGuidGenerator guidGenerator,
  35 + IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions)
30 36 {
31 37 _dbContext = dbContext;
32 38 _guidGenerator = guidGenerator;
  39 + _batchImportOptions = batchImportOptions;
33 40 }
34 41  
35 42 /// <inheritdoc />
... ... @@ -264,6 +271,47 @@ public class GroupAppService : ApplicationService, IGroupAppService
264 271 return new FileStreamResult(stream, "application/pdf") { FileDownloadName = fileName };
265 272 }
266 273  
  274 + /// <inheritdoc />
  275 + [HttpPost("group/batch-import-online")]
  276 + public async Task<GroupBatchImportOnlineResultDto> BatchImportOnlineAsync(
  277 + [FromBody] GroupBatchImportOnlineInputVo input)
  278 + {
  279 + if (input?.Items is null || input.Items.Count == 0)
  280 + {
  281 + throw new UserFriendlyException("请至少提交一条导入数据(items 不能为空)");
  282 + }
  283 +
  284 + var opt = _batchImportOptions.Value;
  285 + var maxRows = opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows;
  286 + if (input.Items.Count > maxRows)
  287 + {
  288 + throw new UserFriendlyException($"单次批量导入最多允许 {maxRows} 条,请分批提交");
  289 + }
  290 +
  291 + var result = new GroupBatchImportOnlineResultDto();
  292 + for (var index = 0; index < input.Items.Count; index++)
  293 + {
  294 + var vo = input.Items[index];
  295 + try
  296 + {
  297 + await CreateAsync(vo);
  298 + result.SuccessCount++;
  299 + }
  300 + catch (UserFriendlyException ex)
  301 + {
  302 + result.FailCount++;
  303 + result.Errors.Add(new GroupBatchImportOnlineErrorDto
  304 + {
  305 + Index = index,
  306 + GroupName = vo.GroupName,
  307 + Message = ex.Message
  308 + });
  309 + }
  310 + }
  311 +
  312 + return result;
  313 + }
  314 +
267 315 private async Task<ISugarQueryable<FlGroupDbEntity, FlPartnerDbEntity>> BuildGroupJoinedQueryAsync(
268 316 GroupGetListInputVo input)
269 317 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LocationAppService.cs
... ... @@ -303,6 +303,47 @@ public class LocationAppService : ApplicationService, ILocationAppService
303 303 }
304 304  
305 305 /// <inheritdoc />
  306 + [HttpPost("location/batch-import-online")]
  307 + public async Task<LocationBatchImportOnlineResultDto> BatchImportOnlineAsync(
  308 + [FromBody] LocationBatchImportOnlineInputVo input)
  309 + {
  310 + if (input?.Items is null || input.Items.Count == 0)
  311 + {
  312 + throw new UserFriendlyException("请至少提交一条导入数据(items 不能为空)");
  313 + }
  314 +
  315 + var opt = _batchImportOptions.Value;
  316 + var maxRows = opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows;
  317 + if (input.Items.Count > maxRows)
  318 + {
  319 + throw new UserFriendlyException($"单次批量导入最多允许 {maxRows} 条,请分批提交");
  320 + }
  321 +
  322 + var result = new LocationBatchImportOnlineResultDto();
  323 + for (var index = 0; index < input.Items.Count; index++)
  324 + {
  325 + var vo = input.Items[index];
  326 + try
  327 + {
  328 + await CreateAsync(vo);
  329 + result.SuccessCount++;
  330 + }
  331 + catch (UserFriendlyException ex)
  332 + {
  333 + result.FailCount++;
  334 + result.Errors.Add(new LocationBatchImportOnlineErrorDto
  335 + {
  336 + Index = index,
  337 + LocationCode = vo.LocationCode,
  338 + Message = ex.Message
  339 + });
  340 + }
  341 + }
  342 +
  343 + return result;
  344 + }
  345 +
  346 + /// <inheritdoc />
306 347 public async Task<LocationBulkUpdateResultDto> UpdateLocationsBulkAsync([FromBody] LocationBulkUpdateInputVo input)
307 348 {
308 349 if (input?.Items is null || input.Items.Count == 0)
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
... ... @@ -477,6 +477,47 @@ public class ProductAppService : ApplicationService, IProductAppService
477 477 }
478 478  
479 479 /// <inheritdoc />
  480 + [HttpPost("product/batch-import-online")]
  481 + public async Task<ProductBatchImportOnlineResultDto> BatchImportOnlineAsync(
  482 + [FromBody] ProductBatchImportOnlineInputVo input)
  483 + {
  484 + if (input?.Items is null || input.Items.Count == 0)
  485 + {
  486 + throw new UserFriendlyException("请至少提交一条导入数据(items 不能为空)");
  487 + }
  488 +
  489 + var opt = _batchImportOptions.Value;
  490 + var maxRows = opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows;
  491 + if (input.Items.Count > maxRows)
  492 + {
  493 + throw new UserFriendlyException($"单次批量导入最多允许 {maxRows} 条,请分批提交");
  494 + }
  495 +
  496 + var result = new ProductBatchImportOnlineResultDto();
  497 + for (var index = 0; index < input.Items.Count; index++)
  498 + {
  499 + var vo = input.Items[index];
  500 + try
  501 + {
  502 + await CreateAsync(vo);
  503 + result.SuccessCount++;
  504 + }
  505 + catch (UserFriendlyException ex)
  506 + {
  507 + result.FailCount++;
  508 + result.Errors.Add(new ProductBatchImportOnlineErrorDto
  509 + {
  510 + Index = index,
  511 + ProductName = vo.ProductName,
  512 + Message = ex.Message
  513 + });
  514 + }
  515 + }
  516 +
  517 + return result;
  518 + }
  519 +
  520 + /// <inheritdoc />
480 521 public async Task<ProductBulkUpdateResultDto> UpdateProductsBulkAsync(
481 522 [FromBody] ProductBulkUpdateInputVo input)
482 523 {
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs
... ... @@ -138,6 +138,11 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
138 138 scopeLocationIds = assigned.Select(x => x.Id).ToList();
139 139 }
140 140  
  141 + // 库中存展开 Guid;全选时编辑回显折叠为 ["ALL"],与新增传 ALL 对称
  142 + (regionIds, scopeLocationIds, assigned) =
  143 + await TeamMemberScopeDisplayHelper.CollapseScopeIdsToAllSentinelForEditAsync(
  144 + _dbContext.SqlSugarClient, partnerIds, regionIds, scopeLocationIds, assigned);
  145 +
141 146 return new TeamMemberGetOutputDto
142 147 {
143 148 Id = user.Id,
... ... @@ -155,7 +160,63 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
155 160 };
156 161 }
157 162  
158   - /// <inheritdoc />
  163 + /// <summary>
  164 + /// 新增成员并写入 <c>userlocation</c> 门店绑定。
  165 + /// </summary>
  166 + /// <remarks>
  167 + /// 范围传参支持 <c>ALL</c> 哨兵(大小写不敏感),与具体 Guid 同传时以 ALL 为准(全选)。
  168 + ///
  169 + /// 示例请求(Locations 全选):
  170 + /// ```json
  171 + /// {
  172 + /// "fullName": "Jane Doe",
  173 + /// "userName": "jane@example.com",
  174 + /// "password": "Pass123!",
  175 + /// "roleId": "ROLE_GUID",
  176 + /// "partnerId": "PARTNER_GUID",
  177 + /// "locationIds": ["ALL"],
  178 + /// "state": true
  179 + /// }
  180 + /// ```
  181 + ///
  182 + /// 示例请求(Region 全选):
  183 + /// ```json
  184 + /// {
  185 + /// "fullName": "John Doe",
  186 + /// "userName": "john@example.com",
  187 + /// "password": "Pass123!",
  188 + /// "roleId": "ROLE_GUID",
  189 + /// "partnerId": "PARTNER_GUID",
  190 + /// "regionIds": ["ALL"],
  191 + /// "state": true
  192 + /// }
  193 + /// ```
  194 + ///
  195 + /// 参数说明:
  196 + /// - partnerId / partnerIds: 展开 ALL 时的 Company 上下文(必填)
  197 + /// - locationIds / locations: 可含 <c>ALL</c>;按该公司全部门店落库;列表 <c>assignedLocations</c> 展示 <c>All Location</c>
  198 + /// - regionIds / groupIds: 可含 <c>ALL</c>;为 ALL 且同时有具体 locationIds 时以门店为准;含具体 Guid 时按 Region 展开(忽略同传 locationIds)
  199 + /// - 优先级:location ALL →(具体 location 且 region 空/ALL)→ region ALL → 具体 region → 具体 location → 仅 Company
  200 + ///
  201 + /// 示例请求(编辑多 Region + 同传 location,按 Region 展开):
  202 + /// ```json
  203 + /// {
  204 + /// "fullName": "Jane Doe",
  205 + /// "userName": "jane@example.com",
  206 + /// "roleId": "ROLE_GUID",
  207 + /// "partnerId": "PARTNER_GUID",
  208 + /// "regionIds": ["REGION_GUID_1", "REGION_GUID_2"],
  209 + /// "locationIds": ["LOCATION_GUID"],
  210 + /// "state": true
  211 + /// }
  212 + /// ```
  213 + /// 落库为两 Region 下门店并集;Get 回显 <c>regionIds</c> 含 2 个 Region。
  214 + /// </remarks>
  215 + /// <param name="input">新增成员请求体</param>
  216 + /// <returns>创建后的成员详情(含 <c>assignedLocations</c> 展示文案)</returns>
  217 + /// <response code="200">创建成功</response>
  218 + /// <response code="400">参数不合法或范围无法解析(如 ALL 未传 Company)</response>
  219 + /// <response code="500">服务器错误</response>
159 220 public async Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input)
160 221 {
161 222 var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input, input.RoleId);
... ... @@ -186,7 +247,45 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
186 247 return await GetAsync(user.Id);
187 248 }
188 249  
189   - /// <inheritdoc />
  250 + /// <summary>
  251 + /// 更新成员信息与 <c>userlocation</c> 门店绑定。
  252 + /// </summary>
  253 + /// <remarks>
  254 + /// 范围传参与 <see cref="CreateAsync"/> 一致:<c>locationIds</c> / <c>regionIds</c> / <c>groupIds</c> / <c>locations</c> 可含 <c>ALL</c> 哨兵;
  255 + /// 与具体 Guid 同传时以 ALL 为准。含具体 <c>regionIds</c> 时按 Region 展开落库并忽略同传 <c>locationIds</c>。
  256 + /// 覆盖 Company 全部门店时列表/详情 <c>assignedLocations</c> 展示 <c>All Location</c>。
  257 + ///
  258 + /// 示例请求(Locations ALL):
  259 + /// ```json
  260 + /// {
  261 + /// "fullName": "Jane Doe",
  262 + /// "userName": "jane@example.com",
  263 + /// "roleId": "ROLE_GUID",
  264 + /// "partnerId": "PARTNER_GUID",
  265 + /// "locationIds": ["ALL"],
  266 + /// "state": true
  267 + /// }
  268 + /// ```
  269 + ///
  270 + /// 示例请求(多 Region + 同传 location,按 Region 展开):
  271 + /// ```json
  272 + /// {
  273 + /// "fullName": "Jane Doe",
  274 + /// "userName": "jane@example.com",
  275 + /// "roleId": "ROLE_GUID",
  276 + /// "partnerId": "PARTNER_GUID",
  277 + /// "regionIds": ["REGION_GUID_1", "REGION_GUID_2"],
  278 + /// "locationIds": ["LOCATION_GUID"],
  279 + /// "state": true
  280 + /// }
  281 + /// ```
  282 + /// </remarks>
  283 + /// <param name="id">成员主键</param>
  284 + /// <param name="input">更新请求体</param>
  285 + /// <returns>更新后的成员详情</returns>
  286 + /// <response code="200">更新成功</response>
  287 + /// <response code="400">参数不合法或成员不存在</response>
  288 + /// <response code="500">服务器错误</response>
190 289 public async Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input)
191 290 {
192 291 var mergedLocationIds = await ResolveTeamMemberLocationIdsForSaveAsync(input);
... ... @@ -449,6 +548,59 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
449 548 }
450 549  
451 550 /// <inheritdoc />
  551 + [HttpPost("team-member/batch-import-online")]
  552 + public async Task<TeamMemberBatchImportOnlineResultDto> BatchImportOnlineAsync(
  553 + [FromBody] TeamMemberBatchImportOnlineInputVo input)
  554 + {
  555 + if (input?.Items is null || input.Items.Count == 0)
  556 + {
  557 + throw new UserFriendlyException("请至少提交一条导入数据(items 不能为空)");
  558 + }
  559 +
  560 + var opt = _batchImportOptions.Value;
  561 + var maxRows = opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows;
  562 + if (input.Items.Count > maxRows)
  563 + {
  564 + throw new UserFriendlyException($"单次批量导入最多允许 {maxRows} 条,请分批提交");
  565 + }
  566 +
  567 + var defaultPassword = opt.TeamMemberImportDefaultPassword?.Trim() ?? string.Empty;
  568 + var result = new TeamMemberBatchImportOnlineResultDto();
  569 + for (var index = 0; index < input.Items.Count; index++)
  570 + {
  571 + var vo = input.Items[index];
  572 + try
  573 + {
  574 + if (string.IsNullOrWhiteSpace(vo.Password))
  575 + {
  576 + if (string.IsNullOrEmpty(defaultPassword))
  577 + {
  578 + throw new UserFriendlyException(
  579 + "未配置默认导入密码 FoodLabeling:BatchImport:TeamMemberImportDefaultPassword");
  580 + }
  581 +
  582 + vo.Password = defaultPassword;
  583 + }
  584 +
  585 + await CreateAsync(vo);
  586 + result.SuccessCount++;
  587 + }
  588 + catch (UserFriendlyException ex)
  589 + {
  590 + result.FailCount++;
  591 + result.Errors.Add(new TeamMemberBatchImportOnlineErrorDto
  592 + {
  593 + Index = index,
  594 + UserName = vo.UserName,
  595 + Message = ex.Message
  596 + });
  597 + }
  598 + }
  599 +
  600 + return result;
  601 + }
  602 +
  603 + /// <inheritdoc />
452 604 public async Task<TeamMemberBulkUpdateResultDto> UpdateTeamMembersBulkAsync(
453 605 [FromBody] TeamMemberBulkUpdateInputVo input)
454 606 {
... ... @@ -801,6 +953,22 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
801 953 .Distinct(StringComparer.OrdinalIgnoreCase)
802 954 .ToList();
803 955  
  956 + // 与详情一致:全选时 regionIds/locationIds 回显 ["ALL"]
  957 + var rawLocationIds = (assigned ?? new List<TeamMemberAssignedLocationDto>())
  958 + .Select(x => x.Id)
  959 + .Where(x => !string.IsNullOrWhiteSpace(x))
  960 + .Select(x => x!.Trim())
  961 + .Distinct(StringComparer.OrdinalIgnoreCase)
  962 + .ToList();
  963 + if (rawLocationIds.Count == 0)
  964 + {
  965 + rawLocationIds = locationIdList;
  966 + }
  967 +
  968 + (regionIds, locationIdList, assignedLocations) =
  969 + await TeamMemberScopeDisplayHelper.CollapseScopeIdsToAllSentinelForEditAsync(
  970 + _dbContext.SqlSugarClient, partnerIds, regionIds, rawLocationIds, assignedLocations);
  971 +
804 972 items.Add(new TeamMemberGetListOutputDto
805 973 {
806 974 Id = u.Id,
... ... @@ -844,19 +1012,6 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
844 1012 : await LocationScopeBindingHelper.ResolveGroupIdsFromLocationIdsAsync(
845 1013 _dbContext.SqlSugarClient, locationIds);
846 1014  
847   - if (Guid.TryParse(userId, out var userGuid) &&
848   - roleIdByUser.TryGetValue(userGuid, out var roleId) &&
849   - await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, roleId) &&
850   - partnerIds.Count > 0)
851   - {
852   - var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(
853   - _dbContext.SqlSugarClient, partnerIds);
854   - if (allRegions.Count > 0)
855   - {
856   - regionIds = allRegions;
857   - }
858   - }
859   -
860 1015 result[userId] = new TeamMemberScopeIds
861 1016 {
862 1017 PartnerIds = partnerIds,
... ... @@ -868,7 +1023,7 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
868 1023 }
869 1024  
870 1025 /// <summary>
871   - /// Company Admin 列表/详情:展示所选 Company 下全部 Region 与门店
  1026 + /// 绑定覆盖 Company 下全部门店时,列表/详情展开为全部 Region 并折叠 Location 为 All Location 展示
872 1027 /// </summary>
873 1028 private async Task<(List<string> RegionIds, List<TeamMemberAssignedLocationDto> AssignedLocations)>
874 1029 ApplyCompanyAdminDisplayScopeAsync(
... ... @@ -877,16 +1032,32 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
877 1032 List<string> regionIds,
878 1033 List<TeamMemberAssignedLocationDto> assigned)
879 1034 {
880   - if (!await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(_dbContext.SqlSugarClient, roleId) ||
881   - partnerIds.Count == 0)
  1035 + if (partnerIds.Count == 0)
  1036 + {
  1037 + return (regionIds, assigned);
  1038 + }
  1039 +
  1040 + var assignedIds = assigned
  1041 + .Select(x => x.Id)
  1042 + .Where(x => !string.IsNullOrWhiteSpace(x))
  1043 + .Select(x => x.Trim())
  1044 + .Distinct(StringComparer.Ordinal)
  1045 + .ToList();
  1046 + if (assignedIds.Count == 0)
882 1047 {
883 1048 return (regionIds, assigned);
884 1049 }
885 1050  
886   - var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(
887   - _dbContext.SqlSugarClient, partnerIds);
888 1051 var allLocationIds = await LocationScopeBindingHelper.ResolveLocationIdsFromPartnerIdsAsync(
889 1052 _dbContext.SqlSugarClient, partnerIds);
  1053 + if (allLocationIds.Count == 0 ||
  1054 + !AllScopeBindingHelper.IsFullIdSelection(assignedIds, allLocationIds))
  1055 + {
  1056 + return (regionIds, assigned);
  1057 + }
  1058 +
  1059 + var allRegions = await LocationScopeBindingHelper.ResolveGroupIdsFromPartnerIdsAsync(
  1060 + _dbContext.SqlSugarClient, partnerIds);
890 1061 var allAssigned = await BuildAssignedLocationDtosAsync(allLocationIds);
891 1062  
892 1063 return (
... ... @@ -942,7 +1113,8 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
942 1113 PartnerIds = input.PartnerIds,
943 1114 RegionIds = input.RegionIds,
944 1115 GroupIds = input.GroupIds,
945   - LocationIds = input.LocationIds
  1116 + LocationIds = input.LocationIds,
  1117 + Locations = input.Locations
946 1118 }, input.RoleId);
947 1119  
948 1120 private async Task<List<string>> ResolveTeamMemberLocationIdsForSaveAsync(
... ... @@ -951,12 +1123,16 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
951 1123 {
952 1124 var partnerIds = NormalizePartnerIds(input);
953 1125 var regionIds = NormalizeRegionIds(input);
954   - var explicitLocationIds = LocationScopeBindingHelper.NormalizeIds(input.LocationIds);
  1126 + var mergedLocationInputs = MergeLocationScopeInputs(input);
  1127 + var locationHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(mergedLocationInputs);
  1128 + var explicitLocationIds = LocationScopeBindingHelper.FilterConcreteScopeIds(mergedLocationInputs);
  1129 + var regionHasAll = LocationScopeBindingHelper.ContainsAllScopeSentinel(regionIds);
955 1130 var isCompanyAdmin = await TeamMemberRoleHelper.IsCompanyAdminRoleAsync(
956 1131 _dbContext.SqlSugarClient, roleId);
957 1132  
958 1133 if (isCompanyAdmin && partnerIds.Count > 0 &&
959   - regionIds.Count == 0 && explicitLocationIds.Count == 0)
  1134 + !regionHasAll && regionIds.Count == 0 &&
  1135 + !locationHasAll && explicitLocationIds.Count == 0)
960 1136 {
961 1137 var fromPartner = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
962 1138 _dbContext.SqlSugarClient, partnerIds, null, null);
... ... @@ -969,8 +1145,8 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
969 1145 return fromPartner;
970 1146 }
971 1147  
972   - var merged = await LocationScopeBindingHelper.MergeToLocationIdsAsync(
973   - _dbContext.SqlSugarClient, partnerIds, regionIds, input.LocationIds);
  1148 + var merged = await LocationScopeBindingHelper.ResolveTeamMemberLocationIdsForSaveAsync(
  1149 + _dbContext.SqlSugarClient, partnerIds, regionIds, mergedLocationInputs);
974 1150  
975 1151 if (merged.Count == 0)
976 1152 {
... ... @@ -984,6 +1160,23 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
984 1160 return merged;
985 1161 }
986 1162  
  1163 + /// <summary>合并 <see cref="TeamMemberCreateInputVo.LocationIds"/> 与 <see cref="TeamMemberCreateInputVo.Locations"/>(均可含 ALL)。</summary>
  1164 + private static List<string>? MergeLocationScopeInputs(TeamMemberCreateInputVo input)
  1165 + {
  1166 + var merged = new List<string>();
  1167 + if (input.LocationIds is { Count: > 0 })
  1168 + {
  1169 + merged.AddRange(input.LocationIds);
  1170 + }
  1171 +
  1172 + if (input.Locations is { Count: > 0 })
  1173 + {
  1174 + merged.AddRange(input.Locations);
  1175 + }
  1176 +
  1177 + return merged.Count > 0 ? merged : null;
  1178 + }
  1179 +
987 1180 private static List<string> NormalizePartnerIds(TeamMemberCreateInputVo input)
988 1181 {
989 1182 var merged = new HashSet<string>(StringComparer.Ordinal);
... ... @@ -1020,7 +1213,11 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
1020 1213 {
1021 1214 var now = DateTime.Now;
1022 1215 var userIdString = userId.ToString();
1023   - var wanted = locationIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList();
  1216 + var wanted = locationIds
  1217 + .Where(x => !string.IsNullOrWhiteSpace(x))
  1218 + .Select(x => x.Trim())
  1219 + .Distinct(StringComparer.OrdinalIgnoreCase)
  1220 + .ToList();
1024 1221 var currentUserId = CurrentUser?.Id?.ToString();
1025 1222  
1026 1223 var validCount = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
... ... @@ -1032,17 +1229,24 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
1032 1229 throw new UserFriendlyException("存在无效门店,请刷新后重试");
1033 1230 }
1034 1231  
  1232 + // 含已逻辑删除行:唯一键 UK_userlocation_user_location 覆盖全历史,不能对已删行再 INSERT
1035 1233 var existing = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
1036 1234 .Where(x => x.UserId == userIdString)
1037 1235 .ToListAsync();
1038 1236  
1039   - var existingActive = existing.Where(x => !x.IsDeleted).ToList();
1040   - var existingActiveSet = existingActive.Select(x => x.LocationId).ToHashSet();
  1237 + var byLocation = existing
  1238 + .GroupBy(x => (x.LocationId ?? string.Empty).Trim(), StringComparer.OrdinalIgnoreCase)
  1239 + .ToDictionary(g => g.Key, g => g.OrderBy(x => x.IsDeleted).ThenByDescending(x => x.CreationTime).First(), StringComparer.OrdinalIgnoreCase);
1041 1240  
1042   - var toDelete = existingActive.Where(x => !wanted.Contains(x.LocationId)).ToList();
1043   - if (toDelete.Count > 0)
  1241 + var wantedSet = wanted.ToHashSet(StringComparer.OrdinalIgnoreCase);
  1242 +
  1243 + var toSoftDelete = existing
  1244 + .Where(x => !x.IsDeleted && !wantedSet.Contains((x.LocationId ?? string.Empty).Trim()))
  1245 + .Select(x => x.Id)
  1246 + .Distinct()
  1247 + .ToList();
  1248 + if (toSoftDelete.Count > 0)
1044 1249 {
1045   - var ids = toDelete.Select(x => x.Id).ToList();
1046 1250 await _dbContext.SqlSugarClient.Updateable<UserLocationDbEntity>()
1047 1251 .SetColumns(x => new UserLocationDbEntity
1048 1252 {
... ... @@ -1050,14 +1254,25 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
1050 1254 LastModificationTime = now,
1051 1255 LastModifierId = currentUserId
1052 1256 })
1053   - .Where(x => ids.Contains(x.Id))
  1257 + .Where(x => toSoftDelete.Contains(x.Id))
1054 1258 .ExecuteCommandAsync();
1055 1259 }
1056 1260  
1057   - var toInsert = wanted.Where(x => !existingActiveSet.Contains(x)).ToList();
1058   - if (toInsert.Count > 0)
  1261 + var toReviveIds = new List<string>();
  1262 + var toInsert = new List<UserLocationDbEntity>();
  1263 + foreach (var locationId in wanted)
1059 1264 {
1060   - var rows = toInsert.Select(locationId => new UserLocationDbEntity
  1265 + if (byLocation.TryGetValue(locationId, out var row))
  1266 + {
  1267 + if (row.IsDeleted)
  1268 + {
  1269 + toReviveIds.Add(row.Id);
  1270 + }
  1271 +
  1272 + continue;
  1273 + }
  1274 +
  1275 + toInsert.Add(new UserLocationDbEntity
1061 1276 {
1062 1277 Id = _guidGenerator.Create().ToString(),
1063 1278 IsDeleted = false,
... ... @@ -1066,9 +1281,25 @@ public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
1066 1281 UserId = userIdString,
1067 1282 LocationId = locationId,
1068 1283 ConcurrencyStamp = string.Empty
1069   - }).ToList();
  1284 + });
  1285 + }
1070 1286  
1071   - await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync();
  1287 + if (toReviveIds.Count > 0)
  1288 + {
  1289 + await _dbContext.SqlSugarClient.Updateable<UserLocationDbEntity>()
  1290 + .SetColumns(x => new UserLocationDbEntity
  1291 + {
  1292 + IsDeleted = false,
  1293 + LastModificationTime = now,
  1294 + LastModifierId = currentUserId
  1295 + })
  1296 + .Where(x => toReviveIds.Contains(x.Id))
  1297 + .ExecuteCommandAsync();
  1298 + }
  1299 +
  1300 + if (toInsert.Count > 0)
  1301 + {
  1302 + await _dbContext.SqlSugarClient.Insertable(toInsert).ExecuteCommandAsync();
1072 1303 }
1073 1304 }
1074 1305 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/appsettings.json
... ... @@ -20,8 +20,8 @@
20 20 },
21 21 //应用启动:SelfUrl 供 Program.cs UseUrls 绑定;用 0.0.0.0 避免写成固定局域网 IP 在本机无该网卡时启动失败(WinError 10049)
22 22 "App": {
23   - "SelfUrl": "http://192.168.31.88:19001",
24   - "CorsOrigins": "http://localhost:19001;http://localhost:18000;http://localhost:5666;http://localhost:5174;http://localhost:5173;http://localhost:3000"
  23 + "SelfUrl": "http://192.168.31.88:19003",
  24 + "CorsOrigins": "http://localhost:19003;http://localhost:18000;http://localhost:5666;http://localhost:5174;http://localhost:5173;http://localhost:3000"
25 25 },
26 26 //配置
27 27 "Settings": {
... ... @@ -32,7 +32,7 @@
32 32 "DbList": [ "Sqlite", "Mysql", "Sqlserver", "Oracle", "PostgreSQL" ],
33 33  
34 34 "DbConnOptions": {
35   - "Url": "server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;port=3306;database=antis-foodlabeling-us;uid=netteam;pwd=netteam;CharSet=utf8mb4;",
  35 + "Url": "server=rm-bp19ohrgc6111ynzh1o.mysql.rds.aliyuncs.com;port=3306;database=antis-foodlabeling-test-us;uid=netteam;pwd=netteam;CharSet=utf8mb4;",
36 36 "DbType": "Mysql",
37 37 "EnabledReadWrite": false,//是否启用读写分离
38 38 "EnabledCodeFirst": false,//是否通过代码优先创建数据库表结构
... ...
项目相关文档/2026-07-23泰额版平台与公司登录拉菜单用法.md 0 → 100644
  1 +# 2026-07-23 泰额版:平台 / 公司登录与拉菜单用法
  2 +
  3 +本文说明泰额版 **平台管理员** 与 **公司账号** 应如何登录、如何拉菜单,以及完整传参 / 反参示例。
  4 +
  5 +> 相关文档:
  6 +> - `2026-07-23泰额版当前登录账号菜单接口.md`
  7 +> - `2026-07-23泰额版平台端操作公司级账号逻辑.md`
  8 +
  9 +---
  10 +
  11 +## 一、先分清两套身份
  12 +
  13 +| 角色 | 登录接口 | 用户所在库 | 拉菜单接口 | 菜单数据来源 |
  14 +|------|----------|------------|------------|--------------|
  15 +| **平台管理员** | `POST /api/app/th-web-auth/login`(选 **Default** + 平台邮箱)或 `POST /api/app/account/login` | 主库 `antis-foodlabeling-host`.`user` | `GET /api/app/account/Vue3Router/vben5`(H5 实际调用) | 主库 `menu`(`MenuSource=0`,后端 vben5 空时回退 Ruoyi) |
  16 +| **公司账号** | `POST /api/app/th-web-auth/login`(选具体公司 tenantId) | 该公司业务库 `user` | `GET /api/app/auth-session/my-menus` 或 H5 侧 `Vue3Router/vben5` | **当前租户业务库** `menu` |
  17 +
  18 +**H5 同一登录框(2026-07-23 后端兼容):**
  19 +
  20 +- 前端**始终**打 `POST /api/app/th-web-auth/login`,不会打 `account/login`。
  21 +- 下拉选 **Default**(`tenantId=11111111-1111-1111-1111-111111111111`)+ 邮箱 `admin@example.com` → 后端在主库校验,签发**无 TenantId** 的平台 Token,`tenantName` 返回 `Platform`。
  22 +- 下拉选 **具体公司**(如「中国麦当劳公司」)→ 仍在该公司业务库校验,JWT 含 `TenantId`。
  23 +- Default 租户业务库可能指向 `antis-foodlabeling-us`;平台邮箱账号**不会**误入 US 库。
  24 +
  25 +**禁止混用:**
  26 +
  27 +- 公司 Token 不要用平台-only 的管理接口(无租户上下文)
  28 +- 平台 Token 调 `auth-session/my-menus` 现已可读主库菜单(兼容);H5 仍优先 `Vue3Router/vben5`
  29 +
  30 +Base URL 示例:`http://127.0.0.1:19002`(以部署为准)。
  31 +
  32 +```mermaid
  33 +flowchart TB
  34 + subgraph platform [平台管理员]
  35 + P1[POST account/login]
  36 + P2[GET account/Vue3Router/ruoyi]
  37 + PHost[(antis-foodlabeling-host)]
  38 + P1 --> PHost
  39 + P2 --> PHost
  40 + end
  41 + subgraph company [公司账号]
  42 + C1[POST th-web-auth/login]
  43 + C2[GET auth-session/my-menus]
  44 + CBiz[(租户业务库)]
  45 + C1 --> CBiz
  46 + C2 --> CBiz
  47 + end
  48 +```
  49 +
  50 +---
  51 +
  52 +## 二、平台管理员
  53 +
  54 +### 2.1 登录
  55 +
  56 +| 项 | 值 |
  57 +|----|------|
  58 +| 方法 / 路径 | `POST /api/app/account/login` |
  59 +| Content-Type | `application/json` |
  60 +| 说明 | 校验主库用户;登录框按 **邮箱** 匹配(`Email` 优先,其次 `UserName`);值须含 `@` |
  61 +
  62 +#### 传参(`LoginInputVo`)
  63 +
  64 +| 字段 | 类型 | 必填 | 说明 |
  65 +|------|------|------|------|
  66 +| `userName` | string | 是 | **填邮箱**,例如种子账号 `admin@example.com`(字段名虽为 userName) |
  67 +| `password` | string | 是 | 明文密码;种子默认 `123456` |
  68 +| `uuid` | string | 视环境 | 验证码会话 Id;未开验证码可省略 |
  69 +| `code` | string | 视环境 | 图形验证码;未开验证码可省略 |
  70 +
  71 +```json
  72 +{
  73 + "userName": "admin@example.com",
  74 + "password": "123456"
  75 +}
  76 +```
  77 +
  78 +#### 反参(`LoginOutputDto`)
  79 +
  80 +| 字段 | 类型 | 说明 |
  81 +|------|------|------|
  82 +| `token` | string | JWT(后续请求 `Authorization: Bearer {token}`) |
  83 +| `refreshToken` | string | 刷新令牌 |
  84 +
  85 +```json
  86 +{
  87 + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  88 + "refreshToken": "..."
  89 +}
  90 +```
  91 +
  92 +> 若全局包装了统一响应,实际可能是 `{ "data": { "token": "...", "refreshToken": "..." } }`,以 Swagger / 实际返回为准。
  93 +
  94 +```bash
  95 +curl -X POST "http://127.0.0.1:19002/api/app/account/login" \
  96 + -H "Content-Type: application/json" \
  97 + -d "{\"userName\":\"admin@example.com\",\"password\":\"123456\"}"
  98 +```
  99 +
  100 +### 2.2 拉菜单(路由)
  101 +
  102 +| 项 | 值 |
  103 +|----|------|
  104 +| 方法 / 路径 | `GET /api/app/account/Vue3Router/ruoyi` |
  105 +| Header | `Authorization: Bearer {token}` |
  106 +| 说明 | 将当前用户菜单转为 **Ruoyi** 路由树。主库种子菜单 `MenuSource=0`,须用 **`ruoyi`**,不要用 `vben5`(否则按 `MenuSource=2` 过滤会空) |
  107 +
  108 +路径参数 `routerType`:
  109 +
  110 +| 值 | 对应 MenuSource | 何时用 |
  111 +|----|-----------------|--------|
  112 +| `ruoyi` | 0 | **当前平台种子推荐** |
  113 +| `pure` | 1 | Pure 前端 |
  114 +| `vben5` | 2 | 仅当菜单已标成 Vben5 时 |
  115 +
  116 +#### 传参
  117 +
  118 +无 Query Body;路径上带 `ruoyi` 即可。
  119 +
  120 +#### 反参
  121 +
  122 +返回 **前端路由结构数组/对象**(由 `Vue3RuoYiRouterBuild` 生成),不是 `auth-session/my-menus` 那种 `menus` 树。字段随框架版本可能含:`name`、`path`、`component`、`meta`、`children` 等。以实际 JSON / Swagger 为准。
  123 +
  124 +超级管理员(用户名 `admin`)会拿到对应 `MenuSource` 下全部未删菜单再构建路由。
  125 +
  126 +```bash
  127 +curl -G "http://127.0.0.1:19002/api/app/account/Vue3Router/ruoyi" \
  128 + -H "Authorization: Bearer <platform-token>"
  129 +```
  130 +
  131 +### 2.3 平台菜单数据位置
  132 +
  133 +| 库表 | 说明 |
  134 +|------|------|
  135 +| `antis-foodlabeling-host`.`menu` | 约 26 条:首页概览、平台管理/SAAS 公司、标签管理…、管理/账户管理… |
  136 +| `user` / `role` / `rolemenu` | 平台 `admin` 已绑定上述菜单 |
  137 +
  138 +种子脚本:`th-tenant-menu-seed.sql`(menu)、`th-platform-user-role-seed.sql`(user/role)。菜单 Id 均为合法 Guid(见下文「平台应有菜单清单」)。
  139 +
  140 +---
  141 +
  142 +## 七、平台应有菜单清单
  143 +
  144 +主库 `antis-foodlabeling-host`.`menu` 共 **26** 条未删食品 SaaS 菜单,`MenuSource=0`(Ruoyi)。根菜单 `ParentId='0'`;子菜单 `ParentId` 为父目录 Guid。
  145 +
  146 +| 菜单名称 | Id | router | routerName | component | menuType | parentId | orderNum | menuIcon |
  147 +|---|---|---|---|---:|---|---:|---:|---|
  148 +| 首页概览 | `f0010001-0001-4000-8000-000000000001` | `/analytics` | `FoodLabelingDashboard` | `/food-labeling/dashboard/index` | 1 | `0` | -1 | `lucide:layout-dashboard` |
  149 +| 平台管理 | `f0010002-0001-4000-8000-000000000002` | `/platform` | `FoodLabelingPlatform` | 空 | 0 | `0` | 5 | `lucide:cloud-cog` |
  150 +| SAAS 公司 | `f0010003-0001-4000-8000-000000000003` | `/platform/tenants` | `FoodLabelingPlatformTenants` | `/food-labeling/platform/tenants/index` | 1 | `f0010002-0001-4000-8000-000000000002` | 6 | `lucide:building` |
  151 +| 标签管理 | `f0010010-0001-4000-8000-000000000010` | `/labeling` | `FoodLabelingLabeling` | 空 | 0 | `0` | 10 | `lucide:tags` |
  152 +| 标签 | `f0010011-0001-4000-8000-000000000011` | `/labels` | `FoodLabelingLabels` | `/food-labeling/labeling/labels/index` | 1 | `f0010010-0001-4000-8000-000000000010` | 11 | `lucide:tag` |
  153 +| 标签分类 | `f0010012-0001-4000-8000-000000000012` | `/label-categories` | `FoodLabelingLabelCategories` | `/food-labeling/labeling/label-categories/index` | 1 | `f0010010-0001-4000-8000-000000000010` | 12 | `lucide:folder-tree` |
  154 +| 标签类型 | `f0010013-0001-4000-8000-000000000013` | `/label-types` | `FoodLabelingLabelTypes` | `/food-labeling/labeling/label-types/index` | 1 | `f0010010-0001-4000-8000-000000000010` | 13 | `lucide:layers` |
  155 +| 标签模板 | `f0010014-0001-4000-8000-000000000014` | `/label-templates` | `FoodLabelingLabelTemplates` | `/food-labeling/labeling/label-templates/index` | 1 | `f0010010-0001-4000-8000-000000000010` | 14 | `lucide:layout-template` |
  156 +| 多选选项集 | `f0010015-0001-4000-8000-000000000015` | `/multiple-options` | `FoodLabelingMultipleOptions` | `/food-labeling/labeling/multiple-options/index` | 1 | `f0010010-0001-4000-8000-000000000010` | 15 | `lucide:list-checks` |
  157 +| 业务模块 | `f0010020-0001-4000-8000-000000000020` | `/modules` | `FoodLabelingModules` | 空 | 0 | `0` | 15 | `lucide:boxes` |
  158 +| 培训 | `f0010021-0001-4000-8000-000000000021` | `/training` | `FoodLabelingTraining` | `/food-labeling/modules/training/index` | 1 | `f0010020-0001-4000-8000-000000000020` | 16 | `lucide:graduation-cap` |
  159 +| 告警 | `f0010022-0001-4000-8000-000000000022` | `/alerts` | `FoodLabelingAlerts` | `/food-labeling/modules/alerts/index` | 1 | `f0010020-0001-4000-8000-000000000020` | 17 | `lucide:bell` |
  160 +| 任务 | `f0010023-0001-4000-8000-000000000023` | `/tasks` | `FoodLabelingTasks` | `/food-labeling/modules/tasks/index` | 1 | `f0010020-0001-4000-8000-000000000020` | 18 | `lucide:list-todo` |
  161 +| 传感器 | `f0010024-0001-4000-8000-000000000024` | `/sensors` | `FoodLabelingSensors` | `/food-labeling/modules/sensors/index` | 1 | `f0010020-0001-4000-8000-000000000020` | 19 | `lucide:activity` |
  162 +| 食物浪费 | `f0010025-0001-4000-8000-000000000025` | `/food-waste` | `FoodLabelingFoodWaste` | `/food-labeling/modules/food-waste/index` | 1 | `f0010020-0001-4000-8000-000000000020` | 20 | `lucide:apple` |
  163 +| 电子标签 | `f0010026-0001-4000-8000-000000000026` | `/e-label-module` | `FoodLabelingELabelModule` | `/food-labeling/modules/e-label/index` | 1 | `f0010020-0001-4000-8000-000000000020` | 21 | `lucide:file-digit` |
  164 +| 管理 | `f0010030-0001-4000-8000-000000000030` | `/management` | `FoodLabelingManagement` | 空 | 0 | `0` | 20 | `lucide:building-2` |
  165 +| 账户管理 | `f0010031-0001-4000-8000-000000000031` | `/account-management` | `FoodLabelingAccountManagement` | `/food-labeling/management/account-management/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 21 | `lucide:users` |
  166 +| 系统菜单 | `f0010032-0001-4000-8000-000000000032` | `/system-menu` | `FoodLabelingSystemMenu` | `/food-labeling/management/system-menu/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 22 | `lucide:menu-square` |
  167 +| 菜单管理 | `f0010033-0001-4000-8000-000000000033` | `/menu-management` | `FoodLabelingMenuManagement` | `/food-labeling/management/menu-management/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 23 | `lucide:utensils` |
  168 +| 设备 | `f0010034-0001-4000-8000-000000000034` | `/devices` | `FoodLabelingDevices` | `/food-labeling/modules/devices/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 24 | `lucide:smartphone` |
  169 +| 报表 | `f0010035-0001-4000-8000-000000000035` | `/reports` | `FoodLabelingReports` | `/food-labeling/management/reports/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 25 | `lucide:file-bar-chart` |
  170 +| 发票 | `f0010036-0001-4000-8000-000000000036` | `/invoices` | `FoodLabelingInvoices` | `/food-labeling/modules/invoices/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 26 | `lucide:receipt` |
  171 +| 二维码 | `f0010037-0001-4000-8000-000000000037` | `/qr-codes` | `FoodLabelingQrCodes` | `/food-labeling/modules/qr-codes/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 27 | `lucide:qr-code` |
  172 +| 支持 | `f0010038-0001-4000-8000-000000000038` | `/support` | `FoodLabelingSupport` | `/food-labeling/management/support/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 28 | `lucide:life-buoy` |
  173 +| API | `f0010039-0001-4000-8000-000000000039` | `/api-settings` | `FoodLabelingApi` | `/food-labeling/modules/api/index` | 1 | `f0010030-0001-4000-8000-000000000030` | 29 | `lucide:code-2` |
  174 +
  175 +平台 admin 账号:`UserName=admin`,`Email=admin@example.com`,`Id=f0020001-0001-4000-8000-000000000001`;角色 `Id=f0020002-0001-4000-8000-000000000002`;`rolemenu` 绑定上述 26 条菜单。
  176 +
  177 +---
  178 +
  179 +## 三、公司账号
  180 +
  181 +### 3.1 登录
  182 +
  183 +| 项 | 值 |
  184 +|----|------|
  185 +| 方法 / 路径 | `POST /api/app/th-web-auth/login` |
  186 +| Content-Type | `application/json` |
  187 +| 说明 | 须指定租户;在**该公司业务库**校验账号,JWT 含 `TenantId` |
  188 +
  189 +#### 传参(`ThWebLoginInputVo`)
  190 +
  191 +| 字段 | 类型 | 必填 | 说明 |
  192 +|------|------|------|------|
  193 +| `tenantId` | guid | 与 `tenantName` 二选一 | 租户 Id(推荐) |
  194 +| `tenantName` | string | 与 `tenantId` 二选一 | 租户名称,如 `中国麦当劳公司` |
  195 +| `userName` | string | 是 | 登录账号(可为邮箱或用户名) |
  196 +| `password` | string | 是 | 明文密码 |
  197 +| `uuid` / `code` | string | 视环境 | 验证码 |
  198 +
  199 +```json
  200 +{
  201 + "tenantId": "3a229a02-77eb-1dcc-afb6-09e2a2b6386c",
  202 + "userName": "admin",
  203 + "password": "123456"
  204 +}
  205 +```
  206 +
  207 +或:
  208 +
  209 +```json
  210 +{
  211 + "tenantName": "中国麦当劳公司",
  212 + "userName": "admin",
  213 + "password": "123456"
  214 +}
  215 +```
  216 +
  217 +#### 反参(`ThWebLoginOutputDto`)
  218 +
  219 +| 字段 | 类型 | 说明 |
  220 +|------|------|------|
  221 +| `token` | string | JWT(含租户与 RBAC Claims) |
  222 +| `refreshToken` | string | 刷新令牌 |
  223 +| `tenantId` | guid | 当前租户 Id |
  224 +| `tenantName` | string | 当前租户名称 |
  225 +
  226 +```json
  227 +{
  228 + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  229 + "refreshToken": "...",
  230 + "tenantId": "3a229a02-77eb-1dcc-afb6-09e2a2b6386c",
  231 + "tenantName": "中国麦当劳公司"
  232 +}
  233 +```
  234 +
  235 +```bash
  236 +curl -X POST "http://127.0.0.1:19002/api/app/th-web-auth/login" \
  237 + -H "Content-Type: application/json" \
  238 + -d "{\"tenantId\":\"3a229a02-77eb-1dcc-afb6-09e2a2b6386c\",\"userName\":\"admin\",\"password\":\"123456\"}"
  239 +```
  240 +
  241 +### 3.2 拉菜单
  242 +
  243 +| 项 | 值 |
  244 +|----|------|
  245 +| 方法 / 路径 | `GET /api/app/auth-session/my-menus` |
  246 +| Header | `Authorization: Bearer {公司 token}` |
  247 +| 建议 Header | `__tenant: {tenantId}`(与 JWT 中 TenantId 一致更稳妥) |
  248 +| 说明 | 读**当前租户业务库** `menu` + 角色;用户名为 `admin` 时返回该库全部未删菜单 |
  249 +
  250 +#### 传参
  251 +
  252 +无 Body;依赖 Token(及可选 `__tenant`)。
  253 +
  254 +#### 反参(`CurrentUserMenuPermissionsOutputDto`,字段一般为 camelCase)
  255 +
  256 +| 字段 | 类型 | 说明 |
  257 +|------|------|------|
  258 +| `user` | object | 当前用户简要信息 |
  259 +| `user.id` | guid/string | 用户 Id |
  260 +| `user.userName` | string | 登录名 |
  261 +| `user.nick` | string? | 昵称 |
  262 +| `user.email` | string? | 邮箱 |
  263 +| `user.icon` | string? | 头像 |
  264 +| `roleCodes` | string[] | 角色编码 |
  265 +| `permissionCodes` | string[] | 权限码(超管常见 `*:*:*`) |
  266 +| `accessPermissionCodes` | string[] | 角色访问权限编码 |
  267 +| `menus` | array | **菜单树**(根列表,`children` 嵌套) |
  268 +| `role` | string | 角色展示名 |
  269 +| `fullName` | string | 全名 |
  270 +| `lastUpdated` | datetime? | 供前端刷新缓存 |
  271 +
  272 +**菜单节点**(`CurrentUserMenuNodeDto`)常见字段:
  273 +
  274 +| 字段 | 说明 |
  275 +|------|------|
  276 +| `id` / `parentId` | 菜单 Id、父 Id(根多为 `"0"`) |
  277 +| `menuName` | 名称 |
  278 +| `router` / `routerName` / `component` | 路由与组件 |
  279 +| `menuType` / `menuSource` / `orderNum` | 类型、来源、排序 |
  280 +| `permissionCode` | 权限码 |
  281 +| `menuIcon` / `isShow` / `state` | 图标、显示、启用 |
  282 +| `children` | 子节点数组 |
  283 +
  284 +```json
  285 +{
  286 + "user": {
  287 + "id": "...",
  288 + "userName": "admin",
  289 + "nick": "超级管理员",
  290 + "email": "admin@example.com"
  291 + },
  292 + "roleCodes": ["admin"],
  293 + "permissionCodes": ["*:*:*"],
  294 + "accessPermissionCodes": [],
  295 + "menus": [
  296 + {
  297 + "id": "...",
  298 + "parentId": "0",
  299 + "menuName": "系统管理",
  300 + "router": "/system",
  301 + "orderNum": 100,
  302 + "children": []
  303 + }
  304 + ],
  305 + "role": "管理员",
  306 + "fullName": "超级管理员",
  307 + "lastUpdated": "2026-07-23T10:00:00"
  308 +}
  309 +```
  310 +
  311 +```bash
  312 +curl -G "http://127.0.0.1:19002/api/app/auth-session/my-menus" \
  313 + -H "Authorization: Bearer <company-token>" \
  314 + -H "__tenant: 3a229a02-77eb-1dcc-afb6-09e2a2b6386c"
  315 +```
  316 +
  317 +未识别租户时典型错误:
  318 +
  319 +> 未识别租户上下文。请使用泰额登录接口(th-web-auth / th-app-auth)或请求头 `__tenant` 携带租户 Id。
  320 +
  321 +### 3.3 公司库菜单现状说明
  322 +
  323 +| 库 | 当前菜单大致情况 |
  324 +|----|------------------|
  325 +| 主库 host | 已种食品 SaaS 中文菜单(平台用) |
  326 +| Default → `antis-foodlabeling-us` | 美国版英文业务菜单 |
  327 +| 如 `antis-foodlabeling-t46d56528`(中国麦当劳) | 多为 **Yi 框架**种子(系统管理/用户管理…),**尚未**种 `th-tenant-menu-seed.sql` 那套食品 SaaS 菜单 |
  328 +
  329 +若公司端侧边栏也要与平台同一套「首页概览 / 标签管理 / 业务模块…」:
  330 +
  331 +1. 对该公司业务库执行 `项目相关文档/th-tenant-menu-seed.sql`(**禁止**对 `antis-foodlabeling-us` 执行,以免污染美国版)
  332 +2. 为公司管理员角色绑定对应 `rolemenu`(或公司 `admin` 用户名可直接看全库未删菜单)
  333 +3. 再用 `th-web-auth/login` + `auth-session/my-menus` 验证
  334 +
  335 +---
  336 +
  337 +## 四、对照速查
  338 +
  339 +| 步骤 | 平台管理员(H5 选 Default) | 公司账号 |
  340 +|------|------------|----------|
  341 +| 1. 登录 | `POST /api/app/th-web-auth/login`(Default tenantId + 邮箱) | `POST /api/app/th-web-auth/login` |
  342 +| 2. 关键 | Default `tenantId` + 邮箱 `userName` + `password` | 公司 `tenantId` + `userName` + `password` |
  343 +| 3. 登录反参 | `token` / `refreshToken` / `tenantName=Platform` | `token` / `refreshToken` / `tenantId` / `tenantName` |
  344 +| 4. 拉菜单(H5) | `GET /api/app/account/Vue3Router/vben5` | `GET /api/app/auth-session/my-menus` 或 H5 同 vben5 |
  345 +| 5. 菜单 Header | `Authorization: Bearer …` | `Authorization` + 建议 `__tenant` |
  346 +| 6. 菜单反参形态 | Vben5 路由树(Ruoyi 回退) | `user` + `menus` 树 + 权限码 |
  347 +
  348 +---
  349 +
  350 +## 五、前端对接注意(H5 / Vben5,只读不改源码)
  351 +
  352 +1. **登录**:`POST /api/app/th-web-auth/login`(见 `th-web-auth.ts`),Header 带 `__tenant: tenantId`。
  353 +2. **登录 Body**:`tenantId`、`userName`(Email 字段)、`password`;可选 `uuid`/`code`(验证码)。
  354 +3. **tenant-select**:`GET /api/app/th-multi-tenancy/tenant-select` 现返回 `id` + `name`;前端用 `id` 作为下拉 value。
  355 +4. **平台管理员操作**:选 **Default**,邮箱填 `admin@example.com`,密码 `123456`。
  356 +5. **公司操作**:选 **中国麦当劳公司** 等公司项,填该公司业务库账号(如 `admin`)。
  357 +6. **拉菜单**:H5 dist 实际调 `GET /api/app/account/Vue3Router/vben5`(非 `my-menus`);平台 Token 无租户即可;主库 `MenuSource=0` 时后端自动回退 Ruoyi 再 Vben5 构建。
  358 +7. **公司端**仍可用 `auth-session/my-menus`(须 Token + `__tenant`)。
  359 +8. 统一响应包装时从 `data` 取 `token` / `menus`。
  360 +
  361 +### 5.1 H5 登录请求示例(平台)
  362 +
  363 +```json
  364 +POST /api/app/th-web-auth/login
  365 +Header: __tenant: 11111111-1111-1111-1111-111111111111
  366 +Body:
  367 +{
  368 + "tenantId": "11111111-1111-1111-1111-111111111111",
  369 + "userName": "admin@example.com",
  370 + "password": "123456"
  371 +}
  372 +```
  373 +
  374 +成功反参(平台):
  375 +
  376 +```json
  377 +{
  378 + "token": "eyJ...",
  379 + "refreshToken": "...",
  380 + "tenantId": "00000000-0000-0000-0000-000000000000",
  381 + "tenantName": "Platform"
  382 +}
  383 +```
  384 +
  385 +### 5.2 H5 登录请求示例(公司)
  386 +
  387 +```json
  388 +{
  389 + "tenantId": "3a229a02-77eb-1dcc-afb6-09e2a2b6386c",
  390 + "userName": "admin",
  391 + "password": "123456"
  392 +}
  393 +```
  394 +
  395 +---
  396 +
  397 +## 六、自检清单
  398 +
  399 +- [ ] H5 选 Default + `admin@example.com`:`th-web-auth/login` 成功,`tenantName=Platform`,Token 无 TenantId Claim
  400 +- [ ] 平台:`Vue3Router/vben5` 返回非空路由(Ruoyi 菜单回退)
  401 +- [ ] 公司:选公司 tenantId + 公司账号登录未破坏;`my-menus` 带 `__tenant` 成功
  402 +- [ ] 公司业务库菜单内容符合产品预期(必要时单独执行租户库 menu 种子,避开 US 库)
... ...
项目相关文档/2026-07-23泰额版平台端操作公司级账号逻辑.md 0 → 100644
  1 +# 2026-07-23 泰额版:平台端操作公司级账号逻辑说明
  2 +
  3 +本文档说明 **泰额版** 平台管理端如何操作「公司级」账号与权限:架构、接口、写库路径、与公司端登录的关系,以及**不得影响美国版**的隔离约束。
  4 +
  5 +相关文档:
  6 +
  7 +- `2026-07-21代码优化.md`:公司列表 / 改密 / SaaS 菜单与角色菜单接口细节
  8 +- `2026-07-23泰额版当前登录账号菜单接口.md`:平台 vs 公司「当前登录人菜单」
  9 +- `th-tenant-menu-seed.sql` / `th-platform-user-role-seed.sql`:平台主库菜单与 User/Role 种子
  10 +
  11 +---
  12 +
  13 +## 一、角色与库分层
  14 +
  15 +| 端 | 登录 | 用户库 | 典型能力 |
  16 +|----|------|--------|----------|
  17 +| **平台端** | `POST /api/app/account/login` | 主库 `antis-foodlabeling-host`.`user` | 公司列表、改公司管理员、分配 SaaS 菜单、改公司角色菜单、开通/删除公司 |
  18 +| **公司端** | `POST /api/app/th-web-auth/login`(须 `tenantId`) | 该公司业务库 `user` | 业务功能;`GET /api/app/auth-session/my-menus` |
  19 +
  20 +**公司 = 租户**:主库表 `YiTenant`(前端常把 `name` 当 `companyName`)。
  21 +
  22 +```mermaid
  23 +flowchart LR
  24 + subgraph platform [平台端]
  25 + PLogin[account/login]
  26 + PHost[(antis-foodlabeling-host)]
  27 + PLogin --> PHost
  28 + end
  29 + subgraph company [公司端]
  30 + CLogin[th-web-auth/login]
  31 + CBiz[(租户业务库)]
  32 + CLogin --> CBiz
  33 + end
  34 + PHost -->|YiTenant.TenantConnectionString| CBiz
  35 + PHost -->|改 admin / 角色菜单| CBiz
  36 +```
  37 +
  38 +### 1.1 库职责
  39 +
  40 +| 库 | 配置来源 | 平台操作相关表 |
  41 +|----|----------|----------------|
  42 +| **主库** `antis-foodlabeling-host` | `DbConnOptions.Url` | `YiTenant`、`fl_th_tenant_admin_credential`、`fl_th_tenant_menu_permission`、平台 `user`/`role`/`menu`/`userrole`/`rolemenu` |
  43 +| **租户业务库** `antis-foodlabeling-{key}` | `YiTenant.TenantConnectionString` | 公司 `User`(登录哈希)、`Role`/`RoleMenu`/`Menu`、业务表 |
  44 +| **美国版库** `antis-foodlabeling-us` | 美版 `appsettings`;泰额 **Default** 租户连接串也指向此库 | **禁止**当作泰额新公司业务库去写平台种子;对 Default 改 admin/角色会动到美版数据 |
  45 +
  46 +### 1.2 两层「菜单」勿混淆
  47 +
  48 +| 层 | 存储 | 接口 | 语义 |
  49 +|----|------|------|------|
  50 +| **SaaS 公司开通模块** | 主库 `fl_th_tenant_menu_permission`(`menuPermissionKeys`) | `menu-permission-tree` / `company-menus` | 该公司「能开哪些产品模块」 |
  51 +| **公司 RBAC 菜单** | 租户库 `Menu` + `RoleMenu` | `company-roles` / `company-role-menus`;公司端 `auth-session/my-menus` | 该公司内角色能看哪些业务菜单 |
  52 +
  53 +平台主库种子菜单(`th-menu-*`)服务于**平台端自身路由**,不是公司业务库菜单。
  54 +
  55 +---
  56 +
  57 +## 二、平台端操作公司账号:能力总览
  58 +
  59 +实现类:`ThMultiTenancyAppService`(路由前缀 `/api/app/th-multi-tenancy/`)。
  60 +开通公司:`ThTenantProvisioningAppService`(`/api/app/th-tenant-provisioning/`)。
  61 +
  62 +| 能力 | 方法 / 路径 | 主要读写 |
  63 +|------|-------------|----------|
  64 +| 登录页租户下拉(匿名) | `GET .../tenant-select` | 读主库租户 + 凭据(无 Id) |
  65 +| 公司分页列表 | `GET .../company-list` | 读主库租户 + 凭据 + SaaS keys |
  66 +| 改公司管理员账号/密码 | `PUT .../company-admin` | 写**租户库 User** + 写主库凭据表 |
  67 +| SaaS 权限树 | `GET .../menu-permission-tree` | 代码目录,无库 |
  68 +| 查/设公司 SaaS 菜单 | `GET/PUT .../company-menus` | 读写主库 `fl_th_tenant_menu_permission` |
  69 +| 查公司角色 | `GET .../company-roles` | 读租户库 `Role`/`RoleMenu` |
  70 +| 设公司角色菜单 | `PUT .../company-role-menus` | 写租户库 `RoleMenu` |
  71 +| 开通公司 | `POST /th-tenant-provisioning/provision` | 写主库 `YiTenant` + 建业务库/Init |
  72 +| 删除公司 | `DELETE .../company/{tenantId}` | 软删主库租户;可选 DROP 业务库 |
  73 +
  74 +鉴权:除 `tenant-select` 外,平台管理接口需 **平台 Token**(`account/login`)。
  75 +
  76 +---
  77 +
  78 +## 三、核心数据流
  79 +
  80 +### 3.1 开通公司(产生「公司级账号」)
  81 +
  82 +```text
  83 +平台管理员
  84 + → POST /api/app/th-tenant-provisioning/provision
  85 + → 主库写入 YiTenant(Name + TenantConnectionString)
  86 + → EnsureDatabaseCreated(物理建库)
  87 + → 后台 InitAsync:业务库 CodeFirst + Seed
  88 + └─ 默认 User.UserName = admin,密码 = RbacOptions.AdminPassword(哈希)
  89 +```
  90 +
  91 +要点:
  92 +
  93 +- 开通**不会**自动写 `fl_th_tenant_admin_credential`。
  94 +- 列表回显密码:无凭据行时用配置默认密码现场 AES 加密;平台首次 `PUT company-admin` 后才持久化可回显密文。
  95 +- Init 异步未完成时,立刻改 admin / 公司登录可能失败。
  96 +
  97 +### 3.2 平台改公司管理员(重点)
  98 +
  99 +接口:`PUT /api/app/th-multi-tenancy/company-admin`
  100 +
  101 +```text
  102 +1. CurrentTenant=null(主库 scope)
  103 + 读 YiTenant、fl_th_tenant_admin_credential(当前 LoginAccount,默认 admin)
  104 +2. 若有 password + passwordSalt:AES 解密得明文
  105 +3. 用 YiTenant.TenantConnectionString 建独立 SqlSugarClient(直连业务库,不靠 Change(tenantId) 切仓储)
  106 +4. 在业务库 User 上:
  107 + - 可选改 UserName(查重)
  108 + - 可选 ApplyPlainPassword → 写 Password/Salt 哈希
  109 +5. Upsert 主库 fl_th_tenant_admin_credential(LoginAccount + AES 密文 + IV)
  110 +```
  111 +
  112 +| 写入位置 | 字段语义 |
  113 +|----------|----------|
  114 +| 租户库 `User.Password/Salt` | **登录校验用哈希**(不可逆) |
  115 +| 主库 `PasswordCipher/PasswordIv` | **列表回显用 AES 密文**(可逆,密钥 `FoodLabeling:TenantSelectCrypto`) |
  116 +
  117 +前端改密必须先按约定 AES 加密再提交,**禁止**明文密码直传。
  118 +
  119 +### 3.3 平台分配公司 SaaS 菜单
  120 +
  121 +```text
  122 +GET menu-permission-tree → 静态 Key 树(ThSaasMenuPermissionCatalog)
  123 +GET company-menus?tenantId= → 读主库已分配 keys
  124 +PUT company-menus → 主库先删后插 fl_th_tenant_menu_permission
  125 +```
  126 +
  127 +不写租户库 `Menu`/`RoleMenu`。非法 key 会校验失败。
  128 +
  129 +### 3.4 平台配置公司内角色菜单
  130 +
  131 +```text
  132 +GET company-roles?tenantId= → 直连租户库读 Role + RoleMenu.menuIds
  133 +PUT company-role-menus → 直连租户库覆盖 RoleMenu(校验 Menu 存在)
  134 +```
  135 +
  136 +这与公司员工登录后看到的 `auth-session/my-menus` 同源(租户库 RBAC)。
  137 +
  138 +### 3.5 删除公司
  139 +
  140 +```text
  141 +DELETE /api/app/th-multi-tenancy/company/{tenantId}?dropDatabase=true
  142 + → 禁止删除 Default 租户
  143 + → DROP 业务库时禁止库名 host / us(及受保护库)
  144 + → 软删 YiTenant
  145 + → 删凭据表、SaaS 菜单权限、租户缓存
  146 +```
  147 +
  148 +---
  149 +
  150 +## 四、跨库访问约定(实现要点)
  151 +
  152 +| 约定 | 说明 |
  153 +|------|------|
  154 +| 主库表 | 实体带 `[DefaultTenantTable]`;读写前 `CurrentTenant.Change(null)`,连接走 `DbConnOptions.Url`(host) |
  155 +| 租户业务库 | `TenantBusinessDatabaseAccessor`:按 `YiTenant` 连接串 **独立 SqlSugarClient**,避免空连接串误连主库 |
  156 +| 不信任 JWT 租户 | 平台改公司数据时以入参 `tenantId` + 主库登记的连接串为准,不以「当前登录租户」替代目标公司 |
  157 +
  158 +---
  159 +
  160 +## 五、与美国版隔离(强制)
  161 +
  162 +| 规则 | 说明 |
  163 +|------|------|
  164 +| 平台种子只写 **host** | `menu` / `user` / `role` 等平台数据禁止写入 `antis-foodlabeling-us` |
  165 +| Default 租户 = US 业务库 | host 中 Default 的 `TenantConnectionString` 指向 `antis-foodlabeling-us`;对该租户执行 `company-admin` / `company-role-menus` **会改美版库** |
  166 +| 新公司必须独立库 | Provision 使用 `antis-foodlabeling-{tenant}` 模板;勿把新公司指到 `us` |
  167 +| 删除保护 | 不可删 Default;不可 DROP `host`/`us` |
  168 +| Token 隔离 | 平台 Token 调公司 `my-menus` 会因无租户失败;公司 Token 勿当平台管理凭证 |
  169 +
  170 +**建议**:平台运营界面操作公司时,只选择泰额独立业务库租户(如 `中国麦当劳公司` → `antis-foodlabeling-t46d56528`),避免点选 Default。
  171 +
  172 +---
  173 +
  174 +## 六、接口速查(含 curl)
  175 +
  176 +Base:`http://127.0.0.1:19002`(以环境为准)。
  177 +
  178 +```bash
  179 +# 平台登录
  180 +curl -X POST "http://127.0.0.1:19002/api/app/account/login" \
  181 + -H "Content-Type: application/json" \
  182 + -d "{\"userName\":\"admin@example.com\",\"password\":\"123456\"}"
  183 +
  184 +# 公司列表(含加密账号密码)
  185 +curl -G "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-list" \
  186 + --data-urlencode "skipCount=0" --data-urlencode "maxResultCount=20" \
  187 + -H "Authorization: Bearer <platform-token>"
  188 +
  189 +# 改公司管理员(password 为前端 AES 密文)
  190 +curl -X PUT "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-admin" \
  191 + -H "Authorization: Bearer <platform-token>" \
  192 + -H "Content-Type: application/json" \
  193 + -d "{\"tenantId\":\"<guid>\",\"loginAccount\":\"admin\",\"password\":\"<cipher>\",\"passwordSalt\":\"<iv>\"}"
  194 +
  195 +# 公司 SaaS 菜单
  196 +curl -G "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-menus" \
  197 + --data-urlencode "tenantId=<guid>" \
  198 + -H "Authorization: Bearer <platform-token>"
  199 +
  200 +# 公司角色
  201 +curl -G "http://127.0.0.1:19002/api/app/th-multi-tenancy/company-roles" \
  202 + --data-urlencode "tenantId=<guid>" \
  203 + -H "Authorization: Bearer <platform-token>"
  204 +```
  205 +
  206 +公司端自测登录(验证平台改密是否生效):
  207 +
  208 +```bash
  209 +curl -X POST "http://127.0.0.1:19002/api/app/th-web-auth/login" \
  210 + -H "Content-Type: application/json" \
  211 + -d "{\"tenantId\":\"<guid>\",\"userName\":\"admin\",\"password\":\"新明文密码\"}"
  212 +```
  213 +
  214 +---
  215 +
  216 +## 七、关键代码路径
  217 +
  218 +| 模块 | 路径 |
  219 +|------|------|
  220 +| 平台多租户服务 | `FoodLabeling.Th.Application/Services/ThMultiTenancyAppService.cs` |
  221 +| 开通租户 | `FoodLabeling.Th.Application/Services/ThTenantProvisioningAppService.cs` |
  222 +| 业务库直连 | `FoodLabeling.Th.Application/MultiTenancy/TenantBusinessDatabaseAccessor.cs` |
  223 +| AES 凭据 | `FoodLabeling.Th.Application/MultiTenancy/TenantSelectCredentialCipher.cs` |
  224 +| SaaS Key 目录 | `FoodLabeling.Th.Application/MultiTenancy/ThSaasMenuPermissionCatalog.cs` |
  225 +| 主库凭据实体 | `FoodLabeling.Th.Domain/Entities/ThTenantAdminCredentialEntity.cs` |
  226 +| 主库 SaaS 菜单实体 | `FoodLabeling.Th.Domain/Entities/ThTenantMenuPermissionEntity.cs` |
  227 +| 公司 Web 登录 | `FoodLabeling.Th.Application/Services/ThWebAuthAppService.cs` |
  228 +| 平台登录 | `Yi.Framework.Rbac.Application/Services/AccountService.cs` |
  229 +
  230 +---
  231 +
  232 +## 八、风险与自检
  233 +
  234 +| 风险 | 应对 |
  235 +|------|------|
  236 +| 对 Default 改密 / 改角色 → 写 US | 运营禁操作 Default;删除已有保护,改密无同等硬拦 |
  237 +| 凭据表可逆 AES | 保护 `TenantSelectCrypto:SecretKey`;仅平台管理员可见列表 |
  238 +| 开通未写凭据表 | 改密走平台接口后才会与列表回显一致 |
  239 +| Init 未完成就改 admin | 等「后台初始化完成」或调 `initialize-tenant-database` |
  240 +| SaaS keys ≠ RBAC 菜单 | 配置完 `company-menus` 后,公司内角色仍需 `company-role-menus` / 公司端角色管理 |
  241 +
  242 +自检清单:
  243 +
  244 +- [ ] 平台用 `account/login`(host `admin`),公司用 `th-web-auth/login`
  245 +- [ ] `company-list` 能看到目标公司 Id 与加密凭据
  246 +- [ ] `company-admin` 后,公司端可用新密码登录;主库凭据表有对应行
  247 +- [ ] `company-menus` 只改 host;`company-role-menus` 只改目标租户库
  248 +- [ ] `antis-foodlabeling-us` 的 user/role 数量未因平台种子脚本变化
  249 +- [ ] 未对 Default / `database=us` 执行 DROP
  250 +
  251 +---
  252 +
  253 +## 九、小结
  254 +
  255 +平台端操作公司级账号的本质是:
  256 +
  257 +1. **元数据与回显凭据**放在主库 host;
  258 +2. **真正能登录的公司账号**在各公司业务库 `User`;
  259 +3. 平台通过 `tenantId` + 直连连接串跨库改公司 admin / 角色菜单;
  260 +4. SaaS 模块开关与公司内 RBAC 是两层模型;
  261 +5. **永远不要把美国版库当成泰额平台种子库或随意操作 Default 租户。**
... ...
项目相关文档/2026-07-23泰额版当前登录账号菜单接口.md 0 → 100644
  1 +# 2026-07-23 泰额版:当前登录账号菜单接口
  2 +
  3 +本文档说明 **泰额版**「获取当前登录账号菜单」的两套接口:**平台级**与**公司级(租户)**。
  4 +
  5 +> 与「平台给公司配置 SaaS 菜单权限」(`menu-permission-tree` / `company-menus`)不同:
  6 +> 本文接口返回的是**当前登录人自己可见的菜单**,用于侧边栏 / 动态路由 / 按钮权限。
  7 +
  8 +---
  9 +
  10 +## 一、对照一览
  11 +
  12 +| 维度 | 平台级 | 公司级(租户业务端) |
  13 +|------|--------|----------------------|
  14 +| 适用角色 | 平台管理员(Yi 框架 / 主库账号) | 某公司(租户)下的业务管理员 / 员工 |
  15 +| 登录接口 | `POST /api/app/account/login` | `POST /api/app/th-web-auth/login`(须带 `tenantId`) |
  16 +| 拉菜单接口 | `GET /api/app/account/Vue3Router/vben5` | `GET /api/app/auth-session/my-menus` |
  17 +| 备选 | `GET /api/app/account`(用户+角色+菜单) | — |
  18 +| 数据来源 | 当前 Account 上下文下的 `menu`(按 `MenuSource` 过滤) | **当前租户业务库** `menu` + 角色菜单 |
  19 +| 租户上下文 | 一般**无** `__tenant` / JWT TenantId | JWT 含 `TenantId`,建议 Header 再带 `__tenant` |
  20 +| 实现 | `AccountService.GetVue3Router` | `AuthSessionAppService.GetMyMenusAsync` |
  21 +
  22 +**Base URL 示例**:`http://127.0.0.1:19002`(以 `appsettings` / 部署环境为准)。
  23 +
  24 +---
  25 +
  26 +## 二、公司级:当前登录账号菜单
  27 +
  28 +### 2.1 接口
  29 +
  30 +| 项 | 值 |
  31 +|----|------|
  32 +| 方法 / 路径 | `GET /api/app/auth-session/my-menus` |
  33 +| 鉴权 | `Authorization: Bearer {token}` |
  34 +| 租户 | Token 须来自 `th-web-auth/login`;建议同时传 Header `__tenant: {tenantId}` |
  35 +| 说明 | 返回当前用户角色、权限码、可见菜单树;未识别租户时会报错引导使用泰额登录 |
  36 +
  37 +### 2.2 登录(前置)
  38 +
  39 +```http
  40 +POST /api/app/th-web-auth/login
  41 +Content-Type: application/json
  42 +
  43 +{
  44 + "tenantId": "11111111-1111-1111-1111-111111111111",
  45 + "userName": "admin",
  46 + "password": "123456"
  47 +}
  48 +```
  49 +
  50 +> 不要用 `POST /api/app/account/login` 作为泰额公司端主登录(无租户会查主库)。
  51 +
  52 +### 2.3 curl 示例
  53 +
  54 +```bash
  55 +# 1) 登录拿 Token
  56 +curl -X POST "http://127.0.0.1:19002/api/app/th-web-auth/login" \
  57 + -H "Content-Type: application/json" \
  58 + -d "{\"tenantId\":\"<tenantId>\",\"userName\":\"admin\",\"password\":\"123456\"}"
  59 +
  60 +# 2) 拉当前账号菜单
  61 +curl -G "http://127.0.0.1:19002/api/app/auth-session/my-menus" \
  62 + -H "Authorization: Bearer <token>" \
  63 + -H "__tenant: <tenantId>"
  64 +```
  65 +
  66 +### 2.4 出参要点(`CurrentUserMenuPermissionsOutputDto`)
  67 +
  68 +| 字段 | 说明 |
  69 +|------|------|
  70 +| `user` | 当前用户简要信息 |
  71 +| `roleCodes` | 角色编码列表 |
  72 +| `permissionCodes` | 权限码列表(超管常见 `*:*:*`) |
  73 +| `accessPermissionCodes` | 角色访问权限编码(如 `manage_people`) |
  74 +| `menus` | 菜单树(`children` 嵌套;根 `parentId` 多为 `"0"`) |
  75 +| `role` | 角色展示名 |
  76 +| `fullName` | 全名 |
  77 +| `lastUpdated` | 系统编辑 / 业务变更相关时间(供前端刷新缓存) |
  78 +
  79 +未带租户上下文时,典型错误提示:
  80 +
  81 +> 未识别租户上下文。请使用泰额登录接口(th-web-auth / th-app-auth)或请求头 `__tenant` 携带租户 Id。
  82 +
  83 +---
  84 +
  85 +## 三、平台级:当前登录账号菜单
  86 +
  87 +### 3.1 接口(推荐)
  88 +
  89 +| 项 | 值 |
  90 +|----|------|
  91 +| 方法 / 路径 | `GET /api/app/account/Vue3Router/vben5` |
  92 +| 鉴权 | `Authorization: Bearer {token}` |
  93 +| 说明 | 将当前登录用户菜单转为 **Vben5** 前端路由结构 |
  94 +
  95 +`routerType` 路径参数:
  96 +
  97 +| 值 | 对应 `MenuSourceEnum` | 说明 |
  98 +|----|----------------------|------|
  99 +| `vben5`(推荐) | 2 = Vben5 | Vben Admin v5 路由构建 |
  100 +| `ruoyi` | 0 = Ruoyi | 若依 Vue3 路由构建 |
  101 +| `pure` | 1 = Pure | vue-pure-admin 路由构建 |
  102 +| 省略 | 默认按 ruoyi | 与框架历史默认一致 |
  103 +
  104 +完整路径示例:
  105 +
  106 +- `GET /api/app/account/Vue3Router/vben5`
  107 +- `GET /api/app/account/Vue3Router/ruoyi`
  108 +- `GET /api/app/account/Vue3Router/pure`
  109 +
  110 +### 3.2 备选:账户信息(含菜单)
  111 +
  112 +| 项 | 值 |
  113 +|----|------|
  114 +| 方法 / 路径 | `GET /api/app/account` |
  115 +| 鉴权 | 需登录 |
  116 +| 说明 | 返回 `UserRoleMenuDto`(用户、角色、菜单等),非专门「路由树」格式 |
  117 +
  118 +### 3.3 登录(前置)
  119 +
  120 +```http
  121 +POST /api/app/account/login
  122 +Content-Type: application/json
  123 +
  124 +{
  125 + "userName": "admin",
  126 + "password": "123456"
  127 +}
  128 +```
  129 +
  130 +> 平台主库账号;一般不传 `tenantId`。字段名以 Swagger 为准(历史上 Web 登录可能按邮箱匹配)。
  131 +
  132 +### 3.4 curl 示例
  133 +
  134 +```bash
  135 +# 1) 平台登录
  136 +curl -X POST "http://127.0.0.1:19002/api/app/account/login" \
  137 + -H "Content-Type: application/json" \
  138 + -d "{\"userName\":\"admin\",\"password\":\"123456\"}"
  139 +
  140 +# 2) 拉当前账号 Vben5 路由菜单
  141 +curl -G "http://127.0.0.1:19002/api/app/account/Vue3Router/vben5" \
  142 + -H "Authorization: Bearer <platform-token>"
  143 +```
  144 +
  145 +### 3.5 行为说明
  146 +
  147 +- 按当前用户角色关联菜单过滤;用户名为 `admin` 时框架侧可返回对应 `MenuSource` 下全部菜单再构建路由。
  148 +- 只消费 `menu` 表中 `MenuSource` 与 `routerType` 匹配的记录。
  149 +
  150 +---
  151 +
  152 +## 四、容易混淆的接口(不是「当前登录人菜单」)
  153 +
  154 +以下属于**平台给公司配置权限**,不要当成「当前登录账号菜单」:
  155 +
  156 +| 方法 / 路径 | 用途 |
  157 +|-------------|------|
  158 +| `GET /api/app/th-multi-tenancy/menu-permission-tree` | SaaS 菜单权限目录树(配置用) |
  159 +| `GET /api/app/th-multi-tenancy/company-menus` | 查询某公司已开通的 `menuPermissionKeys` |
  160 +| `PUT /api/app/th-multi-tenancy/company-menus` | 设置某公司 SaaS 菜单权限 |
  161 +| `GET /api/app/th-rbac-menu/tree` | 租户业务库菜单 **CRUD/管理** 全量树(非会话权限接口) |
  162 +
  163 +详见:`项目相关文档/2026-07-21代码优化.md`、`项目相关文档/2026-07-22代码优化.md`。
  164 +
  165 +---
  166 +
  167 +## 五、前端对接建议
  168 +
  169 +| 端 | 登录后拉菜单 |
  170 +|----|----------------|
  171 +| 公司 / 租户 Web | `th-web-auth/login` → `auth-session/my-menus`;请求始终带 Token + `__tenant` |
  172 +| 平台管理端(Vben5) | `account/login` → `account/Vue3Router/vben5` |
  173 +| Token 混用 | 禁止:公司 Token 调平台路由,或平台 Token 调 `my-menus`(缺租户会失败) |
  174 +
  175 +---
  176 +
  177 +## 六、相关代码位置
  178 +
  179 +| 角色 | 路径 |
  180 +|------|------|
  181 +| 公司级菜单 | `food-labeling-us/.../AuthSessionAppService.cs` → `GetMyMenusAsync` |
  182 +| 公司级登录 | `FoodLabeling.Th.Application/.../ThWebAuthAppService.cs` |
  183 +| 平台级路由 | `Yi.Framework.Rbac.Application/.../AccountService.cs` → `GetVue3Router` |
  184 +| 平台级登录 | 同 `AccountService` 登录接口 |
  185 +
  186 +---
  187 +
  188 +## 七、自检清单
  189 +
  190 +- [ ] 公司端:用 `th-web-auth/login` 拿到 Token 后再调 `auth-session/my-menus`
  191 +- [ ] 公司端:Header 含 `__tenant` 或 JWT 内 `TenantId` 有效
  192 +- [ ] 平台端:用 `account/login` 后再调 `account/Vue3Router/vben5`
  193 +- [ ] 未把 `company-menus` / `menu-permission-tree` 误当作当前登录人菜单
  194 +- [ ] Swagger 中确认实际 Host / 路径与本文一致
... ...
项目相关文档/2026-07-23美国版在线批量导入接口.md 0 → 100644
  1 +# 2026-07-23 美国版:Region / Location / Team Member / Product 在线批量导入
  2 +
  3 +本文档说明 **美国版**(`food-labeling-us`)四个 **JSON 在线批量导入**接口:前端在系统内表格/表单编辑后一次提交,**不再依赖下载 Excel 模板**(Excel 导入接口仍保留,但不推荐)。
  4 +
  5 +> 汇总表与 Excel/导出/批量编辑说明仍见:`项目相关文档/批量导入导出接口说明.md`。
  6 +
  7 +---
  8 +
  9 +## 一、为什么改成在线导入
  10 +
  11 +| 方式 | 问题 / 优势 |
  12 +|------|-------------|
  13 +| Excel 下载模板再上传 | 表头别名、列顺序、Region 名称 vs Id 等易填错;模板版本与环境不一致 |
  14 +| **JSON 在线导入(本文)** | 与单条 `Create` 字段一致;前端下拉选 Company/Region/Location(传 Id);校验即时、失败行可定位 |
  15 +
  16 +---
  17 +
  18 +## 二、公共约定
  19 +
  20 +| 项 | 说明 |
  21 +|----|------|
  22 +| 宿主 | 美国版 `Yi.Abp.Web`;Swagger 分组「食品标签-美国版接口」 |
  23 +| 基址示例 | `http://localhost:19001` / 测试环境 `https://flus-test.3ffoodsafety.com` |
  24 +| Content-Type | `application/json` |
  25 +| 鉴权 | `Authorization: Bearer {token}`(与其它业务接口相同) |
  26 +| 请求体 | `{ "items": [ ... ] }`,每元素字段与对应模块**单条新增**一致 |
  27 +| 行语义 | **仅新增**;批量编辑仍用各模块 `*-bulk`(Update) |
  28 +| 失败策略 | **逐行**调用现有 `CreateAsync`;部分成功;单行 `UserFriendlyException` 记入 `errors` |
  29 +| `index` | 失败行在 `items` 中的下标,**从 0 开始** |
  30 +| 上限 | `FoodLabeling:BatchImport:MaxImportRows`(默认 **5000**) |
  31 +| Excel 旧接口 | 保留;文档标注不推荐,请优先本文接口 |
  32 +
  33 +### 2.1 接口一览
  34 +
  35 +| 模块 | UI 概念 | HTTP | 路径 |
  36 +|------|---------|------|------|
  37 +| Group | **Region** | POST | `/api/app/group/batch-import-online` |
  38 +| Location | Location | POST | `/api/app/location/batch-import-online` |
  39 +| Team Member | Team Member | POST | `/api/app/team-member/batch-import-online` |
  40 +| Product | Product | POST | `/api/app/product/batch-import-online` |
  41 +
  42 +### 2.2 统一出参结构
  43 +
  44 +```json
  45 +{
  46 + "successCount": 2,
  47 + "failCount": 1,
  48 + "errors": [
  49 + {
  50 + "index": 2,
  51 + "message": "具体业务错误信息",
  52 + "groupName": "可选,按模块不同字段名见下表"
  53 + }
  54 + ]
  55 +}
  56 +```
  57 +
  58 +| 模块 | 失败行业务键字段(camelCase) |
  59 +|------|------------------------------|
  60 +| Region | `groupName` |
  61 +| Location | `locationCode` |
  62 +| Team Member | `userName` |
  63 +| Product | `productName` |
  64 +
  65 +整单失败(HTTP 400):`items` 为空,或条数超过 `MaxImportRows`。
  66 +
  67 +### 2.3 与批量编辑的区别
  68 +
  69 +| | 在线批量导入(本文) | 批量编辑(已有) |
  70 +|--|---------------------|------------------|
  71 +| 路径示例 | `.../batch-import-online` | `.../locations-bulk` 等 |
  72 +| 行语义 | 新建 | 按 `id` 更新 |
  73 +| 典型场景 | 系统内批量录入新数据 | 网格「保存全部」 |
  74 +
  75 +---
  76 +
  77 +## 三、Region(Group)在线导入
  78 +
  79 +| 项 | 值 |
  80 +|----|------|
  81 +| 路径 | `POST /api/app/group/batch-import-online` |
  82 +| 服务 | `GroupAppService.BatchImportOnlineAsync` |
  83 +| items 元素 | `GroupCreateInputVo` |
  84 +
  85 +**字段映射**:UI **Region** = 库表 `fl_group.GroupName`(入参字段名仍为 `groupName`)。
  86 +
  87 +| 字段 | 必填 | 说明 |
  88 +|------|------|------|
  89 +| `groupName` | 是 | Region 名称 |
  90 +| `partnerId` | 是 | 所属 Company(`fl_partner.Id`) |
  91 +| `state` | 否 | 启用状态,默认 `true` |
  92 +
  93 +```bash
  94 +curl -X POST "http://localhost:19001/api/app/group/batch-import-online" \
  95 + -H "Authorization: Bearer <token>" \
  96 + -H "Content-Type: application/json" \
  97 + -d "{
  98 + \"items\": [
  99 + { \"groupName\": \"NC Region\", \"partnerId\": \"<partnerGuid>\", \"state\": true },
  100 + { \"groupName\": \"SC Region\", \"partnerId\": \"<partnerGuid>\", \"state\": true }
  101 + ]
  102 + }"
  103 +```
  104 +
  105 +权限与单条 `POST /api/app/group` 相同(含 Company Admin 自动绑定门店等逻辑)。
  106 +
  107 +---
  108 +
  109 +## 四、Location 在线导入
  110 +
  111 +| 项 | 值 |
  112 +|----|------|
  113 +| 路径 | `POST /api/app/location/batch-import-online` |
  114 +| 服务 | `LocationAppService.BatchImportOnlineAsync` |
  115 +| items 元素 | `LocationCreateInputVo` |
  116 +
  117 +| 字段 | 必填 | 说明 |
  118 +|------|------|------|
  119 +| `locationCode` | 是 | Location ID(业务编码) |
  120 +| `locationName` | 是 | 门店名称 |
  121 +| `partner` | 否 | Company **名称**(与单条新增一致,非 Id) |
  122 +| `groupName` | 否 | Region 名称(对应 `location.GroupName`) |
  123 +| `street` / `city` / `stateCode` / `country` / `zipCode` | 否 | 地址 |
  124 +| `phone` / `email` | 否 | 联系方式 |
  125 +| `latitude` / `longitude` | 否 | 坐标 |
  126 +| `operatingHours` | 否 | 营业时间文本 |
  127 +| `state` | 否 | 启用,默认 `true` |
  128 +
  129 +```bash
  130 +curl -X POST "http://localhost:19001/api/app/location/batch-import-online" \
  131 + -H "Authorization: Bearer <token>" \
  132 + -H "Content-Type: application/json" \
  133 + -d "{
  134 + \"items\": [
  135 + {
  136 + \"locationCode\": \"LOC001\",
  137 + \"locationName\": \"UNCC store\",
  138 + \"partner\": \"MedVantage Cafe Group\",
  139 + \"groupName\": \"NC Region\",
  140 + \"city\": \"Charlotte\",
  141 + \"stateCode\": \"NC\",
  142 + \"country\": \"USA\",
  143 + \"state\": true
  144 + }
  145 + ]
  146 + }"
  147 +```
  148 +
  149 +---
  150 +
  151 +## 五、Team Member 在线导入
  152 +
  153 +| 项 | 值 |
  154 +|----|------|
  155 +| 路径 | `POST /api/app/team-member/batch-import-online` |
  156 +| 服务 | `TeamMemberAppService.BatchImportOnlineAsync` |
  157 +| items 元素 | `TeamMemberCreateInputVo` |
  158 +
  159 +| 字段 | 必填 | 说明 |
  160 +|------|------|------|
  161 +| `fullName` | 是 | 姓名 |
  162 +| `userName` | 是 | 登录账号 |
  163 +| `password` | 否 | 空则用配置 `TeamMemberImportDefaultPassword` |
  164 +| `email` / `phone` | 否 | 联系方式 |
  165 +| `roleId` | 否 | 角色 Guid |
  166 +| `partnerId` / `partnerIds` | 否 | Company Id |
  167 +| `regionIds` / `groupIds` | 否 | Region(`fl_group.Id`),**传 Id 不要传名称** |
  168 +| `locationIds` | 否 | 门店 Id 列表 |
  169 +| `state` | 否 | 默认 `true` |
  170 +
  171 +与 Excel 导入差异:在线接口的 Region/Location 使用 **Id**,由前端下拉选择,避免名称拼写错误。
  172 +
  173 +```bash
  174 +curl -X POST "http://localhost:19001/api/app/team-member/batch-import-online" \
  175 + -H "Authorization: Bearer <token>" \
  176 + -H "Content-Type: application/json" \
  177 + -d "{
  178 + \"items\": [
  179 + {
  180 + \"fullName\": \"John Doe\",
  181 + \"userName\": \"john@example.com\",
  182 + \"email\": \"john@example.com\",
  183 + \"roleId\": \"<roleGuid>\",
  184 + \"locationIds\": [\"<locationGuid>\"],
  185 + \"state\": true
  186 + }
  187 + ]
  188 + }"
  189 +```
  190 +
  191 +---
  192 +
  193 +## 六、Product 在线导入
  194 +
  195 +| 项 | 值 |
  196 +|----|------|
  197 +| 路径 | `POST /api/app/product/batch-import-online` |
  198 +| 服务 | `ProductAppService.BatchImportOnlineAsync` |
  199 +| items 元素 | `ProductCreateInputVo` |
  200 +
  201 +| 字段 | 必填 | 说明 |
  202 +|------|------|------|
  203 +| `productName` | 是 | 产品名称 |
  204 +| `productCode` | 否 | 空则后端生成唯一编码 |
  205 +| `categoryId` | 否 | 分类 Id |
  206 +| `productImageUrl` / `displayText` / `codeValue` | 否 | 展示相关 |
  207 +| `buttonAppearance` / `categoryPhotoUrl` | 否 | 按钮外观 |
  208 +| `availabilityType` | 否 | `ALL` / `SPECIFIED` |
  209 +| `partnerId` | 否 | Company Id,展开门店后写入关联 |
  210 +| `groupIds` | 否 | Region Id 列表 |
  211 +| `locationIds` | 否 | 门店 Id 列表 |
  212 +| `state` | 否 | 默认 `true` |
  213 +
  214 +```bash
  215 +curl -X POST "http://localhost:19001/api/app/product/batch-import-online" \
  216 + -H "Authorization: Bearer <token>" \
  217 + -H "Content-Type: application/json" \
  218 + -d "{
  219 + \"items\": [
  220 + {
  221 + \"productName\": \"Tuna & Bacon Sub\",
  222 + \"productCode\": \"40001\",
  223 + \"categoryId\": \"<categoryId>\",
  224 + \"state\": true,
  225 + \"locationIds\": [\"<locationGuid>\"]
  226 + }
  227 + ]
  228 + }"
  229 +```
  230 +
  231 +---
  232 +
  233 +## 七、配置
  234 +
  235 +`appsettings` 节:`FoodLabeling:BatchImport`
  236 +
  237 +| 配置项 | 与在线导入相关 |
  238 +|--------|----------------|
  239 +| `MaxImportRows` | 单次 `items` 最大条数(默认 5000) |
  240 +| `TeamMemberImportDefaultPassword` | Team Member 未填密码时的默认初始密码 |
  241 +
  242 +---
  243 +
  244 +## 八、前端对接建议
  245 +
  246 +1. 批量录入页用系统内表格 + 下拉(Company / Region / Location / Role),提交 JSON,**不要**再引导用户下载 Excel 模板。
  247 +2. 展示返回的 `successCount` / `failCount`,按 `errors[].index` 高亮失败行。
  248 +3. Region 展示文案用「Region」,请求字段仍用 `groupName` / `partnerId`。
  249 +4. 批量改已有数据继续走各模块 **bulk update**,勿用本导入接口。
  250 +5. 部署后需重启后端;Swagger 中确认路径为带模块前缀的 `.../group|location|team-member|product/batch-import-online`。
  251 +
  252 +---
  253 +
  254 +## 九、相关代码
  255 +
  256 +| 类型 | 路径 |
  257 +|------|------|
  258 +| Group | `FoodLabeling.Application/Services/GroupAppService.cs` |
  259 +| Location | `FoodLabeling.Application/Services/LocationAppService.cs` |
  260 +| Team Member | `FoodLabeling.Application/Services/TeamMemberAppService.cs` |
  261 +| Product | `FoodLabeling.Application/Services/ProductAppService.cs` |
  262 +| DTO | `FoodLabeling.Application.Contracts/Dtos/{Group,Location,TeamMember,Product}/*BatchImportOnline*.cs` |
  263 +
  264 +模块根目录:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/`。
  265 +
  266 +---
  267 +
  268 +## 十、自检清单
  269 +
  270 +- [ ] 平台/公司账号登录后 Token 可调业务接口
  271 +- [ ] Region:合法 `partnerId` + `groupName` 可批量成功
  272 +- [ ] Location:重复 `locationCode` 记入 `errors`,其它行仍成功
  273 +- [ ] Team Member:不传 `password` 时使用默认密码可登录
  274 +- [ ] Product:不传 `productCode` 时后端自动生成
  275 +- [ ] `items` 超过 `MaxImportRows` 返回 400
  276 +- [ ] 未误用 Excel 导入作为新前端主路径
... ...
项目相关文档/2026-07-24代码优化.md 0 → 100644
  1 +# 2026-07-24 Team Member 范围 ALL 哨兵(美国版后端)
  2 +
  3 +## 背景
  4 +
  5 +New User / Edit User 弹窗中 Locations、Region 支持勾选 **ALL**。前端可传 `locationIds: ["ALL"]`(或 `regionIds: ["ALL"]`),后端需按当前 Company(`partnerId` / `partnerIds`)展开并落库到 `userlocation`;列表 **Assigned Locations** 覆盖全部门店时展示 **`All Location`**,Region 覆盖全部时展示 **`All Region`**。
  6 +
  7 +本次**仅改美国版后端**,不改 Web 前端。
  8 +
  9 +---
  10 +
  11 +## 接口
  12 +
  13 +| 方法 | 路径 | 说明 |
  14 +|------|------|------|
  15 +| POST | `/api/app/team-member` | 新增成员 |
  16 +| PUT | `/api/app/team-member/{id}` | 更新成员 |
  17 +
  18 +批量编辑 `PUT /api/app/team-member/team-members-bulk` 单行字段与单条 PUT 相同,同样支持 ALL。
  19 +
  20 +---
  21 +
  22 +## ALL 传参约定
  23 +
  24 +### 哨兵值
  25 +
  26 +- 字符串 **`ALL`**(大小写不敏感:`ALL` / `all` / `All`)
  27 +- 适用字段:`locationIds`、`regionIds`、`groupIds`(与 `regionIds` 等价)、`locations`(与 `locationIds` 合并解析)
  28 +
  29 +### 与具体 Guid 同传
  30 +
  31 +若数组中同时含 `ALL` 与具体门店/Region Guid,**以 ALL 为准**(视为全选,忽略具体 Id)。
  32 +
  33 +### 优先级(Team Member 落库)
  34 +
  35 +**不再做 partner + region + location 并集**;按下列顺序取**单一**规则展开:
  36 +
  37 +1. **`locationIds` 含 `ALL`** → 按 `partnerId` / `partnerIds` 展开该公司**全部门店**
  38 +2. **有具体 `locationIds`,且 `regionIds` 为空或为 `ALL`** → **只绑这些门店**(Region=ALL 下再收窄到指定门店;避免 `regionIds:["ALL"]` 盖掉单店)
  39 +3. **`regionIds` 含 `ALL`**(且无具体门店)→ 按 Company 展开该公司**全部 Region** 下门店
  40 +4. **`regionIds` 有具体 Guid** → **按这些 Region 展开门店落库**(**忽略同传的 `locationIds`**,保证多区域能落库回显)
  41 +5. **仅有具体 `locationIds`** → 只绑定指定门店
  42 +6. **仅有 `partnerId` / `partnerIds`**(Company Admin 等)→ 绑定该公司全部门店
  43 +
  44 +| 请求示例 | 落库规则 |
  45 +|----------|----------|
  46 +| `partnerId` + `locationIds: ["ALL"]` | 展开该公司**全部**门店写入 `userlocation` |
  47 +| `partnerId` + `regionIds: ["ALL"]` + `locationIds: ["{loc}"]` | **只绑 `{loc}`**(Region ALL + 具体门店时门店优先) |
  48 +| `partnerId` + `regionIds: ["ALL"]`(无 location) | 展开该公司**全部 Region** 下门店 |
  49 +| `partnerId` + `regionIds: ["{groupGuid1}", "{groupGuid2}"]` | 展开两 Region 下门店并集 |
  50 +| `partnerId` + `regionIds: ["{g1}","{g2}"]` + `locationIds: ["{loc}"]` | **按 Region 展开**(忽略 `{loc}`),`userlocation` 为两 Region 门店并集;Get 回显 `regionIds` 含 2 个 |
  51 +| `partnerId` + `locationIds: ["{guid1}", "{guid2}"]`(无 region) | 仅绑定指定门店 |
  52 +| Company Admin 仅传 `partnerId`(无 region/location) | 仍自动绑定该公司全部门店(原逻辑) |
  53 +
  54 +**注意**:`locationIds` / `regionIds` 含 ALL 时须传 **`partnerId` 或 `partnerIds`**,否则返回 400。
  55 +
  56 +### 编辑回显(GET 折叠为 ALL)
  57 +
  58 +库表 `userlocation` **仍存展开后的门店 Guid**。`GET /api/app/team-member/{id}`(及列表同名字段)在判断为「已覆盖该公司全部」时,将 Id 数组折叠为哨兵,与新增传 ALL 对称:
  59 +
  60 +| 条件 | 出参 |
  61 +|------|------|
  62 +| 绑定门店 = 该公司全部门店 | `locationIds: ["ALL"]`,`assignedLocations` 展示 All Location |
  63 +| 反推 Region = 该公司全部 Region | `regionIds` / `groupIds: ["ALL"]` |
  64 +| 未全选 | 仍返回具体 Guid 数组 |
  65 +
  66 +示例(新增时传 `regionIds/locationIds: ["ALL"]` 后,再 GET):
  67 +
  68 +```json
  69 +{
  70 + "partnerIds": ["3a22a48c-9758-9a26-e4ee-390b0f042835"],
  71 + "regionIds": ["ALL"],
  72 + "groupIds": ["ALL"],
  73 + "locationIds": ["ALL"]
  74 +}
  75 +```
  76 +
  77 +---
  78 +
  79 +## 请求 / 响应示例
  80 +
  81 +### 1. Locations 全选(ALL)
  82 +
  83 +**请求**
  84 +
  85 +```json
  86 +POST /api/app/team-member
  87 +{
  88 + "fullName": "Test All Loc",
  89 + "userName": "test.all.loc@example.com",
  90 + "password": "Pass123!",
  91 + "email": "test.all.loc@example.com",
  92 + "roleId": "ROLE_GUID",
  93 + "partnerId": "3a222876-7c49-6f01-c232-2d21dde8f27e",
  94 + "locationIds": ["ALL"],
  95 + "state": true
  96 +}
  97 +```
  98 +
  99 +**落库**:`userlocation` 写入 Partner `Subway USA` 下全部未删除门店 Id。
  100 +
  101 +**列表/详情 `assignedLocations`**(覆盖全部门店时):
  102 +
  103 +```json
  104 +"assignedLocations": [
  105 + { "locationName": "All Location" }
  106 +]
  107 +```
  108 +
  109 +### 2. 仅指定门店 Guid(对比)
  110 +
  111 +**请求**
  112 +
  113 +```json
  114 +"partnerId": "3a222876-7c49-6f01-c232-2d21dde8f27e",
  115 +"locationIds": [
  116 + "3a222878-b4c1-d27b-d437-93da14886350",
  117 + "3a22287c-d353-da47-869f-0bb5dacced51"
  118 +]
  119 +```
  120 +
  121 +**列表 `assignedLocations`**:展示具体名称,如 `Subway LAX Store`、`Subway UNCC Store`,**不会**出现 `All Location`。
  122 +
  123 +### 3. Region 全选(ALL)
  124 +
  125 +**请求**
  126 +
  127 +```json
  128 +"partnerId": "3a222876-7c49-6f01-c232-2d21dde8f27e",
  129 +"regionIds": ["ALL"]
  130 +```
  131 +
  132 +**落库**:该公司下所有 Region 对应门店写入 `userlocation`。
  133 +
  134 +**Region 展示**:绑定覆盖该公司全部门店时,列表 Region 文案为 **`All Region`**(`FoodLabelingDisplayConsts.AllRegion`)。
  135 +
  136 +### 4. 编辑多 Region + 同传 location(按 Region 展开)
  137 +
  138 +**场景**:UI 勾选 2 个 Region,请求体可能仍带 1 个 `locationIds`(历史选中项)。后端**以 Region 为准**展开,保证多区域能落库并回显。
  139 +
  140 +**请求**
  141 +
  142 +```json
  143 +PUT /api/app/team-member/{id}
  144 +{
  145 + "fullName": "张三",
  146 + "userName": "123",
  147 + "email": "123@qq.com",
  148 + "roleId": "3a21836a-cc04-1783-b054-f2ff3c45d4bf",
  149 + "partnerId": "3a22a48c-9758-9a26-e4ee-390b0f042835",
  150 + "regionIds": [
  151 + "3a22a48d-98be-7d54-7b9d-6062afb176be",
  152 + "3a22a48d-5356-8754-d839-a6b7aaad02f9"
  153 + ],
  154 + "groupIds": [
  155 + "3a22a48d-98be-7d54-7b9d-6062afb176be",
  156 + "3a22a48d-5356-8754-d839-a6b7aaad02f9"
  157 + ],
  158 + "locationIds": ["3a22a48f-4114-ad64-11bf-9adb09f999b9"],
  159 + "state": true
  160 +}
  161 +```
  162 +
  163 +**落库**:`userlocation` 写入两 Region 下门店并集(**不是**仅 `{locationIds}` 那一店)。
  164 +
  165 +**Get 回显**:`regionIds` 含上述 2 个 Region(由 `userlocation` → 门店 → `fl_group` 反推)。
  166 +
  167 +---
  168 +
  169 +## 列表展示规则
  170 +
  171 +| 条件 | Assigned Locations | Region(列表/PDF) |
  172 +|------|-------------------|-------------------|
  173 +| `userlocation` 覆盖该成员 Company 下**全部门店** | `All Location` | 全部 Region 时 `All Region` |
  174 +| 仅部分门店 | 门店 Code/Name,分号拼接 | 具体 Region 名称 |
  175 +
  176 +实现位置:
  177 +
  178 +- 落库展开:`LocationScopeBindingHelper.ResolveTeamMemberLocationIdsForSaveAsync`
  179 +- 展示折叠:`TeamMemberScopeDisplayHelper.FormatAssignedLocationsForListAsync` / `FormatRegionTextForListAsync`
  180 +- 全量回显:`TeamMemberAppService.ApplyCompanyAdminDisplayScopeAsync`(绑定覆盖全部门店时展开 Region)
  181 +
  182 +---
  183 +
  184 +## curl 示例
  185 +
  186 +### 获取 Token
  187 +
  188 +```bash
  189 +curl -X POST "http://localhost:19001/api/oauth/Login" \
  190 + -H "Content-Type: application/x-www-form-urlencoded" \
  191 + -d "userName=admin&password=123456"
  192 +```
  193 +
  194 +将响应中 `data.token`(已含 `Bearer ` 前缀)用于后续 `Authorization` 头。
  195 +
  196 +### 新增 — Locations ALL
  197 +
  198 +```bash
  199 +curl -X POST "http://localhost:19001/api/app/team-member" \
  200 + -H "Authorization: <data.token>" \
  201 + -H "Content-Type: application/json" \
  202 + -d "{
  203 + \"fullName\": \"All Loc User\",
  204 + \"userName\": \"all.loc.user@example.com\",
  205 + \"password\": \"Pass123!\",
  206 + \"email\": \"all.loc.user@example.com\",
  207 + \"roleId\": \"<ROLE_GUID>\",
  208 + \"partnerId\": \"3a222876-7c49-6f01-c232-2d21dde8f27e\",
  209 + \"locationIds\": [\"ALL\"],
  210 + \"state\": true
  211 + }"
  212 +```
  213 +
  214 +### 更新 — Region ALL
  215 +
  216 +```bash
  217 +curl -X PUT "http://localhost:19001/api/app/team-member/<USER_GUID>" \
  218 + -H "Authorization: <data.token>" \
  219 + -H "Content-Type: application/json" \
  220 + -d "{
  221 + \"fullName\": \"All Region User\",
  222 + \"userName\": \"all.region.user@example.com\",
  223 + \"roleId\": \"<ROLE_GUID>\",
  224 + \"partnerId\": \"3a222876-7c49-6f01-c232-2d21dde8f27e\",
  225 + \"regionIds\": [\"ALL\"],
  226 + \"state\": true
  227 + }"
  228 +```
  229 +
  230 +### 查询列表验证 Assigned Locations
  231 +
  232 +```bash
  233 +curl -X GET "http://localhost:19001/api/app/team-member?SkipCount=0&MaxResultCount=20" \
  234 + -H "Authorization: <data.token>"
  235 +```
  236 +
  237 +检查 `items[].assignedLocations` 与 `items[].locationIds`。
  238 +
  239 +---
  240 +
  241 +## 改动文件
  242 +
  243 +| 文件 | 变更 |
  244 +|------|------|
  245 +| `FoodLabeling.Application/Helpers/LocationScopeBindingHelper.cs` | ALL 哨兵识别;`ResolveTeamMemberLocationIdsForSaveAsync` 展开逻辑 |
  246 +| `FoodLabeling.Application/Services/TeamMemberAppService.cs` | 合并 `locations`;Create/Update XML 注释;全量绑定展示 |
  247 +| `FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs` | 字段注释 + `Locations` |
  248 +| `FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs` | 字段注释 + `Locations` |
  249 +| `FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs` | Create/Update 接口说明 |
  250 +
  251 +---
  252 +
  253 +## 验证
  254 +
  255 +- `dotnet build` 编译通过
  256 +- 可选:MCP `user-antis-foodlabeling-us-mysql` 查 `userlocation` 确认 ALL 请求后行数等于该公司 `location` 未删除门店数
  257 +
  258 +---
  259 +
  260 +## label-template 多公司 / 多 Region / 多门店传参与落库(美国版)
  261 +
  262 +### 背景
  263 +
  264 +`POST/PUT /api/app/label-template` 在 **Company / Region / Location** 三维均为 `SPECIFIED` 且各传多个 Id 时,若所选 Id 恰好等于「当前上下文全部可选项」,旧逻辑会把维度折叠为 `ALL` 并**清空关联表**,详情再按门店反推 Company,常表现为 **只回显 1 个公司**。
  265 +
  266 +### 根因
  267 +
  268 +`AllScopeBindingHelper.ResolveDimensionType` 在 `isFullSelection == true` 时一律返回 `ALL`,**未尊重入参显式 `appliedPartnerType` / `appliedRegionType` / `appliedLocation` = `SPECIFIED`**。
  269 +
  270 +### 修复约定
  271 +
  272 +| 入参 | 落库行为 |
  273 +|------|----------|
  274 +| `appliedPartnerType: "SPECIFIED"` + `partnerIds` / `companyIds` 数组 | **始终**写 `fl_label_template_partner`(每个 Id 一行),主表 `AppliedPartnerType = SPECIFIED` |
  275 +| `appliedRegionType: "SPECIFIED"` + `regionIds` / `groupIds` | **始终**写 `fl_label_template_region` |
  276 +| `appliedLocation: "SPECIFIED"` + `locationIds` / `appliedLocationIds` | **始终**写 `fl_label_template_location` |
  277 +| 各维度 `ALL` 或未显式 `SPECIFIED` 且 Id 覆盖当前上下文全集 | 仍归档为 `ALL`,不写该维度关联快照(后续新增主数据动态适用) |
  278 +
  279 +**字段别名(合并去重)**
  280 +
  281 +- Company:`partnerIds` ≡ `companyIds`(`fl_partner.Id`)
  282 +- Region:`regionIds` ≡ `groupIds`(`fl_group.Id`)
  283 +- Location:`locationIds` ≡ `appliedLocationIds`(`location.Id`)
  284 +
  285 +### 请求示例(2 个 Company + 2 个 Region + 2 个门店)
  286 +
  287 +```json
  288 +PUT /api/app/label-template/tpl_xxx
  289 +{
  290 + "appliedPartnerType": "SPECIFIED",
  291 + "companyIds": ["{partnerId1}", "{partnerId2}"],
  292 + "partnerIds": ["{partnerId1}", "{partnerId2}"],
  293 + "appliedRegionType": "SPECIFIED",
  294 + "regionIds": ["{groupId1}", "{groupId2}"],
  295 + "groupIds": ["{groupId1}", "{groupId2}"],
  296 + "appliedLocation": "SPECIFIED",
  297 + "locationIds": ["{locId1}", "{locId2}"],
  298 + "appliedLocationIds": ["{locId1}", "{locId2}"]
  299 +}
  300 +```
  301 +
  302 +### 验证(MCP / curl)
  303 +
  304 +1. 保存后查库:
  305 +
  306 +```sql
  307 +SELECT AppliedPartnerType, AppliedRegionType, AppliedLocationType
  308 +FROM fl_label_template WHERE TemplateCode = 'tpl_xxx';
  309 +
  310 +SELECT PartnerId FROM fl_label_template_partner
  311 +WHERE TemplateId = (SELECT Id FROM fl_label_template WHERE TemplateCode = 'tpl_xxx');
  312 +
  313 +SELECT GroupId FROM fl_label_template_region
  314 +WHERE TemplateId = (SELECT Id FROM fl_label_template WHERE TemplateCode = 'tpl_xxx');
  315 +
  316 +SELECT LocationId FROM fl_label_template_location
  317 +WHERE TemplateId = (SELECT Id FROM fl_label_template WHERE TemplateCode = 'tpl_xxx');
  318 +```
  319 +
  320 +2. `GET /api/app/label-template/tpl_xxx` 出参 `companyIds` / `partnerIds` / `regionIds` / `locationIds` 数组长度应与入参一致。
  321 +
  322 +### 改动文件
  323 +
  324 +| 文件 | 变更 |
  325 +|------|------|
  326 +| `Helpers/AllScopeBindingHelper.cs` | 显式 `SPECIFIED` 时不因全选折叠为 `ALL` |
  327 +| `Helpers/LabelTemplateScopeHelper.cs` | 保存传参对齐 Label 实体;详情/列表读取主表 scope 类型,避免门店反推覆盖 |
  328 +| `Helpers/LabelTemplateScopeSchemaHelper.cs` | 批量读取 `AppliedPartnerType` / `AppliedRegionType` |
... ...
项目相关文档/th-platform-user-role-seed.sql 0 → 100644
  1 +-- 泰额版平台主库 User / Role / UserRole / RoleMenu 初始化
  2 +-- 执行库(必须):antis-foodlabeling-host
  3 +-- 禁止执行库:antis-foodlabeling-us(美国版,写入会污染)
  4 +-- 前置:已执行 th-tenant-menu-seed.sql(主库已有 menu)
  5 +-- 默认账号:admin / 123456(密码哈希与框架种子一致)
  6 +--
  7 +-- ========== 固定 Guid 对照表 ==========
  8 +-- 平台超级管理员角色 f0020002-0001-4000-8000-000000000002
  9 +-- 平台 admin 用户 f0020001-0001-4000-8000-000000000001
  10 +-- 用户-角色关系 f0020003-0001-4000-8000-000000000003
  11 +-- 角色-菜单关系 f0030001~f0030026(见下方 INSERT)
  12 +-- ======================================
  13 +
  14 +CREATE TABLE IF NOT EXISTS `user` (
  15 + `Id` varchar(36) NOT NULL COMMENT '主键',
  16 + `IsDeleted` tinyint(1) NOT NULL COMMENT '逻辑删除',
  17 + `Name` varchar(255) DEFAULT NULL COMMENT '姓名',
  18 + `Age` int(11) DEFAULT NULL COMMENT '年龄',
  19 + `UserName` varchar(255) NOT NULL COMMENT '用户名',
  20 + `Password` varchar(255) NOT NULL COMMENT '密码',
  21 + `Salt` varchar(255) NOT NULL COMMENT '加密盐值',
  22 + `Icon` varchar(255) DEFAULT NULL COMMENT '头像',
  23 + `Nick` varchar(255) DEFAULT NULL COMMENT '昵称',
  24 + `Email` varchar(255) DEFAULT NULL COMMENT '邮箱',
  25 + `Ip` varchar(255) DEFAULT NULL COMMENT 'Ip',
  26 + `Address` varchar(255) DEFAULT NULL COMMENT '地址',
  27 + `Phone` bigint(20) DEFAULT NULL COMMENT '电话',
  28 + `Introduction` varchar(255) DEFAULT NULL COMMENT '简介',
  29 + `Remark` varchar(255) DEFAULT NULL COMMENT '备注',
  30 + `Sex` int(11) NOT NULL COMMENT '性别',
  31 + `DeptId` varchar(36) DEFAULT NULL COMMENT '部门id',
  32 + `CreationTime` datetime NOT NULL COMMENT '创建时间',
  33 + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者',
  34 + `LastModifierId` varchar(36) DEFAULT NULL COMMENT '最后修改者',
  35 + `LastModificationTime` datetime DEFAULT NULL COMMENT '最后修改时间',
  36 + `OrderNum` int(11) NOT NULL COMMENT '排序',
  37 + `State` tinyint(1) NOT NULL COMMENT '状态',
  38 + `ConcurrencyStamp` varchar(255) NOT NULL,
  39 + PRIMARY KEY (`Id`),
  40 + KEY `index_UserName` (`UserName`)
  41 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表-泰额平台主库';
  42 +
  43 +CREATE TABLE IF NOT EXISTS `role` (
  44 + `Id` varchar(36) NOT NULL COMMENT '主键',
  45 + `IsDeleted` tinyint(1) NOT NULL COMMENT '逻辑删除',
  46 + `CreationTime` datetime NOT NULL COMMENT '创建时间',
  47 + `CreatorId` varchar(36) DEFAULT NULL COMMENT '创建者',
  48 + `LastModifierId` varchar(36) DEFAULT NULL COMMENT '最后修改者',
  49 + `LastModificationTime` datetime DEFAULT NULL COMMENT '最后修改时间',
  50 + `OrderNum` int(11) NOT NULL COMMENT '排序',
  51 + `RoleName` varchar(255) NOT NULL COMMENT '角色名',
  52 + `RoleCode` varchar(255) NOT NULL COMMENT '角色编码',
  53 + `Remark` varchar(255) DEFAULT NULL COMMENT '描述',
  54 + `DataScope` int(11) NOT NULL COMMENT '角色数据范围',
  55 + `State` tinyint(1) NOT NULL COMMENT '状态',
  56 + `AccessPermissionCodes` varchar(2000) DEFAULT NULL COMMENT '访问权限 JSON',
  57 + `ConcurrencyStamp` varchar(255) NOT NULL,
  58 + PRIMARY KEY (`Id`)
  59 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表-泰额平台主库';
  60 +
  61 +CREATE TABLE IF NOT EXISTS `userrole` (
  62 + `Id` varchar(36) NOT NULL COMMENT '主键',
  63 + `RoleId` varchar(36) NOT NULL COMMENT '角色id',
  64 + `UserId` varchar(36) NOT NULL COMMENT '用户id',
  65 + PRIMARY KEY (`Id`)
  66 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关系表-泰额平台主库';
  67 +
  68 +CREATE TABLE IF NOT EXISTS `rolemenu` (
  69 + `Id` varchar(36) NOT NULL COMMENT '主键',
  70 + `RoleId` varchar(36) NOT NULL,
  71 + `MenuId` varchar(36) NOT NULL,
  72 + PRIMARY KEY (`Id`)
  73 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色菜单关系表-泰额平台主库';
  74 +
  75 +-- 物理清除旧非法 Id,避免脏数据残留
  76 +DELETE FROM `userrole` WHERE `Id` LIKE 'th-platform%' OR `UserId` LIKE 'th-platform%' OR `RoleId` LIKE 'th-platform%';
  77 +DELETE FROM `rolemenu` WHERE `RoleId` LIKE 'th-platform%' OR `Id` LIKE 'th-prm-%' OR `Id` LIKE 'th-platform%';
  78 +DELETE FROM `user` WHERE `Id` LIKE 'th-platform%';
  79 +DELETE FROM `role` WHERE `Id` LIKE 'th-platform%';
  80 +
  81 +INSERT INTO `role`
  82 +(`Id`, `IsDeleted`, `CreationTime`, `CreatorId`, `LastModifierId`, `LastModificationTime`,
  83 + `OrderNum`, `RoleName`, `RoleCode`, `Remark`, `DataScope`, `State`, `AccessPermissionCodes`, `ConcurrencyStamp`)
  84 +VALUES
  85 +('f0020002-0001-4000-8000-000000000002', 0, NOW(), NULL, NULL, NULL,
  86 + 999, '平台超级管理员', 'admin', '泰额平台主库超级管理员', 0, 1, NULL, UUID())
  87 +ON DUPLICATE KEY UPDATE
  88 + `IsDeleted`=0,
  89 + `LastModificationTime`=NOW(),
  90 + `RoleName`=VALUES(`RoleName`),
  91 + `RoleCode`=VALUES(`RoleCode`),
  92 + `Remark`=VALUES(`Remark`),
  93 + `DataScope`=VALUES(`DataScope`),
  94 + `State`=1,
  95 + `ConcurrencyStamp`=UUID();
  96 +
  97 +INSERT INTO `user`
  98 +(`Id`, `IsDeleted`, `Name`, `Age`, `UserName`, `Password`, `Salt`, `Icon`, `Nick`, `Email`,
  99 + `Ip`, `Address`, `Phone`, `Introduction`, `Remark`, `Sex`, `DeptId`,
  100 + `CreationTime`, `CreatorId`, `LastModifierId`, `LastModificationTime`,
  101 + `OrderNum`, `State`, `ConcurrencyStamp`)
  102 +VALUES
  103 +('f0020001-0001-4000-8000-000000000001', 0, '超级管理员', 20, 'admin',
  104 + 'ANg9hGZC3CMBZsfyTE8-AHmP7A30DrI85LxR1btI4HjNwk-wilA9y2-_L4y_kTS14Qq8uIVOeXdYggrShfvNsQ',
  105 + 'EMXTQOSLmpRmBGS4tMz4mw==',
  106 + NULL, '超级管理员', 'admin@example.com',
  107 + NULL, '成都', 13800000000, '泰额平台超级管理员', '平台主库', 0, NULL,
  108 + NOW(), NULL, NULL, NULL,
  109 + 999, 1, UUID())
  110 +ON DUPLICATE KEY UPDATE
  111 + `IsDeleted`=0,
  112 + `LastModificationTime`=NOW(),
  113 + `Name`=VALUES(`Name`),
  114 + `Nick`=VALUES(`Nick`),
  115 + `Email`=VALUES(`Email`),
  116 + `Password`=VALUES(`Password`),
  117 + `Salt`=VALUES(`Salt`),
  118 + `State`=1,
  119 + `ConcurrencyStamp`=UUID();
  120 +
  121 +INSERT INTO `userrole` (`Id`, `RoleId`, `UserId`)
  122 +VALUES ('f0020003-0001-4000-8000-000000000003', 'f0020002-0001-4000-8000-000000000002', 'f0020001-0001-4000-8000-000000000001')
  123 +ON DUPLICATE KEY UPDATE
  124 + `RoleId`=VALUES(`RoleId`),
  125 + `UserId`=VALUES(`UserId`);
  126 +
  127 +DELETE FROM `rolemenu` WHERE `RoleId` = 'f0020002-0001-4000-8000-000000000002';
  128 +
  129 +INSERT INTO `rolemenu` (`Id`, `RoleId`, `MenuId`)
  130 +VALUES
  131 +('f0030001-0001-4000-8000-000000000001', 'f0020002-0001-4000-8000-000000000002', 'f0010001-0001-4000-8000-000000000001'),
  132 +('f0030002-0001-4000-8000-000000000002', 'f0020002-0001-4000-8000-000000000002', 'f0010002-0001-4000-8000-000000000002'),
  133 +('f0030003-0001-4000-8000-000000000003', 'f0020002-0001-4000-8000-000000000002', 'f0010003-0001-4000-8000-000000000003'),
  134 +('f0030004-0001-4000-8000-000000000004', 'f0020002-0001-4000-8000-000000000002', 'f0010010-0001-4000-8000-000000000010'),
  135 +('f0030005-0001-4000-8000-000000000005', 'f0020002-0001-4000-8000-000000000002', 'f0010011-0001-4000-8000-000000000011'),
  136 +('f0030006-0001-4000-8000-000000000006', 'f0020002-0001-4000-8000-000000000002', 'f0010012-0001-4000-8000-000000000012'),
  137 +('f0030007-0001-4000-8000-000000000007', 'f0020002-0001-4000-8000-000000000002', 'f0010013-0001-4000-8000-000000000013'),
  138 +('f0030008-0001-4000-8000-000000000008', 'f0020002-0001-4000-8000-000000000002', 'f0010014-0001-4000-8000-000000000014'),
  139 +('f0030009-0001-4000-8000-000000000009', 'f0020002-0001-4000-8000-000000000002', 'f0010015-0001-4000-8000-000000000015'),
  140 +('f0030010-0001-4000-8000-000000000010', 'f0020002-0001-4000-8000-000000000002', 'f0010020-0001-4000-8000-000000000020'),
  141 +('f0030011-0001-4000-8000-000000000011', 'f0020002-0001-4000-8000-000000000002', 'f0010021-0001-4000-8000-000000000021'),
  142 +('f0030012-0001-4000-8000-000000000012', 'f0020002-0001-4000-8000-000000000002', 'f0010022-0001-4000-8000-000000000022'),
  143 +('f0030013-0001-4000-8000-000000000013', 'f0020002-0001-4000-8000-000000000002', 'f0010023-0001-4000-8000-000000000023'),
  144 +('f0030014-0001-4000-8000-000000000014', 'f0020002-0001-4000-8000-000000000002', 'f0010024-0001-4000-8000-000000000024'),
  145 +('f0030015-0001-4000-8000-000000000015', 'f0020002-0001-4000-8000-000000000002', 'f0010025-0001-4000-8000-000000000025'),
  146 +('f0030016-0001-4000-8000-000000000016', 'f0020002-0001-4000-8000-000000000002', 'f0010026-0001-4000-8000-000000000026'),
  147 +('f0030017-0001-4000-8000-000000000017', 'f0020002-0001-4000-8000-000000000002', 'f0010030-0001-4000-8000-000000000030'),
  148 +('f0030018-0001-4000-8000-000000000018', 'f0020002-0001-4000-8000-000000000002', 'f0010031-0001-4000-8000-000000000031'),
  149 +('f0030019-0001-4000-8000-000000000019', 'f0020002-0001-4000-8000-000000000002', 'f0010032-0001-4000-8000-000000000032'),
  150 +('f0030020-0001-4000-8000-000000000020', 'f0020002-0001-4000-8000-000000000002', 'f0010033-0001-4000-8000-000000000033'),
  151 +('f0030021-0001-4000-8000-000000000021', 'f0020002-0001-4000-8000-000000000002', 'f0010034-0001-4000-8000-000000000034'),
  152 +('f0030022-0001-4000-8000-000000000022', 'f0020002-0001-4000-8000-000000000002', 'f0010035-0001-4000-8000-000000000035'),
  153 +('f0030023-0001-4000-8000-000000000023', 'f0020002-0001-4000-8000-000000000002', 'f0010036-0001-4000-8000-000000000036'),
  154 +('f0030024-0001-4000-8000-000000000024', 'f0020002-0001-4000-8000-000000000002', 'f0010037-0001-4000-8000-000000000037'),
  155 +('f0030025-0001-4000-8000-000000000025', 'f0020002-0001-4000-8000-000000000002', 'f0010038-0001-4000-8000-000000000038'),
  156 +('f0030026-0001-4000-8000-000000000026', 'f0020002-0001-4000-8000-000000000002', 'f0010039-0001-4000-8000-000000000039')
  157 +ON DUPLICATE KEY UPDATE
  158 + `RoleId` = VALUES(`RoleId`),
  159 + `MenuId` = VALUES(`MenuId`);
... ...
项目相关文档/th-tenant-menu-seed.sql 0 → 100644
  1 +-- 泰额版 menu 菜单初始化脚本(平台主库 / 租户业务库通用)
  2 +-- 执行库示例:antis-foodlabeling-host(平台主库);租户业务库按需切换
  3 +-- 禁止对 antis-foodlabeling-us 执行(美国版)
  4 +--
  5 +-- MenuType: 0=目录, 1=菜单, 2=组件/按钮
  6 +-- MenuSource: 0=Ruoyi, 1=Pure, 2=Vben5;当前固定 Ruoyi(0)
  7 +-- 根菜单 ParentId 用 '0';子菜单 ParentId 用父菜单 Guid
  8 +--
  9 +-- ========== 固定 Guid 对照表(名称 → Id) ==========
  10 +-- 首页概览 f0010001-0001-4000-8000-000000000001
  11 +-- 平台管理 f0010002-0001-4000-8000-000000000002
  12 +-- SAAS 公司 f0010003-0001-4000-8000-000000000003
  13 +-- 标签管理 f0010010-0001-4000-8000-000000000010
  14 +-- 标签 f0010011-0001-4000-8000-000000000011
  15 +-- 标签分类 f0010012-0001-4000-8000-000000000012
  16 +-- 标签类型 f0010013-0001-4000-8000-000000000013
  17 +-- 标签模板 f0010014-0001-4000-8000-000000000014
  18 +-- 多选选项集 f0010015-0001-4000-8000-000000000015
  19 +-- 业务模块 f0010020-0001-4000-8000-000000000020
  20 +-- 培训 f0010021-0001-4000-8000-000000000021
  21 +-- 告警 f0010022-0001-4000-8000-000000000022
  22 +-- 任务 f0010023-0001-4000-8000-000000000023
  23 +-- 传感器 f0010024-0001-4000-8000-000000000024
  24 +-- 食物浪费 f0010025-0001-4000-8000-000000000025
  25 +-- 电子标签 f0010026-0001-4000-8000-000000000026
  26 +-- 管理 f0010030-0001-4000-8000-000000000030
  27 +-- 账户管理 f0010031-0001-4000-8000-000000000031
  28 +-- 系统菜单 f0010032-0001-4000-8000-000000000032
  29 +-- 菜单管理 f0010033-0001-4000-8000-000000000033
  30 +-- 设备 f0010034-0001-4000-8000-000000000034
  31 +-- 报表 f0010035-0001-4000-8000-000000000035
  32 +-- 发票 f0010036-0001-4000-8000-000000000036
  33 +-- 二维码 f0010037-0001-4000-8000-000000000037
  34 +-- 支持 f0010038-0001-4000-8000-000000000038
  35 +-- API f0010039-0001-4000-8000-000000000039
  36 +-- ==================================================
  37 +
  38 +-- 1a) 物理删除非法 Id(th-menu-*),避免留下脏数据行
  39 +DELETE FROM `rolemenu` WHERE `MenuId` LIKE 'th-menu-%';
  40 +DELETE FROM `menu` WHERE `Id` LIKE 'th-menu-%';
  41 +
  42 +-- 1b) 同 PermissionCode 的其它旧行(非本脚本固定 Guid)做逻辑删除
  43 +UPDATE `menu`
  44 +SET `IsDeleted` = 1,
  45 + `LastModificationTime` = NOW()
  46 +WHERE `IsDeleted` = 0
  47 + AND (
  48 + (
  49 + `PermissionCode` IN (
  50 + 'menu.dashboard.analytics',
  51 + 'menu.platform',
  52 + 'menu.platform.tenants',
  53 + 'menu.labeling',
  54 + 'menu.labeling.labels',
  55 + 'menu.labeling.categories',
  56 + 'menu.labeling.types',
  57 + 'menu.labeling.templates',
  58 + 'menu.labeling.multiple-options',
  59 + 'menu.modules',
  60 + 'menu.modules.training',
  61 + 'menu.modules.alerts',
  62 + 'menu.modules.tasks',
  63 + 'menu.modules.sensors',
  64 + 'menu.modules.food-waste',
  65 + 'menu.modules.e-label',
  66 + 'menu.management',
  67 + 'menu.management.account',
  68 + 'menu.management.system-menu',
  69 + 'menu.management.menu',
  70 + 'menu.management.devices',
  71 + 'menu.management.reports',
  72 + 'menu.management.invoices',
  73 + 'menu.management.qr-codes',
  74 + 'menu.management.support',
  75 + 'menu.management.api'
  76 + )
  77 + AND `Id` NOT IN (
  78 + 'f0010001-0001-4000-8000-000000000001',
  79 + 'f0010002-0001-4000-8000-000000000002',
  80 + 'f0010003-0001-4000-8000-000000000003',
  81 + 'f0010010-0001-4000-8000-000000000010',
  82 + 'f0010011-0001-4000-8000-000000000011',
  83 + 'f0010012-0001-4000-8000-000000000012',
  84 + 'f0010013-0001-4000-8000-000000000013',
  85 + 'f0010014-0001-4000-8000-000000000014',
  86 + 'f0010015-0001-4000-8000-000000000015',
  87 + 'f0010020-0001-4000-8000-000000000020',
  88 + 'f0010021-0001-4000-8000-000000000021',
  89 + 'f0010022-0001-4000-8000-000000000022',
  90 + 'f0010023-0001-4000-8000-000000000023',
  91 + 'f0010024-0001-4000-8000-000000000024',
  92 + 'f0010025-0001-4000-8000-000000000025',
  93 + 'f0010026-0001-4000-8000-000000000026',
  94 + 'f0010030-0001-4000-8000-000000000030',
  95 + 'f0010031-0001-4000-8000-000000000031',
  96 + 'f0010032-0001-4000-8000-000000000032',
  97 + 'f0010033-0001-4000-8000-000000000033',
  98 + 'f0010034-0001-4000-8000-000000000034',
  99 + 'f0010035-0001-4000-8000-000000000035',
  100 + 'f0010036-0001-4000-8000-000000000036',
  101 + 'f0010037-0001-4000-8000-000000000037',
  102 + 'f0010038-0001-4000-8000-000000000038',
  103 + 'f0010039-0001-4000-8000-000000000039'
  104 + )
  105 + )
  106 + );
  107 +
  108 +-- 2) 插入 / 更新 26 条食品 SaaS 菜单(合法 Guid)
  109 +INSERT INTO `menu`
  110 +(
  111 + `Id`,
  112 + `IsDeleted`,
  113 + `CreationTime`,
  114 + `CreatorId`,
  115 + `LastModifierId`,
  116 + `LastModificationTime`,
  117 + `OrderNum`,
  118 + `State`,
  119 + `MenuName`,
  120 + `RouterName`,
  121 + `MenuType`,
  122 + `PermissionCode`,
  123 + `ParentId`,
  124 + `MenuIcon`,
  125 + `Router`,
  126 + `IsLink`,
  127 + `IsCache`,
  128 + `IsShow`,
  129 + `Remark`,
  130 + `Component`,
  131 + `MenuSource`,
  132 + `Query`,
  133 + `ConcurrencyStamp`
  134 +)
  135 +VALUES
  136 +('f0010001-0001-4000-8000-000000000001', 0, NOW(), NULL, NULL, NULL, -1, 1, '首页概览', 'FoodLabelingDashboard', 1, 'menu.dashboard.analytics', '0', 'lucide:layout-dashboard', '/analytics', 0, 0, 1, NULL, '/food-labeling/dashboard/index', 0, NULL, UUID()),
  137 +
  138 +('f0010002-0001-4000-8000-000000000002', 0, NOW(), NULL, NULL, NULL, 5, 1, '平台管理', 'FoodLabelingPlatform', 0, 'menu.platform', '0', 'lucide:cloud-cog', '/platform', 0, 0, 1, NULL, NULL, 0, NULL, UUID()),
  139 +('f0010003-0001-4000-8000-000000000003', 0, NOW(), NULL, NULL, NULL, 6, 1, 'SAAS 公司', 'FoodLabelingPlatformTenants', 1, 'menu.platform.tenants', 'f0010002-0001-4000-8000-000000000002', 'lucide:building', '/platform/tenants', 0, 0, 1, NULL, '/food-labeling/platform/tenants/index', 0, NULL, UUID()),
  140 +
  141 +('f0010010-0001-4000-8000-000000000010', 0, NOW(), NULL, NULL, NULL, 10, 1, '标签管理', 'FoodLabelingLabeling', 0, 'menu.labeling', '0', 'lucide:tags', '/labeling', 0, 0, 1, NULL, NULL, 0, NULL, UUID()),
  142 +('f0010011-0001-4000-8000-000000000011', 0, NOW(), NULL, NULL, NULL, 11, 1, '标签', 'FoodLabelingLabels', 1, 'menu.labeling.labels', 'f0010010-0001-4000-8000-000000000010', 'lucide:tag', '/labels', 0, 0, 1, NULL, '/food-labeling/labeling/labels/index', 0, NULL, UUID()),
  143 +('f0010012-0001-4000-8000-000000000012', 0, NOW(), NULL, NULL, NULL, 12, 1, '标签分类', 'FoodLabelingLabelCategories', 1, 'menu.labeling.categories', 'f0010010-0001-4000-8000-000000000010', 'lucide:folder-tree', '/label-categories', 0, 0, 1, NULL, '/food-labeling/labeling/label-categories/index', 0, NULL, UUID()),
  144 +('f0010013-0001-4000-8000-000000000013', 0, NOW(), NULL, NULL, NULL, 13, 1, '标签类型', 'FoodLabelingLabelTypes', 1, 'menu.labeling.types', 'f0010010-0001-4000-8000-000000000010', 'lucide:layers', '/label-types', 0, 0, 1, NULL, '/food-labeling/labeling/label-types/index', 0, NULL, UUID()),
  145 +('f0010014-0001-4000-8000-000000000014', 0, NOW(), NULL, NULL, NULL, 14, 1, '标签模板', 'FoodLabelingLabelTemplates', 1, 'menu.labeling.templates', 'f0010010-0001-4000-8000-000000000010', 'lucide:layout-template', '/label-templates', 0, 0, 1, NULL, '/food-labeling/labeling/label-templates/index', 0, NULL, UUID()),
  146 +('f0010015-0001-4000-8000-000000000015', 0, NOW(), NULL, NULL, NULL, 15, 1, '多选选项集', 'FoodLabelingMultipleOptions', 1, 'menu.labeling.multiple-options', 'f0010010-0001-4000-8000-000000000010', 'lucide:list-checks', '/multiple-options', 0, 0, 1, NULL, '/food-labeling/labeling/multiple-options/index', 0, NULL, UUID()),
  147 +
  148 +('f0010020-0001-4000-8000-000000000020', 0, NOW(), NULL, NULL, NULL, 15, 1, '业务模块', 'FoodLabelingModules', 0, 'menu.modules', '0', 'lucide:boxes', '/modules', 0, 0, 1, NULL, NULL, 0, NULL, UUID()),
  149 +('f0010021-0001-4000-8000-000000000021', 0, NOW(), NULL, NULL, NULL, 16, 1, '培训', 'FoodLabelingTraining', 1, 'menu.modules.training', 'f0010020-0001-4000-8000-000000000020', 'lucide:graduation-cap', '/training', 0, 0, 1, NULL, '/food-labeling/modules/training/index', 0, NULL, UUID()),
  150 +('f0010022-0001-4000-8000-000000000022', 0, NOW(), NULL, NULL, NULL, 17, 1, '告警', 'FoodLabelingAlerts', 1, 'menu.modules.alerts', 'f0010020-0001-4000-8000-000000000020', 'lucide:bell', '/alerts', 0, 0, 1, NULL, '/food-labeling/modules/alerts/index', 0, NULL, UUID()),
  151 +('f0010023-0001-4000-8000-000000000023', 0, NOW(), NULL, NULL, NULL, 18, 1, '任务', 'FoodLabelingTasks', 1, 'menu.modules.tasks', 'f0010020-0001-4000-8000-000000000020', 'lucide:list-todo', '/tasks', 0, 0, 1, NULL, '/food-labeling/modules/tasks/index', 0, NULL, UUID()),
  152 +('f0010024-0001-4000-8000-000000000024', 0, NOW(), NULL, NULL, NULL, 19, 1, '传感器', 'FoodLabelingSensors', 1, 'menu.modules.sensors', 'f0010020-0001-4000-8000-000000000020', 'lucide:activity', '/sensors', 0, 0, 1, NULL, '/food-labeling/modules/sensors/index', 0, NULL, UUID()),
  153 +('f0010025-0001-4000-8000-000000000025', 0, NOW(), NULL, NULL, NULL, 20, 1, '食物浪费', 'FoodLabelingFoodWaste', 1, 'menu.modules.food-waste', 'f0010020-0001-4000-8000-000000000020', 'lucide:apple', '/food-waste', 0, 0, 1, NULL, '/food-labeling/modules/food-waste/index', 0, NULL, UUID()),
  154 +('f0010026-0001-4000-8000-000000000026', 0, NOW(), NULL, NULL, NULL, 21, 1, '电子标签', 'FoodLabelingELabelModule', 1, 'menu.modules.e-label', 'f0010020-0001-4000-8000-000000000020', 'lucide:file-digit', '/e-label-module', 0, 0, 1, NULL, '/food-labeling/modules/e-label/index', 0, NULL, UUID()),
  155 +
  156 +('f0010030-0001-4000-8000-000000000030', 0, NOW(), NULL, NULL, NULL, 20, 1, '管理', 'FoodLabelingManagement', 0, 'menu.management', '0', 'lucide:building-2', '/management', 0, 0, 1, NULL, NULL, 0, NULL, UUID()),
  157 +('f0010031-0001-4000-8000-000000000031', 0, NOW(), NULL, NULL, NULL, 21, 1, '账户管理', 'FoodLabelingAccountManagement', 1, 'menu.management.account', 'f0010030-0001-4000-8000-000000000030', 'lucide:users', '/account-management', 0, 0, 1, NULL, '/food-labeling/management/account-management/index', 0, NULL, UUID()),
  158 +('f0010032-0001-4000-8000-000000000032', 0, NOW(), NULL, NULL, NULL, 22, 1, '系统菜单', 'FoodLabelingSystemMenu', 1, 'menu.management.system-menu', 'f0010030-0001-4000-8000-000000000030', 'lucide:menu-square', '/system-menu', 0, 0, 1, NULL, '/food-labeling/management/system-menu/index', 0, NULL, UUID()),
  159 +('f0010033-0001-4000-8000-000000000033', 0, NOW(), NULL, NULL, NULL, 23, 1, '菜单管理', 'FoodLabelingMenuManagement', 1, 'menu.management.menu', 'f0010030-0001-4000-8000-000000000030', 'lucide:utensils', '/menu-management', 0, 0, 1, NULL, '/food-labeling/management/menu-management/index', 0, NULL, UUID()),
  160 +('f0010034-0001-4000-8000-000000000034', 0, NOW(), NULL, NULL, NULL, 24, 1, '设备', 'FoodLabelingDevices', 1, 'menu.management.devices', 'f0010030-0001-4000-8000-000000000030', 'lucide:smartphone', '/devices', 0, 0, 1, NULL, '/food-labeling/modules/devices/index', 0, NULL, UUID()),
  161 +('f0010035-0001-4000-8000-000000000035', 0, NOW(), NULL, NULL, NULL, 25, 1, '报表', 'FoodLabelingReports', 1, 'menu.management.reports', 'f0010030-0001-4000-8000-000000000030', 'lucide:file-bar-chart', '/reports', 0, 0, 1, NULL, '/food-labeling/management/reports/index', 0, NULL, UUID()),
  162 +('f0010036-0001-4000-8000-000000000036', 0, NOW(), NULL, NULL, NULL, 26, 1, '发票', 'FoodLabelingInvoices', 1, 'menu.management.invoices', 'f0010030-0001-4000-8000-000000000030', 'lucide:receipt', '/invoices', 0, 0, 1, NULL, '/food-labeling/modules/invoices/index', 0, NULL, UUID()),
  163 +('f0010037-0001-4000-8000-000000000037', 0, NOW(), NULL, NULL, NULL, 27, 1, '二维码', 'FoodLabelingQrCodes', 1, 'menu.management.qr-codes', 'f0010030-0001-4000-8000-000000000030', 'lucide:qr-code', '/qr-codes', 0, 0, 1, NULL, '/food-labeling/modules/qr-codes/index', 0, NULL, UUID()),
  164 +('f0010038-0001-4000-8000-000000000038', 0, NOW(), NULL, NULL, NULL, 28, 1, '支持', 'FoodLabelingSupport', 1, 'menu.management.support', 'f0010030-0001-4000-8000-000000000030', 'lucide:life-buoy', '/support', 0, 0, 1, NULL, '/food-labeling/management/support/index', 0, NULL, UUID()),
  165 +('f0010039-0001-4000-8000-000000000039', 0, NOW(), NULL, NULL, NULL, 29, 1, 'API', 'FoodLabelingApi', 1, 'menu.management.api', 'f0010030-0001-4000-8000-000000000030', 'lucide:code-2', '/api-settings', 0, 0, 1, NULL, '/food-labeling/modules/api/index', 0, NULL, UUID())
  166 +ON DUPLICATE KEY UPDATE
  167 + `IsDeleted` = VALUES(`IsDeleted`),
  168 + `LastModificationTime` = NOW(),
  169 + `OrderNum` = VALUES(`OrderNum`),
  170 + `State` = VALUES(`State`),
  171 + `MenuName` = VALUES(`MenuName`),
  172 + `RouterName` = VALUES(`RouterName`),
  173 + `MenuType` = VALUES(`MenuType`),
  174 + `PermissionCode` = VALUES(`PermissionCode`),
  175 + `ParentId` = VALUES(`ParentId`),
  176 + `MenuIcon` = VALUES(`MenuIcon`),
  177 + `Router` = VALUES(`Router`),
  178 + `IsLink` = VALUES(`IsLink`),
  179 + `IsCache` = VALUES(`IsCache`),
  180 + `IsShow` = VALUES(`IsShow`),
  181 + `Remark` = VALUES(`Remark`),
  182 + `Component` = VALUES(`Component`),
  183 + `MenuSource` = VALUES(`MenuSource`),
  184 + `Query` = VALUES(`Query`),
  185 + `ConcurrencyStamp` = UUID();
  186 +
  187 +-- 导入后可执行下面语句检查:
  188 +-- SELECT Id, ParentId, MenuName, RouterName, Router, MenuType, MenuSource, OrderNum, IsShow, State
  189 +-- FROM `menu`
  190 +-- WHERE IsDeleted = 0
  191 +-- ORDER BY OrderNum ASC, MenuName ASC;
... ...
项目相关文档/批量导入导出接口说明.md
... ... @@ -10,6 +10,7 @@
10 10 |------|------|
11 11 | [公共约定](#公共约定) | 基址、鉴权、Swagger、通用注意事项 |
12 12 | [共享配置](#共享配置) | `appsettings` 中 `FoodLabeling:BatchImport` |
  13 +| [在线批量导入(JSON,推荐)](#在线批量导入json推荐) | Region / Location / Team Member / Product 四模块 JSON 逐行导入 |
13 14 | [1 Location Manager(门店)](#1-location-manager门店) | 下载模板 / Excel 导出 / 导入 / 批量编辑 |
14 15 | [2 Team Member(成员)](#2-team-member成员) | 下载模板 / **PDF 全量导出** / Excel 导入 / 批量编辑 |
15 16 | [3 Products(菜单-产品)](#3-products菜单-产品) | 下载模板 / Excel 全量导出 / Excel 导入 / 批量编辑 |
... ... @@ -27,7 +28,7 @@
27 28 - **Swagger 分组**:**「食品标签-美国版接口」**;具体路径以 Swagger 展示为准(下表为常见命名,联调时请以 Swagger 为准)。
28 29 - **鉴权**:与其它业务接口相同,请求头携带登录接口返回的 **`data.token` 完整值**(已含 `Bearer ` 前缀),示例:`Authorization: {data.token}`。
29 30 - **文件类响应**:`Content-Type` 多为 `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`(`.xlsx`)。
30   -- **导入类请求**:统一使用 **`multipart/form-data`**,文件字段名以各接口说明为准(Location 为 **`file`**)
  31 +- **导入类请求**:Excel 导入统一使用 **`multipart/form-data`**,文件字段名以各接口说明为准(Location 为 **`file`**);**推荐**使用下文 [在线批量导入(JSON)](#在线批量导入json推荐) 替代 Excel 上传
31 32 - **批量编辑类请求**:使用 **`application/json`**,一次提交多行(与前端表格「保存全部」对齐)。
32 33 - **导出类响应**:Location 与 **Products(菜单-产品)**、**Reports — Print Log** 为 **Excel 全量**;**Team Member**、**Account Management 的 Company / Region**、**Reports — Label Report** 等为 **PDF**(见各小节);数据量极大时请注意服务端内存与响应耗时。
33 34  
... ... @@ -52,6 +53,81 @@
52 53  
53 54 ---
54 55  
  56 +## 在线批量导入(JSON,推荐)
  57 +
  58 +四个 Account Management / 业务模块均提供 **JSON 在线批量导入** 接口:请求体 `{ "items": [ ...CreateInputVo ] }`,**逐行调用现有 `CreateAsync`**,**部分成功**(单行失败不影响其它行)。`errors` 中 **`index` 从 0 起**(与 `items` 数组下标一致)。单次条数上限为 **`MaxImportRows`**(默认 5000)。
  59 +
  60 +| 模块 | 方法 | HTTP | 路径 | 失败行 key 字段 |
  61 +|------|------|------|------|-----------------|
  62 +| Region(Group) | `BatchImportOnlineAsync` | `POST` | `/api/app/group/batch-import-online` | `groupName` |
  63 +| Location | `BatchImportOnlineAsync` | `POST` | `/api/app/location/batch-import-online` | `locationCode` |
  64 +| Team Member | `BatchImportOnlineAsync` | `POST` | `/api/app/team-member/batch-import-online` | `userName` |
  65 +| Product | `BatchImportOnlineAsync` | `POST` | `/api/app/product/batch-import-online` | `productName` |
  66 +
  67 +**公共返回字段**(各模块 `*BatchImportOnlineResultDto`):`successCount`、`failCount`、`errors[{ index, message, key? }]`(JSON 命名一般为 camelCase)。
  68 +
  69 +**Team Member 特殊规则**:`password` 为空或仅空白时,使用配置 **`TeamMemberImportDefaultPassword`**;若配置亦为空则该行失败。
  70 +
  71 +**与 Excel 导入关系**:Excel 导入接口(`import-*-batch`)**保留但不推荐**,新前端与联调请优先 **`batch-import-online`**;Excel 仍适用于离线模板填写后上传的旧流程。
  72 +
  73 +### Region(Group)在线导入
  74 +
  75 +| 项目 | 说明 |
  76 +|------|------|
  77 +| Body | `GroupBatchImportOnlineInputVo`:`items` 为 `GroupCreateInputVo` 数组(`groupName`、`partnerId`、`state`) |
  78 +| 业务 | 与单条 `POST /api/app/group` 一致;ID 由现有 `CreateAsync` 生成 |
  79 +
  80 +```bash
  81 +curl -X POST "$BASE/api/app/group/batch-import-online" \
  82 + -H "Authorization: TOKEN" \
  83 + -H "Content-Type: application/json" \
  84 + -d "{\"items\":[{\"groupName\":\"NC Region\",\"partnerId\":\"PARTNER_ID\",\"state\":true}]}"
  85 +```
  86 +
  87 +### Location 在线导入
  88 +
  89 +| 项目 | 说明 |
  90 +|------|------|
  91 +| Body | `LocationBatchImportOnlineInputVo`:`items` 为 `LocationCreateInputVo` 数组 |
  92 +| 业务 | 与单条 `POST /api/app/location` 一致 |
  93 +
  94 +```bash
  95 +curl -X POST "$BASE/api/app/location/batch-import-online" \
  96 + -H "Authorization: TOKEN" \
  97 + -H "Content-Type: application/json" \
  98 + -d "{\"items\":[{\"locationCode\":\"LOC001\",\"locationName\":\"UNCC store\",\"partner\":\"MedVantage Cafe Group\",\"groupName\":\"NC Region\",\"state\":true}]}"
  99 +```
  100 +
  101 +### Team Member 在线导入
  102 +
  103 +| 项目 | 说明 |
  104 +|------|------|
  105 +| Body | `TeamMemberBatchImportOnlineInputVo`:`items` 为 `TeamMemberCreateInputVo` 数组 |
  106 +| 密码 | 未填 `password` 时使用 `TeamMemberImportDefaultPassword` |
  107 +
  108 +```bash
  109 +curl -X POST "$BASE/api/app/team-member/batch-import-online" \
  110 + -H "Authorization: TOKEN" \
  111 + -H "Content-Type: application/json" \
  112 + -d "{\"items\":[{\"fullName\":\"John Doe\",\"userName\":\"john@example.com\",\"email\":\"john@example.com\",\"roleId\":\"ROLE_GUID\",\"locationIds\":[\"LOCATION_GUID\"],\"state\":true}]}"
  113 +```
  114 +
  115 +### Product 在线导入
  116 +
  117 +| 项目 | 说明 |
  118 +|------|------|
  119 +| Body | `ProductBatchImportOnlineInputVo`:`items` 为 `ProductCreateInputVo` 数组 |
  120 +| 业务 | 与单条 `POST /api/app/product` 一致 |
  121 +
  122 +```bash
  123 +curl -X POST "$BASE/api/app/product/batch-import-online" \
  124 + -H "Authorization: TOKEN" \
  125 + -H "Content-Type: application/json" \
  126 + -d "{\"items\":[{\"productName\":\"Tuna & Bacon Sub\",\"categoryId\":\"CATEGORY_ID\",\"productCode\":\"40001\",\"state\":true,\"locationIds\":[\"LOCATION_GUID\"]}]}"
  127 +```
  128 +
  129 +---
  130 +
55 131 ## 1 Location Manager(门店)
56 132  
57 133 **应用服务**:`LocationAppService`(模块 `food-labeling-us`)。
... ... @@ -80,7 +156,7 @@
80 156 | 排序 | 与列表一致:有 `Sorting` 则按其排序,否则默认 `CreationTime` 降序 |
81 157 | 响应文件名示例 | `locations-export-yyyyMMdd-HHmmss.xlsx` |
82 158  
83   -### 1.3 批量导入 Excel
  159 +### 1.3 批量导入 Excel(不推荐,请优先 [batch-import-online](#在线批量导入json推荐))
84 160  
85 161 | 项目 | 说明 |
86 162 |------|------|
... ... @@ -182,7 +258,7 @@
182 258  
183 259 **说明**:PDF 中「Assigned Locations」展示该成员**全部**已分配门店(不受列表按门店筛选时「仅显示命中门店」的收缩影响),便于导出后审阅完整权限。
184 260  
185   -### 2.3 批量导入 Excel
  261 +### 2.3 批量导入 Excel(不推荐,请优先 [batch-import-online](#在线批量导入json推荐))
186 262  
187 263 | 项目 | 说明 |
188 264 |------|------|
... ... @@ -255,7 +331,7 @@
255 331  
256 332 **部署**:须使用 **`dotnet publish` 后的完整输出目录**(含 `ClosedXML.dll`、`DocumentFormat.OpenXml.dll`、`Yi.Abp.Web.deps.json` 等)部署;**不要用 `bin/Debug`(或 `bin/Release`)里挑文件当上线包**。也**禁止**「本地跑起来后只把若干 dll / 自己改过的文件」覆盖到线上(极易漏掉第三方依赖)。上线建议整包替换发布目录,或在服务器上 `git pull` + `dotnet publish`;发布时若输出里缺少 `ClosedXML.dll`,当前 `Yi.Abp.Web` 工程会在 `dotnet publish` 结束时报错提示。
257 333  
258   -### 3.3 批量导入 Excel
  334 +### 3.3 批量导入 Excel(不推荐,请优先 [batch-import-online](#在线批量导入json推荐))
259 335  
260 336 | 项目 | 说明 |
261 337 |------|------|
... ... @@ -429,6 +505,27 @@ curl -X PUT &quot;$BASE/api/app/product/products-bulk&quot; \
429 505 -H "Authorization: TOKEN" \
430 506 -H "Content-Type: application/json" \
431 507 -d "{\"items\":[{\"id\":\"YOUR_PRODUCT_ID\",\"productName\":\"Tuna & Bacon Sub\",\"categoryId\":\"CATEGORY_ID\",\"productCode\":\"40001\",\"state\":true,\"locationIds\":[\"LOCATION_GUID_1\",\"LOCATION_GUID_2\"]}]}"
  508 +
  509 +# --- 在线批量导入(JSON,推荐)---
  510 +curl -X POST "$BASE/api/app/group/batch-import-online" \
  511 + -H "Authorization: TOKEN" \
  512 + -H "Content-Type: application/json" \
  513 + -d "{\"items\":[{\"groupName\":\"NC Region\",\"partnerId\":\"PARTNER_ID\",\"state\":true}]}"
  514 +
  515 +curl -X POST "$BASE/api/app/location/batch-import-online" \
  516 + -H "Authorization: TOKEN" \
  517 + -H "Content-Type: application/json" \
  518 + -d "{\"items\":[{\"locationCode\":\"LOC001\",\"locationName\":\"UNCC store\",\"state\":true}]}"
  519 +
  520 +curl -X POST "$BASE/api/app/team-member/batch-import-online" \
  521 + -H "Authorization: TOKEN" \
  522 + -H "Content-Type: application/json" \
  523 + -d "{\"items\":[{\"fullName\":\"John\",\"userName\":\"john@example.com\",\"email\":\"john@example.com\",\"roleId\":\"ROLE_GUID\",\"state\":true}]}"
  524 +
  525 +curl -X POST "$BASE/api/app/product/batch-import-online" \
  526 + -H "Authorization: TOKEN" \
  527 + -H "Content-Type: application/json" \
  528 + -d "{\"items\":[{\"productName\":\"Tuna Sub\",\"categoryId\":\"CATEGORY_ID\",\"state\":true}]}"
432 529 ```
433 530  
434 531 更完整的接口测试流程见仓库内 `.codex/skills/api-interface-testing/SKILL.md`。
... ...