# 5-18 接口优化 本文档说明 **2026-05-18** 对美国版接口修复与约定,包括: 1. **`/api/app/rbac-role`**:`accessPermissions` 读写(见下文 rbac-role 章节)。 2. **`/api/app/team-member`**:成员 **平台端无法登录**(已修复);列表支持 **Company / Region / Location** 筛选(见 [team-member-list](#team-member-列表-companyregionlocation-筛选))。 3. **`/api/app/label-type`**:列表 **No. of Labels / Region / Location**;新增/编辑 **Region·Location 多选**(见 [label-type](#label-type-标签类型))。 4. **`/api/app/label-multiple-option`**:列表 **Region / Location** 筛选与出参;新增/编辑 **Region·Location 多选**(见 [label-multiple-option](#label-multiple-option-多选项))。 5. **`/api/app/reports/print-log-list`**:Print Log **Label ID** 当日门店序号 + **Expiry Date** 从打印快照解析(见 [reports-print-log](#reports-print-log-打印日志))。 6. **`/api/app/reports/label-report`**:按 Token **门店绑定**统计(管理员全量;见 [reports-label-report](#reports-label-report-标签报表))。 7. **`/api/app/us-app-auth/location-detail/{locationId}`**:出参增加 **经营时间** `operatingHours`(见 [us-app-auth-location-detail](#us-app-auth-location-detail-门店详情))。 8. **App `POST /api/app/us-app-labeling/get-print-log-list`**、**`get-label-report`**:管理员 / Partner 角色可查看**当前门店全部**打印记录与统计(见 [us-app-print-log](#us-app-print-log-app-打印日志与报表))。 9. **`GET /api/app/us-app-labeling/labeling-tree`**:修复产品分类 `SPECIFIED` 未配门店关联导致全店空树(见 [us-app-labeling-tree](#us-app-labeling-tree-四级列表))。 10. **`GET /api/app/location`**:门店列表按 Token **Region** 数据范围过滤(见 [location-list](#location-门店列表))。 11. **`GET /api/app/reports/template-print-stat-list`**:按模板统计打印标签数量(见 [reports-template-print-stat](#reports-template-print-stat-模板打印统计))。 --- ## reports-print-log 打印日志 **应用服务**:`ReportsAppService` **接口**:`GET /api/app/reports/print-log-list`(及同源导出 `export-print-log-pdf` / `export-print-log-excel`) **辅助类**:`ReportsPrintLogDailyLabelIdHelper`、`ReportsPrintLogExpiryHelper` ### Label ID 变更说明 | 项 | 说明 | |----|------| | **Label ID 含义** | 由 **`fl_label.LabelCode`**(如 `079`)改为 **门店当日打印序号** | | **展示格式** | `{yyyyMMdd}-{n}`,例如 `20260513-1`、`20260513-2` | | **排序规则** | 按 **`LocationId`(门店)** + **自然日**(`PrintedAt`,无则 `CreationTime`)分组;组内按打印时间 **升序**,同秒按任务 `Id` 升序,`n` 从 **1** 递增 | | **跨页一致** | 分页列表与导出使用同一套序号(按门店全日任务计算,非仅当前页内排序) | | **API 字段名** | 仍为 **`labelCode`**(兼容前端列绑定),内容为当日序号,**不是**标签主数据编码 | ### 列表出参(节选) | 字段 | 说明 | |------|------| | taskId | 打印任务 Id(`fl_label_print_task.Id`,重打用) | | **labelCode** | **Label ID 列**:`20260515-1` 这种当日门店序号 | | productName / categoryName / templateText | 不变 | | printedAt | 打印时间 | | locationText / locationId | 门店 | | **expiryDateText** | **Expiry Date 列**:从 `PrintInputJson` 解析的保质期展示文案;无则「无」 | **示例** ```json { "taskId": "task-guid-001", "labelCode": "20260515-3", "productName": "Tuna & Bacon Sub", "printedAt": "2026-05-15T15:00:40", "locationText": "UNCC store (LOC001)", "expiryDateText": "05/17" } ``` ### Expiry Date(到期时间)变更说明 | 项 | 说明 | |----|------| | **数据来源** | `fl_label_print_task.PrintInputJson`(App 打印接口落库,多为**整份模板快照** JSON,含 `elements[]`) | | **解析方式** | `ReportsPrintLogExpiryHelper.ExtractExpiryText`:先读根级 `expiryDate` / `expiry` / `expirationDate` 等;若无,在 `elements` 中匹配 **duration date / expiry** 类元素,取 `config.text`(或 `config.format`) | | **匹配规则** | 与 Web `isDateTimeDataEntryField` 对齐:`elementName` / `inputKey` 含 `durationdate`、`expirydate` 等;`typeAdd` 含 `duration date`;排除 `currentdate`、`currenttime`、`prepped` 等制备日期字段 | | **展示格式** | **与标签出纸一致**(如 `05/17`、`2026-05-17`),不做二次换算 | | **适用范围** | 分页列表、`export-print-log-pdf`、`export-print-log-excel` 共用同一解析逻辑 | **库内样例(节选)** `PrintInputJson.elements` 中 `elementName: "durationdate1"` → `config.text: "05/17"`,接口应返回 `expiryDateText: "05/17"`(此前仅查根级字段,列恒为「无」)。 ### Label ID 计算逻辑(与代码一致) 同一门店、同一天内: 1. 查询 `fl_label_print_task` 中 `LocationId` 相同且 `DATE(COALESCE(PrintedAt, CreationTime))` 相同的全部任务; 2. 按打印时间升序编号 `1, 2, 3…`; 3. 格式化为 `yyyyMMdd-n`。 > **注意**:序号统计范围为该门店**当日全部打印任务**(不限于当前列表筛选关键字),以保证同一天内序号全局唯一、连续。列表/导出的日期、门店筛选只决定**哪些行展示**,不改变已展示行的序号。 ### 库内核对 SQL ```sql SELECT t.Id AS task_id, t.LocationId, DATE(COALESCE(t.PrintedAt, t.CreationTime)) AS print_day, ROW_NUMBER() OVER ( PARTITION BY t.LocationId, DATE(COALESCE(t.PrintedAt, t.CreationTime)) ORDER BY COALESCE(t.PrintedAt, t.CreationTime), t.Id ) AS daily_seq, CONCAT( DATE_FORMAT(COALESCE(t.PrintedAt, t.CreationTime), '%Y%m%d'), '-', ROW_NUMBER() OVER ( PARTITION BY t.LocationId, DATE(COALESCE(t.PrintedAt, t.CreationTime)) ORDER BY COALESCE(t.PrintedAt, t.CreationTime), t.Id ) ) AS label_id_display FROM fl_label_print_task t WHERE t.LocationId = :locationId AND COALESCE(t.PrintedAt, t.CreationTime) >= :dayStart AND COALESCE(t.PrintedAt, t.CreationTime) < :dayEnd ORDER BY print_day, daily_seq; ``` ### 联调注意 | 现象 | 可能原因 | |------|----------| | 仍显示 `079` 等 | 后端未部署;或前端绑错字段(应绑 `labelCode`) | | 同一天序号不连续 | 存在无 `LocationId` 的任务,显示「无」 | | 与 App 端打印记录不一致 | App 接口为另一套(`us-app-labeling` 打印历史),本规则仅 **Reports Print Log** | --- ## location 门店列表 **应用服务**:`LocationAppService` **接口**:`GET /api/app/location`(分页;同源 Excel 导出 `export-locations-excel` 使用相同筛选) **辅助类**:`LocationRegionScopeHelper`、`ReportsRoleHelper` ### 数据范围(按 Token) | 角色 | 可见门店 | |------|----------| | **管理员** | **全部**未删除门店(`ReportsRoleHelper.IsAdminRole`) | | **非管理员** | 仅 **`userlocation` 绑定门店所属 Region** 下的全部门店 | **Region 判定**:与 `fl_group` / `group` 列表一致——取绑定门店的 `location.Partner`(公司名称)+ `location.GroupName`(Region 名称)去重后,列表返回 **同一 Partner + GroupName** 的所有 `location` 行(不限于本人绑定的那几家店)。 未绑定门店、或绑定门店缺少 `Partner`/`GroupName` 时,列表为空。 ### 请求示例 ```http GET /api/app/location?SkipCount=1&MaxResultCount=10 Authorization: Bearer {token} ``` 可选 Query:`Keyword`、`Partner`、`GroupName`、`State`、`Sorting`(在数据范围之上再收窄)。 ### 与 group 列表的关系 | 接口 | 非管理员范围 | |------|----------------| | `GET /api/app/group` | 可见的 **fl_group** 记录 | | `GET /api/app/location` | 上述 Region 对应的 **location** 门店 | ### 库内核对(非管理员) ```sql -- 当前用户绑定门店所属的 Region(Partner + GroupName) SELECT DISTINCT loc.Partner, loc.GroupName FROM userlocation ul INNER JOIN location loc ON ul.LocationId = loc.Id AND loc.IsDeleted = 0 WHERE ul.IsDeleted = 0 AND ul.UserId = :currentUserId AND loc.Partner IS NOT NULL AND loc.Partner != '' AND loc.GroupName IS NOT NULL AND loc.GroupName != ''; -- 列表应返回的同 Region 全部门店 SELECT loc.* FROM location loc WHERE loc.IsDeleted = 0 AND (loc.Partner, loc.GroupName) IN ( SELECT DISTINCT l2.Partner, l2.GroupName FROM userlocation ul INNER JOIN location l2 ON ul.LocationId = l2.Id AND l2.IsDeleted = 0 WHERE ul.IsDeleted = 0 AND ul.UserId = :currentUserId ); ``` ### 联调注意 | 现象 | 处理 | |------|------| | 非管理员列表为空 | 检查 `userlocation` 是否有绑定;门店 `Partner`、`GroupName` 是否已填 | | 只能看到部分 Region | 正常:仅能看到绑定门店所在 Region;换绑门店可扩大范围 | | 入参 `GroupName` 越权筛选 | 仅能筛 **已有权限范围内** 的数据,不会扩大范围 | --- ## us-app-labeling-tree 四级列表 **应用服务**:`UsAppLabelingAppService.GetLabelingTreeAsync` **接口**:`GET /api/app/us-app-labeling/labeling-tree?locationId={guid}` ### 问题与修复(2026-05-18) | 现象 | 原因 | 处理 | |------|------|------| | App 各门店 Labeling 页 **No products found** | 产品已写入 `fl_location_product`,但 `fl_product_category.AvailabilityType=SPECIFIED` 且 **未**在 `fl_product_category_location` 配置当前门店时,旧逻辑在 Join 条件中整行过滤 | **已修复**:四级树的产品范围仅由 **`fl_location_product` + `fl_label.LocationId`** 决定;产品分类、标签分类仅校验未删除且启用,不再用 SPECIFIED 子查询拦截 | | 某门店仍为空 | 该门店 **无** `fl_label`(`LocationId` 匹配且 `State=1`) | 属数据:需在 Web **Labels** 为该门店创建/复制标签,或把已有标签的 `LocationId` 指到该门店 | ### 数据范围(与代码一致) 1. **门店产品**:`fl_location_product.LocationId = locationId` 2. **门店标签**:`fl_label.LocationId = locationId` 且未删除、启用 3. **关联**:`fl_label_product` 连接标签与产品 4. **登录**:须绑定该门店(`userlocation`),否则返回业务错误 ### 请求示例 ```http GET /api/app/us-app-labeling/labeling-tree?locationId=3a212211-3b01-d66f-a804-125c0cee3bf0 Authorization: Bearer {token} ``` ### 出参 `UsAppLabelCategoryTreeNodeDto[]`(L1 标签分类 → L2 产品分类 → L3 产品+模板卡片 → L4 标签类型)。 ### 库内核对 SQL ```sql -- 门店是否有可展示产品(绑定产品数) SELECT COUNT(*) FROM fl_location_product WHERE LocationId = :locationId; -- 门店是否有可用标签(决定树是否非空) SELECT COUNT(*) FROM fl_label WHERE LocationId = :locationId AND IsDeleted = 0 AND State = 1; -- 修复后应能查到的树行数(与接口一致) SELECT COUNT(*) AS tree_rows FROM fl_label_product lp INNER JOIN fl_label l ON lp.LabelId = l.Id INNER JOIN fl_product p ON lp.ProductId = p.Id INNER JOIN fl_label_category c ON l.LabelCategoryId = c.Id INNER JOIN fl_label_type t ON l.LabelTypeId = t.Id INNER JOIN fl_label_template tpl ON l.TemplateId = tpl.Id LEFT JOIN fl_product_category pc ON p.CategoryId = pc.Id WHERE l.LocationId = :locationId AND p.Id IN (SELECT ProductId FROM fl_location_product WHERE LocationId = :locationId) AND l.IsDeleted = 0 AND l.State = 1 AND p.IsDeleted = 0 AND p.State = 1 AND c.IsDeleted = 0 AND c.State = 1 AND t.IsDeleted = 0 AND t.State = 1 AND tpl.IsDeleted = 0 AND (pc.Id IS NULL OR (pc.IsDeleted = 0 AND pc.State = 1)); ``` --- ## us-app-print-log App 打印日志与报表 **应用服务**:`UsAppLabelingAppService` **辅助类**:`UsAppPrintLogScopeHelper`、`ReportsRoleHelper` ### 涉及接口 | 接口 | 说明 | |------|------| | `POST /api/app/us-app-labeling/get-print-log-list` | 打印日志分页 | | `POST /api/app/us-app-labeling/get-label-report` | 当前门店 Label Report 统计(出参与 Web `reports/label-report` 同结构) | | `POST /api/app/us-app-labeling/reprint` | 重打;权限与日志查看一致 | ### 查看权限(按 Token + 当前门店) 须已登录,且 `userlocation` **绑定** `locationId`。 | 角色 | 打印日志 / Report 数据范围 | |------|---------------------------| | **管理员** | 当前门店 **全部** 用户的打印任务(`ReportsRoleHelper.IsAdminRole`:用户名为 `admin`、角色码 `admin`、权限 `*:*:*` 等) | | **Partner** | 同上;判定为 `UserRole` → `Role` 的 **`RoleCode` 或 `RoleName` 含 `partner`**(忽略大小写,如 Partner Admin) | | **其它**(如 Staff、Store Manager) | 仅 **`CreatedBy == 当前用户 Id`** | ### get-print-log-list **请求示例** ```http POST /api/app/us-app-labeling/get-print-log-list Authorization: Bearer {token} Content-Type: application/json { "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8", "skipCount": 1, "maxResultCount": 20 } ``` **出参要点** | 字段 | 说明 | |------|------| | `items[].operatorName` | 实际打印人姓名(全店可见时为对应 `CreatedBy` 用户,非固定当前登录人) | | 其它 | 与既有 `PrintLogItemDto` 一致 | ### get-label-report **请求示例** ```json { "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8", "startDate": "2026-04-07", "endDate": "2026-05-18", "keyword": "" } ``` **出参**:`ReportsLabelReportOutputDto`(`summary`、`labelsByCategory`、`printVolumeTrend`、`mostUsedProducts`)。 ### 与 Web Reports 的差异 | 模块 | 范围 | |------|------| | **App** `get-print-log-list` / `get-label-report` | 单门店 + 上表角色规则 | | **Web** `reports/print-log-list` | 非管理员仍仅本人;见 reports-print-log 章节 | | **Web** `reports/label-report` | 非管理员为绑定门店全量;见 reports-label-report 章节 | ### 联调注意 | 现象 | 处理 | |------|------| | Partner 仍只看本人 | 确认角色 `RoleCode`/`RoleName` 含 `partner`,或是否为管理员 | | 列表为空 | 确认 `locationId` 与绑定门店一致;Staff 仅能看到自己打印的记录 | --- ## us-app-auth-location-detail 门店详情 **应用服务**:`UsAppAuthAppService` **接口**:`GET /api/app/us-app-auth/location-detail/{locationId}` **鉴权**:Bearer Token;仅可查当前用户在 **`userlocation`** 中绑定的门店。 ### 变更说明 | 项 | 说明 | |----|------| | **新增出参** | **`operatingHours`**(经营时间) | | **数据来源** | `location.OperatingHours`(`varchar(512)` 自由文本) | | **空值展示** | 未维护或空白时返回 **`无`**(与 `locationName`、`storePhone` 等字段一致) | | **维护入口** | Web 管理端 `POST/PUT /api/app/location` 的 `operatingHours` 字段 | ### 数据库(若列不存在须先执行) 脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_location_add_operating_hours_column.sql` ```sql ALTER TABLE `location` ADD COLUMN `OperatingHours` varchar(512) DEFAULT NULL COMMENT '经营时间(自由文本)' AFTER `Longitude`; ``` ### 请求示例 ```http GET /api/app/us-app-auth/location-detail/3a21220f-db37-3e32-7390-d55f64cd62a8 Authorization: Bearer {token} ``` ### 响应体(UsAppLocationDetailOutputDto) | 字段(JSON) | 类型 | 说明 | |--------------|------|------| | `locationId` | string | 门店主键 Guid | | `locationName` | string | 门店名称 | | `fullAddress` | string | 街道/城市/州/邮编拼接 | | `storePhone` | string | `location.Phone` | | **`operatingHours`** | string | **经营时间**;示例:`Mon–Fri 9:00 AM – 6:00 PM`;空为 `无` | | `managerName` | string | 本店绑定用户中角色含 `manager` 者姓名 | | `managerPhone` | string | 同上用户电话 | ### 响应示例 ```json { "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8", "locationName": "Central Park Store", "fullAddress": "123 Main St, New York, NY 10001", "storePhone": "(212) 555-0100", "operatingHours": "Mon–Fri 9:00 AM – 6:00 PM", "managerName": "Jane Doe", "managerPhone": "+1 (555) 123-4567" } ``` ### 联调注意 | 现象 | 处理 | |------|------| | `operatingHours` 恒为 `无` | 1)确认已执行 DDL;2)在 Web 门店编辑保存 `operatingHours`;3)重启 API 使实体映射生效 | | 403 / 业务异常 | 当前 Token 用户未在 `userlocation` 绑定该 `locationId` | --- ## reports-template-print-stat 模板打印统计 **应用服务**:`ReportsAppService` **接口**:`GET /api/app/reports/template-print-stat-list` **辅助类**:`ReportsLocationScopeHelper`、`ReportsRoleHelper` ### 功能 按 **`fl_label_template`** 汇总 **`fl_label_print_task`** 行数,返回 **模板名称 + 打印标签数量** 分页列表(默认按 `printedCount` 降序)。 ### 数据范围(与 label-report 一致) | 角色 | 统计范围 | |------|----------| | **管理员** | 全部门店打印任务(可按 Company/Region/Location 入参收窄) | | **非管理员** | 仅 **`userlocation` 绑定门店**内全部打印任务(**不按** `CreatedBy` 过滤) | ### 入参 | 参数 | 说明 | |------|------| | `SkipCount` / `MaxResultCount` | 分页(`SkipCount` 为 **1-based 页码**,第一页传 `1`) | | `StartDate` / `EndDate` | 统计区间(含起止日;未传默认近 30 天至今天) | | `PartnerId` | Company(`fl_partner.Id`) | | `GroupId` | Region(`fl_group.Id`) | | `LocationId` | 门店(`location.Id`) | | `Keyword` | **模板名称**模糊匹配(`fl_label_template.TemplateName`) | | `Sorting` | 可选 `PrintedCount asc`;默认 **`PrintedCount desc`** | ### 请求示例 ```http GET /api/app/reports/template-print-stat-list?SkipCount=1&MaxResultCount=20&StartDate=2026-04-07&EndDate=2026-05-18 Authorization: Bearer {token} ``` ### 出参(`items[]`) | 字段 | 类型 | 说明 | |------|------|------| | `templateId` | string? | `fl_label_template.Id` | | `templateName` | string | 模板名称;缺失时 **「无」** | | `printedCount` | int | 该模板下打印任务条数 | **分页包装**:`pageIndex`、`pageSize`、`totalCount`、`totalPages`、`items`(与其它列表一致)。 ### 响应示例 ```json { "pageIndex": 1, "pageSize": 20, "totalCount": 3, "totalPages": 1, "items": [ { "templateId": "tpl-001", "templateName": "2x3 Price Label", "printedCount": 128 }, { "templateId": "tpl-002", "templateName": "Deli Scale Label", "printedCount": 45 } ] } ``` ### 库内核对 ```sql SELECT t.TemplateId, tpl.TemplateName, COUNT(*) AS printed_count FROM fl_label_print_task t LEFT JOIN fl_label_template tpl ON t.TemplateId = tpl.Id INNER JOIN location loc ON t.LocationId = CAST(loc.Id AS CHAR) AND loc.IsDeleted = 0 WHERE COALESCE(t.PrintedAt, t.CreationTime) >= :start AND COALESCE(t.PrintedAt, t.CreationTime) < :endExcl AND t.LocationId IN (:allowedLocationIds) -- 非管理员:userlocation 绑定门店 GROUP BY t.TemplateId, tpl.TemplateName ORDER BY printed_count DESC; ``` ### 联调注意 | 现象 | 处理 | |------|------| | 列表为空 | 检查日期区间、门店绑定、该区间是否有打印任务 | | 与 **print-log-list** 数量不一致 | print-log 非管理员仅本人任务;本接口与 **label-report** 同范围 | | 模板已删除仍有统计 | 任务仍保留 `TemplateId`;名称来自 Left Join,无名称时显示「无」 | --- ## reports-label-report 标签报表 **应用服务**:`ReportsAppService` **接口**:`GET /api/app/reports/label-report`(及同源 `export-label-report-pdf`) **辅助类**:`ReportsLocationScopeHelper`、`ReportsRoleHelper` ### 数据范围(按 Token) | 角色 | 统计范围 | 说明 | |------|----------|------| | **管理员** | 全部门店打印任务 | 识别方式与 Print Log 一致:`admin` 角色 / 用户名为 `admin` / 权限 `*:*:*` | | **非管理员** | 仅 **`userlocation` 绑定门店** | `UserLocation.UserId = 当前用户 Id` 的 `LocationId`;统计该门店下**全部**打印任务,**不按** `CreatedBy` 过滤 | ### 入参筛选(与 Print Log 一致) | 参数 | 说明 | |------|------| | `StartDate` / `EndDate` | 统计区间(含起日、含止日;未传时默认近 30 天至今天) | | `PartnerId` | Company(`fl_partner.Id`) | | `GroupId` | Region(`fl_group.Id`) | | `LocationId` | 门店(`location.Id`) | | `Keyword` | 产品名 / 标签分类 / 产品分类模糊匹配 | **筛选叠加规则** - **管理员**:未传 Company/Region/Location → 不限制门店;传入后与对应门店集合取交集。 - **非管理员**:始终在绑定门店集合内统计;若再传 `PartnerId` / `GroupId` / `LocationId`,与绑定门店 **取交集**(传了未绑定门店 → 空数据)。 - **无绑定门店**:返回空统计(各指标为 0 / 空列表)。 ### 示例 ```http GET /api/app/reports/label-report?StartDate=2026-04-07&EndDate=2026-05-18 Authorization: Bearer {token} ``` ### 出参结构(不变) | 块 | 字段 | |----|------| | `summary` | `totalLabelsPrinted`、`totalLabelsPrintedPrevPeriod`、`mostPrintedCategoryName`、`topProductName`、`avgDailyPrints` 等 | | `labelsByCategory` | 按标签分类汇总 | | `printVolumeTrend` | 近 7 日(在查询区间内)每日打印量 | | `mostUsedProducts` | Top 20 产品 | ### 与 Print Log 的差异 | 模块 | 非管理员范围 | |------|----------------| | **label-report** | 绑定门店内**所有**打印记录 | | **print-log-list** | 仍仅 **`CreatedBy = 当前用户`**(见 `报表Reports接口对接说明.md`) | ### 库内核对(非管理员) ```sql -- 当前用户绑定的门店 SELECT ul.LocationId FROM userlocation ul WHERE ul.IsDeleted = 0 AND ul.UserId = :currentUserId; -- 绑定门店在区间内的打印量(应与接口 summary.totalLabelsPrinted 一致) SELECT COUNT(*) AS cnt FROM fl_label_print_task t WHERE t.LocationId IN (:boundLocationIds) AND COALESCE(t.PrintedAt, t.CreationTime) >= :start AND COALESCE(t.PrintedAt, t.CreationTime) < :endExclusive; ``` --- ## label-multiple-option 多选项 **应用服务**:`LabelMultipleOptionAppService` **命名约定**:UI **Region** = 入参 **`regionIds` / `groupIds`**、列表筛选 **`groupId`**(均为 `fl_group.Id`);UI **Location** = 入参 **`locationIds`**、列表筛选 **`locationId`**(`location.Id`)。存储表 **`fl_label_multiple_option_location`**(与 label-type / label-category 一致)。 ### 数据库变更(列表筛选前须执行) 脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_multiple_option_scope.sql` ```sql ALTER TABLE `fl_label_multiple_option` ADD COLUMN `AvailabilityType` varchar(20) NOT NULL DEFAULT 'ALL' COMMENT '门店可用范围:ALL/SPECIFIED' AFTER `State`; CREATE TABLE IF NOT EXISTS `fl_label_multiple_option_location` ( `Id` varchar(36) NOT NULL, `MultipleOptionId` varchar(36) NOT NULL, `LocationId` varchar(36) NOT NULL, `CreationTime` datetime NOT NULL, `CreatorId` varchar(36) DEFAULT NULL, PRIMARY KEY (`Id`), KEY `idx_fl_lmol_option` (`MultipleOptionId`), KEY `idx_fl_lmol_location` (`LocationId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签多选项适用门店'; ``` > 执行 DDL 后,历史数据默认 **`AvailabilityType = ALL`**,任意 Region/门店筛选下仍可见。 ### 新增 `POST /api/app/label-multiple-option`、编辑 `PUT /api/app/label-multiple-option/{id}` | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | optionCode | string | 是 | 多选项编码 | | optionName | string | 是 | 多选项名称 | | optionValuesJson | string | 否 | 选项值 JSON 字符串 | | state | bool | 否 | 默认 `true` | | orderNum | int | 否 | 排序 | | availabilityType | string | 否 | `ALL` / `SPECIFIED`;传 region/location 数组时自动 **`SPECIFIED`** | | **regionIds** | string[] | 否 | **Region 多选**(`fl_group.Id`) | | **groupIds** | string[] | 否 | 与 `regionIds` 合并去重 | | **locationIds** | string[] | 否 | **Location 多选**(`location.Id`) | **合并规则**:每个 `regionIds` 展开为该 Region 下全部门店,再与 `locationIds` **取并集** → 写入 `fl_label_multiple_option_location`;`SPECIFIED` 时至少 **1** 个有效门店。 **请求示例** ```json POST /api/app/label-multiple-option { "optionCode": "OPT_ALLERGENS", "optionName": "Allergens", "optionValuesJson": "[\"Peanuts\",\"Dairy\",\"Gluten\"]", "state": true, "orderNum": 1, "availabilityType": "SPECIFIED", "regionIds": ["fl_group_id_east"], "locationIds": ["11111111-1111-1111-1111-111111111111"] } ``` **详情 `GET /api/app/label-multiple-option/{id}` 出参(范围字段)** | 字段 | 说明 | |------|------| | availabilityType | `ALL` / `SPECIFIED` | | regionIds | Region Id(`SPECIFIED` 时由门店反推) | | groupIds | 与 `regionIds` 相同 | | locationIds | 已绑定门店 Id | **编辑**:Body 与新增相同;会 **先删后插** 重建 `fl_label_multiple_option_location`。 --- ### 列表 `GET /api/app/label-multiple-option` 示例:`?SkipCount=1&MaxResultCount=10` ### 列表 Query 筛选 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | skipCount | int | 是 | 跳过条数(第 1 页常为 `1`) | | maxResultCount | int | 是 | 每页条数 | | sorting | string | 否 | 排序 | | keyword | string | 否 | 匹配 `optionCode`、`optionName` | | state | bool | 否 | 启用状态 | | **groupId** | string | 否 | **Region** 筛选(`fl_group.Id`) | | **locationId** | string | 否 | **Location** 筛选;**优先于** `groupId` | **筛选语义**(与 label-category / label-type 一致): - 未传 `groupId`、`locationId`:返回全部未删除多选项。 - 传入筛选:返回 **`availabilityType = ALL`** 或 **`fl_label_multiple_option_location` 命中该门店** 的记录。 **请求示例** ```bash GET /api/app/label-multiple-option?SkipCount=1&MaxResultCount=10 Authorization: {token} ``` ```bash GET /api/app/label-multiple-option?SkipCount=1&MaxResultCount=10&groupId=你的fl_group主键 Authorization: {token} ``` ```bash GET /api/app/label-multiple-option?SkipCount=1&MaxResultCount=10&locationId=11111111-1111-1111-1111-111111111111 Authorization: {token} ``` ### 列表出参 `items[]` | 字段 | 类型 | 说明 | |------|------|------| | id | string | 多选项主键 | | optionCode / optionName | string | 编码、名称 | | optionValuesJson | string | 选项值 JSON | | state / orderNum | bool / int | 状态、排序 | | availabilityType | string | `ALL` / `SPECIFIED` | | **region** | string | 列表列 **Region**:`ALL` → `All Regions`;`SPECIFIED` → 绑定门店 `GroupName` 拼接 | | **location** | string | 列表列 **Location**:`ALL` → `All Locations`;`SPECIFIED` → 门店名拼接 | | regionIds | string[] | Region Id(`SPECIFIED` 时由门店反推) | | locationIds | string[] | 门店 Id | | lastEdited | datetime | 最近编辑时间 | **响应示例** ```json { "id": "opt_allergens_001", "optionCode": "OPT_ALLERGENS", "optionName": "Allergens", "optionValuesJson": "[\"Peanuts\",\"Dairy\"]", "state": true, "availabilityType": "SPECIFIED", "orderNum": 1, "region": "East Region", "location": "UNCC store", "regionIds": ["fl_group_id_east"], "locationIds": ["11111111-1111-1111-1111-111111111111"], "lastEdited": "2026-05-18T12:00:00" } ``` ### 库内核对 SQL ```sql SELECT o.Id, o.OptionName, o.AvailabilityType, ol.LocationId, loc.GroupName AS region_name, COALESCE(NULLIF(TRIM(loc.LocationName), ''), loc.LocationCode) AS location_name FROM fl_label_multiple_option o LEFT JOIN fl_label_multiple_option_location ol ON ol.MultipleOptionId = o.Id LEFT JOIN location loc ON loc.Id = ol.LocationId AND loc.IsDeleted = 0 WHERE o.IsDeleted = 0 ORDER BY o.OrderNum DESC; ``` ### 联调注意 | 现象 | 可能原因 | |------|----------| | region / location 为 `All Regions` / `All Locations` | `availabilityType = ALL`(历史数据默认) | | region / location 为「无」 | `SPECIFIED` 但未写入 `fl_label_multiple_option_location` | | 筛 Region 后列表为空 | 非 `ALL` 且关联表未覆盖该门店 | | 接口报错列不存在 | 未执行 `fl_label_multiple_option_scope.sql` | | 保存报「至少需要匹配到一个有效门店」 | Region 下无门店且 `locationIds` 为空 | --- ## label-type 标签类型 **应用服务**:`LabelTypeAppService` **命名约定**:UI **Region** = **`regionIds` / `groupIds`**(`fl_group.Id`);UI **Location** = **`locationIds`**(`location.Id`)。存储表 **`fl_label_type_location`**(与 label-category 一致)。 ### 数据库变更(新增/编辑 Region·Location 前须执行) 脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_type_scope.sql` ```sql ALTER TABLE `fl_label_type` ADD COLUMN `AvailabilityType` varchar(20) NOT NULL DEFAULT 'ALL' COMMENT '门店可用范围:ALL/SPECIFIED' AFTER `State`; CREATE TABLE IF NOT EXISTS `fl_label_type_location` ( `Id` varchar(36) NOT NULL, `LabelTypeId` varchar(36) NOT NULL, `LocationId` varchar(36) NOT NULL, `CreationTime` datetime NOT NULL, `CreatorId` varchar(36) DEFAULT NULL, PRIMARY KEY (`Id`), KEY `idx_fl_ltl_type` (`LabelTypeId`), KEY `idx_fl_ltl_location` (`LocationId`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签类型适用门店'; ``` | 表/字段 | 说明 | |---------|------| | `fl_label_type.AvailabilityType` | `ALL` = 全部门店;`SPECIFIED` = 仅关联表内门店 | | `fl_label_type_location` | `LabelTypeId` + `LocationId` 多选落库 | --- ### 新增 `POST /api/app/label-type`、编辑 `PUT /api/app/label-type/{id}` | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | typeCode | string | 是 | 类型编码 | | typeName | string | 是 | 类型名称 | | state | bool | 否 | 默认 `true` | | orderNum | int | 否 | 排序 | | availabilityType | string | 否 | `ALL` / `SPECIFIED`;传了 region/location 数组时自动 **`SPECIFIED`** | | **regionIds** | string[] | 否 | **Region 多选**(`fl_group.Id`);推荐字段名 | | **groupIds** | string[] | 否 | 与 `regionIds` 合并去重(兼容) | | **locationIds** | string[] | 否 | **Location 多选**(`location.Id`) | **合并规则**:每个 `regionIds` 展开为该 Region 下全部门店,再与 `locationIds` **取并集** → 写入 `fl_label_type_location`;`SPECIFIED` 时至少 **1** 个有效门店,否则报错:`指定适用区域或门店时,至少需要匹配到一个有效门店`。 **请求示例** ```json POST /api/app/label-type { "typeCode": "PRICE", "typeName": "Price Label", "state": true, "orderNum": 10, "availabilityType": "SPECIFIED", "regionIds": ["fl_group_id_east"], "locationIds": ["11111111-1111-1111-1111-111111111111"] } ``` **详情 `GET /api/app/label-type/{id}` 出参(范围字段)** | 字段 | 说明 | |------|------| | availabilityType | `ALL` / `SPECIFIED` | | regionIds | Region Id 数组(`SPECIFIED` 时由门店反推) | | groupIds | 与 `regionIds` 相同 | | locationIds | 已绑定门店 Id | **编辑**:Body 字段与新增相同;会 **先删后插** 重建 `fl_label_type_location`。 --- ### 列表 `GET /api/app/label-type` 示例:`?SkipCount=1&MaxResultCount=10` ### 列表 UI 列与 API 字段对照 | 列表列(UI) | API 字段(camelCase) | 类型 | 说明 | |--------------|----------------------|------|------| | **No. of Labels** | **noOfLabels** | long | 该 Label Type 下 **未删除** 标签条数(`fl_label.LabelTypeId = fl_label_type.Id` 且 `IsDeleted = 0`) | | **Region** | **region** | string | `ALL` → `All Regions`;`SPECIFIED` → 绑定门店 `GroupName` 拼接;未配置为 **`无`** | | **Location** | **location** | string | `ALL` → `All Locations`;`SPECIFIED` → 门店名拼接 | | (辅助) | regionIds | string[] | Region 主键(`fl_group.Id`,由门店反推) | | (辅助) | locationIds | string[] | 门店主键(`location.Id`) | | Last Edited | lastEdited | datetime | 类型与下属标签最近编辑时间的较大值 | ### No. of Labels 是否需要库字段? **不需要** `NoOfLabels` 物理列,由 `fl_label` 实时 `COUNT`。 | 表 | 作用 | |----|------| | `fl_label_type` | 类型主数据 + **`AvailabilityType`** | | `fl_label_type_location` | 新增/编辑时 Region·Location 多选落库 | | `fl_label` | 标签实例;**`noOfLabels`** 统计来源 | | `location` | 门店;Region 展示用 **`GroupName`** | **统计逻辑(与代码一致)**: ```sql -- 某类型下标签数(No. of Labels) SELECT COUNT(*) AS no_of_labels FROM fl_label WHERE IsDeleted = 0 AND LabelTypeId = :labelTypeId; ``` 列表接口在分页查出类型后,对当前页 `Id` 批量 `GROUP BY LabelTypeId` 计数,避免 N+1;**标签增删改后无需改类型表**,下次列表即反映最新数量。 ### 变更说明 | 项 | 说明 | |----|------| | **列表** `GET /api/app/label-type` | 出参增加 **`noOfLabels`**、**`region`**、**`location`**、**`regionIds`**、**`locationIds`**、**`lastEdited`** | | **列表筛选** | Query 可选 **`groupId`**(Region)、**`locationId`**(Location);`locationId` 优先于 `groupId` | | **统计一致性** | 传 Region/门店筛选时,**`noOfLabels`**、**`region`/`location`** 仅统计筛选范围内标签 | | **筛选语义** | 传 `groupId`/`locationId` 时:返回 **`availabilityType=ALL`** 或 **关联门店命中** 的类型(与 label-category 一致) | | **列表 region/location** | 来自 **配置**(`AvailabilityType` + `fl_label_type_location`),非仅标签反推 | **命名约定**:UI **Region** = Query **`groupId`**(`fl_group.Id`);UI **Location** = Query **`locationId`**(`location.Id`)。 ### 列表 Query | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | skipCount | int | 是 | 跳过条数(项目约定:第 1 页常为 `1`) | | maxResultCount | int | 是 | 每页条数 | | sorting | string | 否 | 排序,如 `orderNum desc` | | keyword | string | 否 | 匹配 `typeCode`、`typeName` | | state | bool | 否 | 启用状态 | | **groupId** | string | 否 | **Region** 筛选(`fl_group.Id`) | | **locationId** | string | 否 | **Location** 筛选(`location.Id`);**优先于** `groupId` | **请求示例(默认列表,含三列出参)** ```bash GET /api/app/label-type?SkipCount=1&MaxResultCount=10 Authorization: {token} ``` **按 Region / Location 筛选** ```bash GET /api/app/label-type?SkipCount=1&MaxResultCount=10&groupId=你的fl_group主键 Authorization: {token} ``` ```bash GET /api/app/label-type?SkipCount=1&MaxResultCount=10&locationId=11111111-1111-1111-1111-111111111111 Authorization: {token} ``` ### 列表出参 `items[]`(节选) | 字段 | 类型 | 说明 | |------|------|------| | id | string | 类型主键(`fl_label_type.Id`) | | typeCode / typeName | string | 编码、名称 | | state / orderNum | bool / int | 状态、排序 | | availabilityType | string | `ALL` / `SPECIFIED` | | **noOfLabels** | long | 列表列 **No. of Labels** | | **region** | string | 列表列 **Region** | | **location** | string | 列表列 **Location** | | regionIds | string[] | Region Id(`fl_group.Id`) | | locationIds | string[] | 门店 Id | | lastEdited | datetime | 最近编辑时间 | **响应 `items[]` 示例** ```json { "id": "type_price_001", "typeCode": "PRICE", "typeName": "Price Label", "state": true, "availabilityType": "SPECIFIED", "orderNum": 10, "region": "East Region", "location": "UNCC store, Central Park Store", "regionIds": ["fl_group_id_east"], "locationIds": ["11111111-1111-1111-1111-111111111111"], "noOfLabels": 8, "lastEdited": "2026-05-18T10:00:00" } ``` ### 库内核对 SQL(只读,无需执行 DDL) **1)确认 `fl_label_type` 无 NoOfLabels 列(不应加列)** ```sql SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_label_type' ORDER BY ORDINAL_POSITION; ``` **2)按类型统计标签数(与接口 `noOfLabels` 一致)** ```sql SELECT t.Id, t.TypeCode, t.TypeName, COUNT(l.Id) AS no_of_labels FROM fl_label_type t LEFT JOIN fl_label l ON l.LabelTypeId = t.Id AND l.IsDeleted = 0 WHERE t.IsDeleted = 0 GROUP BY t.Id, t.TypeCode, t.TypeName ORDER BY t.OrderNum DESC, t.CreationTime DESC; ``` **3)某类型已配置的 Region / Location(与列表 region/location 一致)** ```sql SELECT t.Id, t.TypeName, t.AvailabilityType, tl.LocationId, loc.GroupName AS region_name, COALESCE(NULLIF(TRIM(loc.LocationName), ''), loc.LocationCode) AS location_name FROM fl_label_type t LEFT JOIN fl_label_type_location tl ON tl.LabelTypeId = t.Id LEFT JOIN location loc ON loc.Id = tl.LocationId AND loc.IsDeleted = 0 WHERE t.IsDeleted = 0 AND t.Id = :labelTypeId; ``` ### 联调注意(label-type) | 现象 | 可能原因 | |------|----------| | **noOfLabels 为 0** | 该类型下尚无标签,或标签均已逻辑删除 | | region / location 为「无」 | `SPECIFIED` 但未绑定门店,或门店无 `GroupName`/名称 | | 保存报「至少需要匹配到一个有效门店」 | Region 下无门店且 `locationIds` 为空 | | 筛 Region 后列表变空 | 非 `ALL` 且 `fl_label_type_location` 未覆盖该门店 | | 未执行 DDL | 先跑 `fl_label_type_scope.sql` | | noOfLabels 与标签列表不一致 | 对比时 label 列表需传与 label-type 相同的 `groupId`/`locationId` | | 前端列空白 | 绑定 **`noOfLabels`**、**`region`**、**`location`**(勿用 `creationTime` 代替 `lastEdited`) | > 与 **label-category**(见 `5-17接口优化.md`)区别:分类有 `availabilityType=ALL`;**标签类型**无 ALL,三列均由 **`fl_label`** 实时汇总。 > **不提供** `ALTER TABLE fl_label_type ADD NoOfLabels ...`:冗余字段易与 `fl_label` 不一致,且每次标签变更都要维护计数。 --- ## team-member 列表 Company·Region·Location 筛选 **应用服务**:`TeamMemberAppService` **接口**:`GET /api/app/team-member`(分页;PDF 导出 `export-team-members-pdf` 使用相同 Query 筛选) **辅助类**:`LocationScopeBindingHelper.ResolveFilteredLocationIdsForListAsync` ### Query 参数(在原有 Keyword / RoleId / State 基础上) | UI | Query 参数 | 说明 | |----|------------|------| | Company | `partnerId` | `fl_partner.Id` | | Region | `groupId` | `fl_group.Id` | | Location | `locationId` | `location.Id`(Guid 字符串) | **筛选优先级**(与 product 列表一致):传 `locationId` 时仅按该门店;否则传 `groupId` 按 Region 下全部门店;否则传 `partnerId` 按 Company 下全部门店;均未传则不按组织范围过滤。 **命中规则**:返回在 **`userlocation`** 中至少绑定一家「落在上述门店集合内」的成员;列表行内 `assignedLocations` 在传入范围参数时仅展示该范围内的绑定门店。 ### 请求示例 ```http GET /api/app/team-member?SkipCount=1&MaxResultCount=10&partnerId={fl_partner.Id} Authorization: Bearer {token} GET /api/app/team-member?SkipCount=1&MaxResultCount=10&groupId={fl_group.Id} GET /api/app/team-member?SkipCount=1&MaxResultCount=10&locationId={location.Id} ``` 可与 `Keyword`、`RoleId`、`State`、`Sorting` 组合使用。 ### 库内核对 ```sql -- 某 Region 下应出现在列表中的成员(userId 去重) SELECT DISTINCT ul.UserId FROM userlocation ul INNER JOIN location loc ON ul.LocationId = CAST(loc.Id AS CHAR) AND loc.IsDeleted = 0 WHERE ul.IsDeleted = 0 AND loc.Partner = :partnerName AND loc.GroupName = :groupName; ``` ### 联调注意 | 现象 | 处理 | |------|------| | 传了 `groupId` 仍无数据 | 确认成员 `userlocation` 是否绑定该区域下门店 | | `partnerId` / `groupId` 无效 | 返回空列表(非 404) | | 与 5-17 新增字段关系 | 列表 **筛选** 用 Query;新增/编辑 Body 仍用 `partnerId` / `regionIds` / `locationIds`(见 `5-17接口优化.md`) | --- ## team-member 新增用户无法平台登录 ### 问题现象 通过 **`POST /api/app/team-member`** 新增用户(如 `email=123@qq.com`、`userName=1234`)后,在平台登录页用 **邮箱 + 创建时密码** 登录失败(`Sign-in failed: incorrect email or password`)。 ### 根因(两处) **1)新增时双重 `BuildPassword`(已修复)** - `new UserAggregateRoot(userName, password, …)` 构造函数内已 `BuildPassword()` 一次; - 若再调用 `user.BuildPassword()`,会对 **已哈希值再哈希**,明文登录必失败。 **2)改密时 SqlSugar `IsOwnsOne` 只更新了 Salt、未更新 Password(本次主因)** - `PUT /api/app/team-member/{id}` 传入新明文 → `BuildPassword()` 会生成 **新 Salt + 新 Password 哈希**; - `UpdateAsync` 整表更新时 **`Password` 列可能未写入**,库中仍保留旧哈希(例如与 admin 相同的 `ANg9hGZC…`),但 **Salt 已是新值**; - 校验 `SHA2Encode(明文, Salt)` 与库中 `Password` 永远对不上(查库可见 `123@qq.com` 与 `admin` 的 `Password` 相同、`Salt` 不同)。 ### 代码修复 - 新增:与 `UserDataSeed` 一致,`EncryPassword = new EncryPasswordValueObject(明文)` 后 **只调用一次** `BuildPassword()`。 - 改密 / 重置密码:改密后调用 `UserPasswordHelper.EnsurePasswordColumnsPersistedAsync`,**显式 SET `Password` 与 `Salt`**(`Yi.Framework.Rbac.Domain/Helpers/UserPasswordHelper.cs`)。 - 登录校验统一走 `UserPasswordHelper.VerifyPlainPassword` / `UserAggregateRoot.JudgePassword`。 ### 平台登录约定 | 项目 | 说明 | |------|------| | 接口 | `POST /api/app/account/login`(`AccountService.PostLoginAsync`) | | 入参 | `userName` 填 **邮箱**(平台 UI 的 Email 框);`password` 为明文 | | 匹配 | `User.Email`(忽略大小写)优先;否则 `User.UserName` 与邮箱相同也可 | | 状态 | `State = true` 且未删除才可登录 | **登录示例** ```json POST /api/app/account/login { "userName": "123@qq.com", "password": "创建时设置的明文密码" } ``` ### 已受影响账号的处理 部署修复后,**已受影响账号**(如 `123@qq.com`,库内 `Password`/`Salt` 不一致)必须 **再保存一次密码**: ```http PUT /api/app/team-member/{id} Content-Type: application/json { "fullName": "显示名", "userName": "1234", "email": "123@qq.com", "password": "新的明文密码", "phone": 1234567890, "roleId": "角色Guid", "locationIds": ["门店Id"], "state": true } ``` 保存后用 **邮箱 + 新密码** 登录。 **库内核对** ```sql SELECT Id, UserName, Email, State, Password, Salt FROM User WHERE Email = '123@qq.com' AND IsDeleted = 0; -- 正常:同一用户的 Password 须与 SHA512(salt+明文) 一致;勿出现与 admin 相同 Password、不同 Salt。 ``` ### 新增成员入参提醒 | 字段 | 说明 | |------|------| | userName | 登录名(可与邮箱不同);平台登录仍建议用 **email** 字段 | | email | 平台登录主键(须为合法邮箱格式) | | password | 明文,至少 6 位(`UserManager` 校验) | | roleId | 须绑定角色,否则登录后可能无菜单 | | locationIds / partnerId / groupIds | 门店范围(见 5-17 team-member 章节) | --- ## rbac-role accessPermissions 以下为 **`/api/app/rbac-role`** 相关说明(原 5-18 内容)。 --- ## 问题与根因 | 现象 | 根因 | |------|------| | `POST`/`PUT` 传了 `accessPermissions` 后仍无菜单绑定 | 库表 **`Menu.PermissionCode` 全为空**,按 Code 解析不到 `MenuId`;或入参 `menuIds: []` 抢先清空绑定 | | `GET /api/app/rbac-role/{id}` 的 `accessPermissions` 为 `""` | 无 **`RoleMenu`** 记录,或已绑定菜单的 **`PermissionCode` 为空** | | `menuIds` 详情为空(历史问题) | 曾错误查询 `rolemenu` 字符串表;已改为查询 **`RoleMenu`**(Guid) | **数据链路(正确)** ```text accessPermissions(入参/出参字符串) ↔ Menu.PermissionCode(权限码,可为空时按 Router 推导 menu.xxx) ↔ RoleMenu(RoleId + MenuId) ↔ Role ``` --- ## 变更摘要 | 项 | 说明 | |----|------| | **绑定写入** | `accessPermissions` 解析为 `MenuId` 后 **覆盖写入 `RoleMenu`**;解析不到任何菜单时 **返回明确错误** | | **出参汇总** | `GET` 列表/详情从 **`RoleMenu` + `Menu.PermissionCode`** 汇总;`PermissionCode` 为空时按 **`Router`** 推导(与回填 SQL 一致) | | **详情 menuIds** | 从 **`RoleMenu`** 读取,不再查错误的 `rolemenu` 映射 | | **RoleMenu 主键** | 插入时生成 **`Id`**(`RoleMenu` 表必填) | | **数据库脚本** | `scripts/menu_backfill_permission_code.sql` 批量回填 `PermissionCode` | --- ## 公共约定 - **宿主**:`Yi.Abp.Web`;路由前缀 `api/app`。 - **应用服务**:`RbacRoleAppService`(模块 `food-labeling-us`)。 - **鉴权**:`Authorization: {token}`。 --- ## accessPermissions 规则 ### 出参(只读) - 查询 **`RoleMenu`** 中该角色绑定的菜单,取 **`Menu.PermissionCode`**(非空优先)。 - 若 `PermissionCode` 为空,按 **`Router`** 推导:`/labels` → `menu.labels`。 - 去重后按字母序用 **`, `** 拼接;无绑定为 `""`。 ### 入参(写入) | 场景 | menuIds | accessPermissions | 行为 | |------|---------|-------------------|------| | 新增/编辑 | 非空数组 | 任意 | **以 menuIds 为准**(覆盖 `RoleMenu`) | | 新增/编辑 | 不传 / null | 非空字符串 | 按 PermissionCode 解析菜单并 **覆盖绑定** | | 新增/编辑 | 不传 / null | `""` | **清空** `RoleMenu` | | 新增/编辑 | `[]` | 不传 | **清空** `RoleMenu` | | 编辑 | 不传 | 不传 / null | **不修改** 已有菜单绑定 | - 解析 **忽略大小写**;支持英文逗号、分号分隔。 - 若传了非空 `accessPermissions` 但 **0 条菜单匹配**,接口返回业务错误(提示检查 `PermissionCode` 或执行回填脚本)。 --- ## 1 角色详情 | 项目 | 说明 | |------|------| | HTTP | `GET` | | 路径 | `/api/app/rbac-role/{id}` | | 示例 | `/api/app/rbac-role/3a1f077b-3665-63f2-5fea-0fd7e7044b88` | **响应 `data` 字段(节选)** | 字段 | 说明 | |------|------| | menuIds | 已绑定菜单 Guid 字符串数组(来自 `RoleMenu`) | | accessPermissions | 已绑定菜单权限码汇总,如 `menu.account-management, menu.labels` | --- ## 2 新增角色 | 项目 | 说明 | |------|------| | HTTP | `POST` | | 路径 | `/api/app/rbac-role` | **请求示例(accessPermissions)** ```json { "roleName": "Partner Admin", "roleCode": "admin", "state": true, "accessPermissions": "menu.labels, menu.label-categories, menu.account-management" } ``` > 须先保证 `Menu` 表存在对应 **`PermissionCode`**(或执行回填脚本后使用 `menu.{router-segment}` 形式)。 --- ## 3 编辑角色 | 项目 | 说明 | |------|------| | HTTP | `PUT` | | 路径 | `/api/app/rbac-role/{id}` | | Body | 与新增相同(`RbacRoleUpdateInputVo`) | --- ## 数据库准备(必做) 执行(按环境选择库): `美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/menu_backfill_permission_code.sql` 回填后示例: | MenuName | Router | PermissionCode | |----------|--------|----------------| | Labels | /labels | menu.labels | | Account Management | /account-management | menu.account-management | **验证 SQL** ```sql SELECT r.RoleName, m.MenuName, m.PermissionCode FROM Role r INNER JOIN RoleMenu rm ON rm.RoleId = r.Id INNER JOIN Menu m ON m.Id = rm.MenuId AND m.IsDeleted = 0 WHERE r.Id = '3a1f077b-3665-63f2-5fea-0fd7e7044b88'; ``` --- ## 关联接口 | 接口 | 说明 | |------|------| | `POST /api/app/rbac-role-menu/set` | 仅维护 `RoleMenu`(`menuIds` 覆盖式),不直接写 `accessPermissions` 列 | | `GET /api/app/rbac-role-menu/menu-ids/{roleId}` | 查询已绑定菜单 Id | 保存菜单权限后,再调 **`GET /api/app/rbac-role/{id}`** 应能看到非空 **`accessPermissions`**(在 `PermissionCode` 已配置前提下)。 --- ## 联调注意 | 现象 | 处理 | |------|------| | accessPermissions 仍为空 | 1)查 `RoleMenu` 是否有记录;2)查 `Menu.PermissionCode` 是否已回填 | | 保存报未匹配到菜单 | `accessPermissions` 与库中 Code 不一致;先执行回填脚本或改用 `menuIds` | | 同时传 `menuIds: []` 与 accessPermissions | **menuIds 优先**,空数组会清空绑定,accessPermissions 被忽略 | --- ## 修订记录 | 日期 | 说明 | |------|------| | 2026-05-18 | 修复 rbac-role accessPermissions 读写;RoleMenu 绑定;Menu.PermissionCode 回填脚本 | | 2026-05-18 | 修复 team-member 登录:去掉双重 BuildPassword;改密显式落库 Password+Salt;已建账号需 PUT 重置密码 | | 2026-05-18 | label-type 列表 noOfLabels/region/location;新增编辑 regionIds+locationIds 多选;DDL `fl_label_type_scope.sql` | | 2026-05-18 | label-multiple-option 列表筛选与出参;新增/编辑 regionIds+locationIds 多选;DDL `fl_label_multiple_option_scope.sql` | | 2026-05-18 | reports print-log-list:Label ID 改为门店当日序号 yyyyMMdd-n(labelCode 字段) | | 2026-05-18 | reports print-log-list:Expiry Date 从 PrintInputJson 模板快照 elements 解析(expiryDateText) | | 2026-05-18 | reports label-report:管理员全量;非管理员按 userlocation 绑定门店统计(不按 CreatedBy) | | 2026-05-18 | us-app-auth location-detail:出参 operatingHours 读 location.OperatingHours | | 2026-05-18 | us-app get-print-log-list / get-label-report:管理员与 Partner 可看当前门店全部打印;reprint 同权 | | 2026-05-18 | us-app labeling-tree:产品已绑门店时不再因产品分类 SPECIFIED 无 location 关联而空树 | | 2026-05-18 | location 列表:管理员全部门店;非管理员仅绑定门店所属 Region(Partner+GroupName) | | 2026-05-18 | team-member 列表:Query 增加 partnerId / groupId / locationId(Company·Region·Location)筛选 | | 2026-05-18 | reports template-print-stat-list:按模板汇总打印标签数量(templateName + printedCount) |