# 6-1 代码优化 本文档说明 **2026-06-01** 对美国版 **`auth-session/my-menus`** 中 **`lastUpdated`** 字段的语义与实现变更。 --- ## my-menus 的 lastUpdated(系统编辑更新时间) ### 背景 前端(`AuthProvider`)用 `GET /api/app/auth-session/my-menus` 返回的 **`lastUpdated`** 判断菜单/权限缓存是否需要刷新。原先取 **`User.LastModificationTime`**,仅在用户资料变更时变化,**菜单、角色、业务数据** 等任意接口修改后不会更新。 ### 目标行为 - **`lastUpdated`** 表示 **系统编辑更新时间**。 - 宿主下任意 **`/api/app`** 的 **写操作**(`POST` / `PUT` / `PATCH` / `DELETE`)且 **HTTP 2xx 成功** 后,刷新全局时间戳(分布式缓存)。 - **`my-menus`** 返回:`max(系统编辑时间戳, User.LastModificationTime)`;若从未有过写操作,则仅回退用户资料时间。 ### 接口 | 项 | 说明 | |----|------| | 方法 | `GET` | | 路径 | `/api/app/auth-session/my-menus` | | 鉴权 | Web 登录 Bearer Token | | 测试环境示例 | `http://flus-test.3ffoodsafety.com/api/app/auth-session/my-menus` | **请求示例** ```http GET /api/app/auth-session/my-menus HTTP/1.1 Host: flus-test.3ffoodsafety.com Authorization: Bearer {access_token} ``` **响应字段(与本次变更相关)** | 字段 | 类型 | 说明 | |------|------|------| | `lastUpdated` | `DateTime?` | 系统编辑全局时间戳;与任意符合条件的写接口成功联动。与用户 `LastModificationTime` 取 **较晚** 者。 | | `menus` | 数组 | 可见菜单树 | | `permissionCodes` | 字符串数组 | 权限码 | | `role` / `fullName` | 字符串 | 角色展示名、全名 | **响应片段示例** ```json { "user": { "id": "…", "userName": "admin" }, "lastUpdated": "2026-06-01T14:32:10.123", "menus": [], "permissionCodes": [], "role": "Administrator", "fullName": "Admin" } ``` ### 写操作如何刷新时间戳 实现类:`SystemEditStampGlobalFilter`(注册于 `FoodLabelingApplicationModule`)。 | 条件 | 是否刷新 | |------|----------| | 路径以 `/api/app` 开头 | 是(含 RBAC、美国版 `food-labeling-us`、同宿主泰额版等) | | 方法为 POST / PUT / PATCH / DELETE | 是 | | 响应状态码 2xx | 是 | | 路径含 `/login`、`/logout`、`forgot-password`、`/captcha`、`/my-menus`、`get-label-report` | **否**(登录、退出、验证码、纯查询报表等) | 缓存键:`FoodLabeling:SystemEditStamp`(`SystemEditStampCacheHelper`)。 ### 验证步骤(建议) 1. 调用 `my-menus`,记录 `lastUpdated` 为 **T0**。 2. 调用任意会改数据的写接口(如新增/修改产品、用户、菜单、选店 `auth-scope/select-location` 等),确认 **2xx**。 3. 再次调用 `my-menus`,`lastUpdated` 应为 **T1**,且 **T1 > T0**(秒级即可)。 4. 仅调用 `get-label-report` 等排除路径时,`lastUpdated` **不应** 变化。 ### 涉及文件 | 文件 | 说明 | |------|------| | `FoodLabeling.Application/Filters/SystemEditStampGlobalFilter.cs` | 写成功后 Touch 时间戳 | | `FoodLabeling.Application/Helpers/SystemEditStampCache.cs` | 缓存读写 | | `FoodLabeling.Application/Services/AuthSessionAppService.cs` | `GetMyMenusAsync` 组装 `lastUpdated` | | `FoodLabeling.Application.Contracts/.../CurrentUserMenuPermissionsOutputDto.cs` | DTO 注释 | | `FoodLabeling.Application.Contracts/IServices/IAuthSessionAppService.cs` | 接口 XML 注释 | ### 前端说明 `Food Labeling Management Platform/src/components/auth/AuthProvider.tsx` 已支持从响应根级或 `user` 内读取 `lastUpdated`,**无需改前端**即可使用新语义;建议在本地对比 `lastUpdated` 与上次缓存值,变化时重新拉取菜单。 --- ## team-member 列表按 Token 权限过滤 ### 背景 `GET /api/app/team-member` 原先仅按 Query 的 `partnerId` / `groupId` / `locationId` 筛选,**未**结合登录 Token 的数据范围,非管理员可能看到其它公司的成员。 ### 目标行为 | 登录身份 | 列表可见成员 | |----------|----------------| | **系统管理员**(`ReportsRoleHelper.IsAdminRole`:用户名 `admin`、角色码 `admin`、权限 `*:*:*` 等) | **全部** 用户(仍可用 Query 按 Company / Region / Location 收窄) | | **其它账号** | 仅在 **`userlocation` 至少绑定一家门店**,且该门店属于当前账号 **绑定门店所属公司**(`fl_partner`,见 `PartnerScopeHelper`)下的成员 | 与 **`GET /api/app/partner`** 公司列表、`auth-scope` 级联选店使用的公司范围规则一致。 ### 接口 | 项 | 说明 | |----|------| | 方法 | `GET` | | 路径 | `/api/app/team-member` | | 鉴权 | Web 登录 Bearer Token | | 测试环境示例 | `http://flus-test.3ffoodsafety.com/api/app/team-member?SkipCount=1&MaxResultCount=10` | | PDF 导出 | `GET /api/app/team-member/export-team-members-pdf`(**相同** Token 范围 + Query 筛选) | ### Query 参数 | 参数 | 说明 | |------|------| | `SkipCount` | 跳过条数(分页) | | `MaxResultCount` | 每页条数 | | `Keyword` | 姓名 / 用户名 / 邮箱 / 电话模糊搜索 | | `RoleId` | 角色 Id | | `State` | 启用状态 | | `partnerId` | Company(`fl_partner.Id`);非管理员须在本人可见公司内 | | `groupId` | Region(`fl_group.Id`) | | `locationId` | 门店 Id;传则优先于 `partnerId` / `groupId` | | `Sorting` | 排序(可选) | **筛选优先级**(Query 内):`locationId` → `groupId` → `partnerId`;均未传时,管理员不限制组织范围,非管理员限定为其可见公司下全部门店。 **命中规则**:成员须在 `userlocation` 中至少绑定一家落在「最终门店集合」内的门店。 ### 请求示例 ```http GET /api/app/team-member?SkipCount=1&MaxResultCount=10 HTTP/1.1 Host: flus-test.3ffoodsafety.com Authorization: Bearer {access_token} ``` 非管理员带 Company 筛选(须在本人可见公司内): ```http GET /api/app/team-member?SkipCount=1&MaxResultCount=10&partnerId={fl_partner.Id} Authorization: Bearer {partner_admin_token} ``` ### 验证步骤(建议) 1. 使用 **系统管理员** Token 调用列表 → 应返回全库成员(或受 Query 筛选)。 2. 使用 **仅绑定 A 公司门店** 的 Partner/公司管理员 Token → 仅返回在 A 公司门店有 `userlocation` 绑定的成员;不应出现仅绑定 B 公司的成员。 3. 非管理员传 **其它公司** 的 `partnerId` → 空列表(非 403)。 4. `export-team-members-pdf` 与列表使用同一套范围,条数应与列表筛选一致。 ### 库内核对(非管理员 · 某公司) ```sql -- 替换 :partnerId 为 fl_partner.Id,:partnerName 为 PartnerName SELECT DISTINCT u.Id, u.UserName, u.Name FROM `User` u INNER JOIN userlocation ul ON ul.UserId = CAST(u.Id AS CHAR) AND ul.IsDeleted = 0 INNER JOIN location loc ON ul.LocationId = CAST(loc.Id AS CHAR) AND loc.IsDeleted = 0 INNER JOIN fl_partner p ON p.IsDeleted = 0 AND (loc.Partner = p.PartnerName OR loc.Partner = p.Id) WHERE u.IsDeleted = 0 AND p.Id = :partnerId; ``` ### 涉及文件 | 文件 | 说明 | |------|------| | `FoodLabeling.Application/Helpers/TeamMemberListScopeHelper.cs` | Token 范围 + Query 交集 | | `FoodLabeling.Application/Helpers/PartnerScopeHelper.cs` | 公司可见范围(复用) | | `FoodLabeling.Application/Services/TeamMemberAppService.cs` | `GetListAsync`、PDF 导出 | | `FoodLabeling.Application.Contracts/.../TeamMemberGetListInputVo.cs` | 入参注释 | | `FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs` | 接口 XML 注释 | ### 联调注意 | 现象 | 处理 | |------|------| | 非管理员列表为空 | 确认当前账号 `userlocation` 是否已绑定门店,且门店 `Partner` 能对应到 `fl_partner` | | 成员无门店绑定 | 不会出现在非管理员列表中(无 `userlocation` 命中) | | 与 5-18 文档关系 | 5-18 描述 Query 筛选字段;本节补充 **Token 权限范围**,以本节为准 | --- ## product 下载导入模板 405 修复 ### 问题现象 ```http GET|POST http://flus-test.3ffoodsafety.com/api/app/product/download-product-import-template Authorization: Bearer {token} ``` 使用 **POST** 调用时返回 **405 Method Not Allowed**(后端已限定为 **GET**;旧版 Web 用 `authorizedPostBlobDownload` 发 POST)。 ### 根因 | 项 | 说明 | |----|------| | 后端 | `DownloadProductImportTemplateAsync` 标注 **`[HttpGet]`**(ABP 对 `Download*` 也可能误判动词,需显式路由) | | 前端(修复前) | `productService.downloadProductImportTemplate` 使用 **POST** 下载 Blob | | 约定 | 与 `export-products-excel`、Location/批量文档一致:**下载模板 / 导出** 均为 **GET**,无 Body | ### 修复方式 1. **后端** `ProductAppService`:增加显式路径,并 **同时接受 GET 与 POST**(兼容未刷新前端): - `[HttpGet("product/download-product-import-template")]` - `[HttpPost("product/download-product-import-template")]` 2. **前端** `productService.ts`:改为 **`authorizedGetBlobDownload`**(GET)。 ### 接口说明 | 项 | 说明 | |----|------| | 方法 | **`GET`**(推荐);**`POST`**(兼容,无 Body) | | 路径 | `/api/app/product/download-product-import-template` | | 鉴权 | Web 登录 Bearer Token | | 响应 | `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`;文件名来自配置 `FoodLabeling:BatchImport:ProductTemplateFileName`(默认 `Product-Manager-批量导入模板.xlsx`) | | 配置 | `TemplateDirectory` + `ProductTemplateFileName`(见 `批量导入导出接口说明.md` §3.1) | ### 请求示例 ```http GET /api/app/product/download-product-import-template HTTP/1.1 Host: flus-test.3ffoodsafety.com Authorization: Bearer {access_token} ``` ```bash curl -X GET "http://flus-test.3ffoodsafety.com/api/app/product/download-product-import-template" \ -H "Authorization: Bearer {token}" \ -o "Product-Manager-template.xlsx" ``` ### 验证步骤 1. 浏览器或 Postman 使用 **GET** + Token → 应下载 xlsx(非 405)。 2. 仍用 **POST**(无 Body)→ 部署新后端后亦应成功(兼容)。 3. 若返回业务错误「模板文件不存在」→ 检查服务器 `TemplateDirectory` 下是否存在配置的 xlsx。 ### 涉及文件 | 文件 | 说明 | |------|------| | `FoodLabeling.Application/Services/ProductAppService.cs` | 显式 GET/POST 路由 | | `FoodLabeling.Application.Contracts/IServices/IProductAppService.cs` | XML 注释 | | `Food Labeling Management Platform/src/services/productService.ts` | 改为 GET 下载 | ### 联调注意 | 现象 | 处理 | |------|------| | 仍 405 | 确认已部署含本修复的后端;前端须 GET 或等新后端支持 POST | | 404 | 路径须为 `download-product-import-template`,勿当成 `GET /product/{id}` | | 与导出区别 | 全量导出为 `GET .../export-products-excel?Keyword=...`;模板下载无 Query | --- ## dashboard/overview 首页统计口径修复 ### 问题现象 `GET /api/app/dashboard/overview` 返回的 **Active Users**、**Locations**、**People** 与业务实际不符;公司维度账号可能看到全库用户数/门店数,且**系统 admin** 被计入某公司成员统计。 测试环境示例:`http://flus-test.3ffoodsafety.com/api/app/dashboard/overview` ### 目标行为 | 指标 | 统计口径 | |------|----------| | **Locations** | 范围内未删除门店数:管理员=全平台;其它账号=其绑定门店所属公司下全部门店 | | **People** | 范围内**团队成员**:在 `userlocation` 中至少绑定一家范围内门店的用户(`User.IsDeleted=false`),**排除**系统 admin | | **Active Users** | 同上,且 `User.State=true` | | **系统 admin** | 用户名 `admin` 或角色码 `admin` 的账号**不计入** People / Active Users(不归属任何 Company) | 打印类指标(**Labels Printed Today**、**Weekly Print Volume**、**Recent Labels**)与 **Products** 亦按同一门店范围过滤;**Active Templates**、**By Category** 仍为全平台口径(与改前一致)。 ### 接口 | 项 | 说明 | |----|------| | 方法 | `GET` | | 路径 | `/api/app/dashboard/overview` | | 鉴权 | Web 登录 Bearer Token | | 请求参数 | 无(范围由 Token 解析,与 `team-member` 列表、`partner` 列表一致) | ### 请求示例 ```http GET /api/app/dashboard/overview HTTP/1.1 Host: flus-test.3ffoodsafety.com Authorization: Bearer {access_token} ``` ### 响应字段(本次相关) | 字段 | 说明 | |------|------| | `activeUsers` | 启用状态的范围内团队成员数(不含系统 admin) | | `locations` | 范围内门店数 | | `people` | 范围内团队成员总数(含禁用,不含系统 admin) | | `labelsPrintedToday` | 范围内门店当日打印任务数 | | `weeklyPrintVolume` | 近 7 天范围内打印量 | | `recentLabels` | 范围内门店最近 10 条打印记录 | 各指标对象含 `value`、`previousValue`、`changeValue`、`changeRate`(周对比基准:最近 7 天起始日前创建的存量等,见实现)。 ### 验证步骤(建议) 1. **系统管理员** Token:Locations / People 为全平台(People 不含 `admin` 账号);与库内 `location`、`userlocation` 汇总大致一致。 2. **仅绑定 A 公司门店** 的账号:三项指标仅反映 A 公司范围;不应等于全库总数。 3. 确认 `admin` 用户不在 People / Active Users 中(即使其有 `userlocation` 绑定)。 4. 无 `userlocation` 的普通用户:不计入 People / Active Users。 ### 库内核对(某公司团队成员,不含 admin) ```sql SELECT COUNT(DISTINCT ul.UserId) AS people_count FROM userlocation ul INNER JOIN location loc ON ul.LocationId = CAST(loc.Id AS CHAR) AND loc.IsDeleted = 0 INNER JOIN fl_partner p ON p.IsDeleted = 0 AND (loc.Partner = p.PartnerName OR loc.Partner = p.Id) INNER JOIN `User` u ON CAST(u.Id AS CHAR) = ul.UserId AND u.IsDeleted = 0 WHERE ul.IsDeleted = 0 AND p.Id = :partnerId AND u.UserName <> 'admin' AND u.Id NOT IN ( SELECT ur.UserId FROM UserRole ur INNER JOIN Role r ON r.Id = ur.RoleId AND r.IsDeleted = 0 AND r.RoleCode = 'admin' ); ``` ### 涉及文件 | 文件 | 说明 | |------|------| | `FoodLabeling.Application/Helpers/DashboardScopeHelper.cs` | 范围解析、admin 排除、计数 | | `FoodLabeling.Application/Helpers/TeamMemberListScopeHelper.cs` | 门店范围(复用) | | `FoodLabeling.Application/Services/DashboardAppService.cs` | `GetOverviewAsync` | | `FoodLabeling.Application.Contracts/IServices/IDashboardAppService.cs` | XML 注释 | ### 联调注意 | 现象 | 处理 | |------|------| | 公司账号统计仍为 0 | 检查该账号 `userlocation` 与门店 `Partner` 能否关联到 `fl_partner` | | admin 仍出现在 People | 确认用户名/角色码是否为 `admin`;部署须含本修复 | | 与 `Dashboard统计接口对接说明.md` | 该文档为通用结构说明;**统计口径以本节为准** | --- ## 标签 `/api/app/label` 适用 Region(新增/编辑) ### 背景 标签原先仅通过 `locationId` 绑定单门店,无法显式表达「适用哪些 Region」。现与 **label-type**、**label-multiple-option** 一致,支持 **全选(ALL)**、**单选/多选 Region(SPECIFIED + `regionIds`)**,Region 主键为 **`fl_group.Id`**。 ### 存储 | 位置 | 字段 | 说明 | |------|------|------| | `fl_label` | `AppliedRegionType` | `ALL` = 全平台 Region;`SPECIFIED` = 按关联表 | | `fl_label_region` | `LabelId` + `GroupId` | 多选 Region 明细(`GroupId` = `fl_group.Id`) | | `fl_label` | `LocationId` | 仍保留:App 打印/树展示兼容;多 Region 且未显式传门店时,取合并门店列表**第一家** | **DDL(测试/生产须先执行)**:`module/food-labeling-us/scripts/fl_label_region_scope.sql` ### 接口一览 | 方法 | 路径 | 说明 | |------|------|------| | `GET` | `/api/app/label?SkipCount=&MaxResultCount=` | 分页列表;支持 `GroupId` 按 Region 筛选;出参含 `region`、`regionIds`、`appliedRegionType` | | `GET` | `/api/app/label/{id}` | 详情(`id` = `LabelCode`) | | `POST` | `/api/app/label` | 新增 | | `PUT` | `/api/app/label/{id}` | 编辑 | 测试环境示例列表:`http://flus-test.3ffoodsafety.com/api/app/label?SkipCount=1&MaxResultCount=10` ### 请求 Body(新增 / 编辑,JSON) | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `appliedRegionType` | string | 否 | `ALL` 或 `SPECIFIED`。传了非空 `regionIds`/`groupIds` 时按 **SPECIFIED** 落库 | | `regionIds` | string[] | 条件 | Region Id 数组(`fl_group.Id`);与 `groupIds` **合并去重** | | `groupIds` | string[] | 否 | 与 `regionIds` 等价别名 | | `locationId` | string | 条件 | 门店 Id;SPECIFIED 时用于校验是否在 Region 范围内;ALL 时可单独指定 | | `locationIds` | string[] | 否 | 门店候选多选(与 Region 合并解析) | | `labelName` | string | 是 | 标签名称 | | `templateCode` | string | 是 | 模板编码 | | `labelCategoryId` | string | 是 | 标签类别 | | `labelTypeId` | string | 是 | 标签类型 | | `productIds` | string[] | 是 | 至少 1 个产品 Id | | `labelInfoJson` | object | 否 | 模板填写数据 | | `state` | bool | 否 | 默认 `true` | ### 三种适用范围示例 **1. 全选 Region(ALL)** ```json { "labelName": "Demo Label", "templateCode": "TPL001", "appliedRegionType": "ALL", "regionIds": [], "labelCategoryId": "...", "labelTypeId": "...", "productIds": ["..."], "locationId": "可选-指定展示/打印默认门店" } ``` **2. 单选 Region** ```json { "appliedRegionType": "SPECIFIED", "regionIds": ["1234567890123456789"], "locationId": "门店Id-须在Region内", "labelName": "...", "templateCode": "...", "labelCategoryId": "...", "labelTypeId": "...", "productIds": ["..."] } ``` **3. 多选 Region** ```json { "regionIds": ["regionId1", "regionId2"], "groupIds": ["regionId2"], "labelName": "...", "templateCode": "...", "labelCategoryId": "...", "labelTypeId": "...", "productIds": ["..."] } ``` > 未传 `locationId` 且合并 Region 对应多家门店时,后端取**第一家**写入 `fl_label.LocationId`;App 侧 `labeling-tree` / 预览打印通过 Region 关联判断是否适用该门店。 ### 响应字段(详情 / 列表相关) | 字段 | 说明 | |------|------| | `appliedRegionType` | `ALL` / `SPECIFIED` | | `regionIds` | Region Id 数组(ALL 时为空) | | `groupIds` | 与 `regionIds` 相同 | | `region` | 展示名:ALL 时为 `All Regions`;SPECIFIED 时为 Region 名称逗号拼接 | | `locationId` | 当前绑定门店 | ### 列表筛选 | 查询参数 | 说明 | |----------|------| | `GroupId` | 按 Region(`fl_group.Id`)过滤:命中 `fl_label_region`,或 `AppliedRegionType=ALL` 的标签 | | `LocationId` | 按门店(原有) | | `Keyword` 等 | 与其它筛选组合 | ### 验证步骤(建议) 1. 执行 `fl_label_region_scope.sql` 后部署后端。 2. **POST** 新增:仅 `regionIds` 单选 → 查 `fl_label_region` 一行、`AppliedRegionType=SPECIFIED`。 3. **POST** `appliedRegionType=ALL` → `fl_label_region` 无行、`AppliedRegionType=ALL`。 4. **PUT** 编辑:改为多选 `regionIds` → 关联表行数与 Id 一致(先删后插)。 5. **GET** 列表/详情:出参 `region`、`regionIds` 与库一致。 6. App:`/api/app/us-app-labeling/labeling-tree` 仅展示对当前门店 Region 命中的标签(ALL 或关联 Region)。 ### 库内核对 SQL ```sql -- 标签 Region 范围 SELECT l.Id, l.LabelCode, l.AppliedRegionType, l.LocationId FROM fl_label l WHERE l.IsDeleted = 0 AND l.LabelCode = :labelCode; SELECT lr.GroupId, g.GroupName FROM fl_label_region lr INNER JOIN fl_group g ON g.Id = lr.GroupId AND g.IsDeleted = 0 WHERE lr.LabelId = :labelId; ``` ### 涉及文件 | 文件 | 说明 | |------|------| | `scripts/fl_label_region_scope.sql` | DDL | | `Helpers/LabelRegionScopeHelper.cs` | 保存/展示/列表筛选/App 校验 | | `Services/LabelAppService.cs` | CRUD、列表出参 | | `Services/UsAppLabelingAppService.cs` | App 树与打印范围 | | `Contracts/Dtos/Label/*` | 入参/出参 | | `Contracts/IServices/ILabelAppService.cs` | XML 注释 | | Web `labelService.ts` / `types/label.ts` | 前端 API 映射 | | Web `LabelsList.tsx` | 创建/编辑提交 `appliedRegionType` + `regionIds` | ### 联调注意 | 现象 | 处理 | |------|------| | 保存报「请选择适用 Region」 | SPECIFIED 须传 `regionIds` 或 `locationId` | | 列表无 `region` 列 | 确认已执行 DDL 并部署含 `LabelRegionScopeHelper` 的版本 | | 编辑后 Region 未变 | 检查 Body 是否带 `regionIds`;Web 创建/编辑已随 Region 选择器提交 |