--- schema: "hl-changelog/v2" ticket: "8006" title: "团期 staff 保存支持按角色范围覆盖,不再跨配置位清空" consumer: "admin" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "2e8850800bc0715c35d77a9496aab39128cf8e64" target_release: "v2.1" verified_at: "2026-09-21" status_note: "2026-09-21 于网关 https://api.test.1814.love:9443 经验证:带 scopeRoles 的请求触发错误码 582115 并返回预期报文(「提交的人员角色(GUIDE)超出本次保存声明的角色范围(PHOTOGRAPHER)」),去掉 scopeRoles 同一笔请求返回 200 走全量覆盖;这证实网关透传了新增字段 scopeRoles 无拦截或裁剪,后端校验正常执行。PR #8117 已合入 dev-v3(合并提交 094521f0b),当前测试环境部署 commit d30cd9561。 前端 2026-09-21 已交付(hl-admin v2.1 2e885080,commit 全哈希见 frontend_ref):saveGroupBatchStaff 加 scopeRoles 第三参(空数组不下发),配置弹窗改范围覆盖只提交本位行、导游位 GUIDE+LEADER 同传;checkpoint 全量绿。" updated_at: "2026-09-21" base: "dev-v3" --- # 团期人员配置:保存接口新增角色范围声明,支持按配置位分部分保存 > **服务**: hl-order-service-v3 > **PR**: #8117 | **Issue**: #8006 | **合并提交**: `094521f0b` > **影响范围**: 管理后台「团期详情 → 配置导游/摄影」弹窗保存按钮的后端接口 --- ## ⚠️ 关键变化 **团期 staff 配置是按弹窗(导游位 + 摄影位)分两次各自维护的。改前,任何一次保存都会全量覆盖整期所有角色,导致保存导游位时漏带摄影位就把摄影师清空——即使摄影位根本没动。** **现在可选择:不传 `scopeRoles` 时行为不变(整期全量覆盖),传了则只覆盖指定的几个角色,别的角色既有人员保持不动。导游位与摄影位终于能互不干扰地维护。** 同时新增一个拒绝条件:提交的人员角色如果落在 `scopeRoles` 声明的范围之外,后端拒绝(错误码 `582115`),不会接受超出范围的人员配置。 --- ## 一、背景 团期 staff 配置分两个弹窗: - 导游位:选导游/领队,对应 `GUIDE` + `LEADER` 两个角色 - 摄影位:选摄影师,对应 `PHOTOGRAPHER` 一个角色 全量覆盖的模式要求"保存时把你要的所有人员一口气提交",否则没提交的就被清空。但实际场景是前端打开弹窗改一处、保存一次,改另一处、再保存一次,两个弹窗分离维护。导致**只要有一次漏带了另一弹窗的既有人员,那一弹窗的人就被静默清空**。 本次改动让后端对保存范围更聪慧:前端只需声明"我这次改的是哪些角色",后端就只删那个范围内的旧行、只插那个范围内的新行,其他角色的人员一行不动。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 保存团期人员配置 | PUT | `/v3/admin/group-batch/{productBatchId}/staff` | **请求体新增字段** | 新增 `scopeRoles`;错误码新增 `582115` | --- ## 三、接口详情 ### 1. 保存团期人员配置 `PUT /v3/admin/group-batch/{productBatchId}/staff` **VO**: `BatchStaffConfigReqVO → BatchStaffConfigRespVO` #### 使用场景 「配置导游」或「配置摄影」弹窗点保存时调用,将本弹窗的人员配置提交到后端。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | `productBatchId` | Path | Long | ✅ | — | 产品侧班期 ID | | `scopeRoles` | Body | `List` | ❌ | 元素取值: `LEADER` / `GUIDE` / `DRIVER` / `PHOTOGRAPHER` / `OTHER` / `GUIDE_ASSISTANT` / `STUDY_TEACHER` / `LIFE_TEACHER`;**不传 = 整期全量覆盖**(历史行为),**传了就必须 ≥ 1 项**(传空数组 400 拒绝) | **新增**。本次保存覆盖的角色范围。不传时全量覆盖整期(与改前一致),传了则只在这些角色内覆盖,范围外的既有行不动。**特别注意:导游位并收 `GUIDE` 与 `LEADER` 两个角色,保存导游位时必须同时传这两个,只传其中一个会导致 `582115` 拒绝**(见下方错误响应) | | `staffList` | Body | `List` | ✅ | — | 本范围内要保存的人员列表。传空数组 `[]` 表示清空该范围内的人员(与改前一致) | | `staffList[].staffId` | Body | Long | ✅ | — | 资源域人员 ID | | `staffList[].staffRole` | Body | String | ✅ | 同上 `scopeRoles` 的取值域 | 人员在本配置中的角色。如果传了 `scopeRoles`,这里的每一项都必须在范围内,否则 `582115` 拒绝 | | `staffList[].sortOrder` | Body | Integer | ❌ | — | 展示排序(缺省 0) | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | `productBatchId` | Long | 回显路径参数 | | `groupBatchId` | Long | 运营团期 ID(由 productBatchId 反查得到) | | `staffList` | `List` | **保存后的整期最终状态**(见下方说明) | | `affectedOrderCount` | Integer | 扇出影响的活跃子订单数 | **`staffList` 回显语义变化**: - **改前**:只回显本次提交的 items(即请求体的 `staffList`) - **改后**:回显**保存完成后整个团期的最终状态**——无论你是全量保存还是按范围保存,返回都是整期全部人员的完整快照 - 好处 1:前端无需再发第二个 GET 请求去拿最新名单 - 好处 2:前端能看到"我这一存操作扇出到多少订单"和"团期现在全部配置是啥",更清楚整体状态 - 注意:如果你传了 `scopeRoles=["PHOTOGRAPHER"]` 只保存摄影位,返回的 `staffList` 仍然包含导游位的人(如果有的话),这是正常的,代表团期的完整配置 #### 请求示例 **场景 1:按范围保存(新用法)** 前端打开导游位弹窗,改了导游/领队名单,保存时只声明导游位: ```json { "scopeRoles": ["GUIDE", "LEADER"], "staffList": [ { "staffId": 1005, "staffRole": "LEADER", "sortOrder": 0 }, { "staffId": 1002, "staffRole": "GUIDE", "sortOrder": 1 } ] } ``` **场景 2:全量保存(历史用法,不传 `scopeRoles`)** ```json { "staffList": [ { "staffId": 1005, "staffRole": "LEADER", "sortOrder": 0 }, { "staffId": 1002, "staffRole": "GUIDE", "sortOrder": 1 }, { "staffId": 2003, "staffRole": "PHOTOGRAPHER", "sortOrder": 0 } ] } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": { "productBatchId": 80001, "groupBatchId": 90211, "staffList": [ { "staffId": 1005, "staffRole": "LEADER", "staffName": "刘大山", "staffPhone": "138****6677", "sortOrder": 0 }, { "staffId": 1002, "staffRole": "GUIDE", "staffName": "李雪梅", "staffPhone": "138****8888", "sortOrder": 1 }, { "staffId": 2003, "staffRole": "PHOTOGRAPHER", "staffName": "王摄影", "staffPhone": "188****9999", "sortOrder": 0 } ], "affectedOrderCount": 3 } } ``` #### 空数据 / 降级响应 `staffList` 传空数组清空覆盖范围内的人员: ```json { "code": 200, "message": "成功", "success": true, "data": { "productBatchId": 80001, "groupBatchId": 90211, "staffList": [ { "staffId": 2003, "staffRole": "PHOTOGRAPHER", "staffName": "王摄影", "staffPhone": "188****9999", "sortOrder": 0 } ], "affectedOrderCount": 2 } } ``` (如果 `scopeRoles=["GUIDE","LEADER"]` 且 `staffList=[]`,则导游位人员全清,摄影位保留) #### 错误响应 **`scopeRoles` 传了空数组**: ```json { "code": 400, "message": "scopeRoles 传了就不能是空数组;要整期全量覆盖请整个字段不传", "success": false, "data": null } ``` **提交的人员角色超出 `scopeRoles` 声明的范围**(新错误码 `582115`): ```json { "code": 582115, "message": "提交的人员角色(PHOTOGRAPHER)超出本次保存声明的角色范围(GUIDE,LEADER),请检查配置位与人员是否匹配", "success": false, "data": null } ``` **特别情况:导游位只声明了 `GUIDE` 却选了 `LEADER`**: ```json { "code": 582115, "message": "提交的人员角色(LEADER)超出本次保存声明的角色范围(GUIDE),请检查配置位与人员是否匹配", "success": false, "data": null } ``` **`staffList` 字段缺失**: ```json { "code": 400, "message": "staff 配置列表不能缺失;确要清空请显式传空数组 []", "success": false, "data": null } ``` #### 业务边界 - **覆盖范围不校验完整性**:你可以只传 `scopeRoles=["GUIDE"]` 而保存时含有领队,后端照做。只覆盖 GUIDE 那一行,领队那一行在范围外。这个设计缺口(没有校验"GUIDE+LEADER 必须同时出现")已在工单 #8122 记录,**前端若需保证配置位完整性,当前需自己在前端侧把关**。 - **范围外人员拒绝率 100%**:如果 staffList 里有任何一项的 `staffRole` 不在 `scopeRoles` 声明的范围内,整个请求 `582115` 拒绝。失败时前端建议重新拉取当前配置以确保数据一致性。 - **导游位的特殊性**:导游位由 `GUIDE` 和 `LEADER` 两个角色共同维护。保存导游位配置时,`scopeRoles` 必须同时包含 `["GUIDE", "LEADER"]`。只传其中一个(如 `["GUIDE"]`)会导致另一个角色的人员被拒(582115)。 --- ## 四、契约约束与正确调用方式 ### ✅ 正确 / ❌ 错误 payload 对照 | 场景 | payload | 结果 | |------|---------|------| | ✅ 保存导游位(两个角色都声明) | `scopeRoles: ["GUIDE","LEADER"], staffList: [{staffRole:"LEADER",...}]` | 200,导游位按新值覆盖,摄影位保留 | | ✅ 保存摄影位 | `scopeRoles: ["PHOTOGRAPHER"], staffList: [{staffRole:"PHOTOGRAPHER",...}]` | 200,摄影位按新值覆盖,导游位保留 | | ✅ 全量覆盖(不传 scopeRoles) | `staffList: [{staffRole:"GUIDE",...}, {staffRole:"PHOTOGRAPHER",...}]` | 200,整期全量覆盖(与改前一致) | | ✅ 清空导游位 | `scopeRoles: ["GUIDE","LEADER"], staffList: []` | 200,导游位人员全清,摄影位保留 | | ❌ 导游位只声明一个角色 | `scopeRoles: ["GUIDE"], staffList: [{staffRole:"LEADER",...}]` | 582115,拒绝 | | ❌ 传空 scopeRoles | `scopeRoles: [], staffList: [...]` | 400,拒绝 | | ❌ staffList 里有超出范围的角色 | `scopeRoles: ["GUIDE","LEADER"], staffList: [{staffRole:"PHOTOGRAPHER",...}]` | 582115,拒绝 | --- ## 五、数据库行为 无 DDL、无 Flyway 迁移。全量覆盖与范围覆盖的删除窗口不同: | 操作 | 删除哪些旧行 | 插入哪些新行 | |------|------------|-----------| | 不传 `scopeRoles` | 整期所有角色 | `staffList` 的全部项 | | 传 `scopeRoles: ["GUIDE","LEADER"]` | 只删这两个角色的旧行 | `staffList` 的全部项(但必须都在范围内) | **范围外的既有行保持**:如果某个角色的人员不在覆盖范围内,本次保存对它们零影响,仍留在数据库。 --- ## 六、边界行为 - 未登录 → 401(网关拦截) - 团期不存在/未成团 → 589500 或 589552 - 资源域 Feign 调用慢或失败 → 内部重试或超时返 500;不会静默降级造成快照不完整 - 扇出异常 → 主事务已提交(数据已存盘),只是下游不知道;用户需手工检查或等待重试周期 ## 六.6、修改前后对比 | 项 | 改前 | 改后 | |---|---|---| | 覆盖范围 | 全量(整期所有角色全删再全插) | 可选:不传 = 全量,传 `scopeRoles` = 按范围 | | 删除窗口 | `DELETE FROM order_batch_staff WHERE product_batch_id=?` | 按 `scopeRoles` 缩小范围 | | `staffList` 回显 | 本次提交的 items | **保存后的整期最终状态** | | 扇出数据 | 本次提交的 items(错误:只含本范围) | **整期最终状态**(正确:全部角色) | | 导游位保存风险 | 漏带摄影位 → 摄影师清空 | 不再互相干扰 | | 错误码集合 | — | 新增 `582115`(范围校验失败) | | 入参 | — | 新增 `scopeRoles`(可选) | ## 六.7、影响评估 | 维度 | 评估 | |---|---| | 兼容性 | **完全向后兼容**。不传 `scopeRoles` 时行为逐字不变,现有调用无需改动 | | 前端 | **需要改动**。弹窗打开时自动填充 `scopeRoles`(导游位 → `["GUIDE","LEADER"]`,摄影位 → `["PHOTOGRAPHER"]`),保存时传上去。后端已在测试环境部署在线,可立即改代码进行联调与实测 | | 数据 | 无 DDL;存量 `order_batch_staff` 数据不动 | | 回滚 | `git revert` 后,不传 `scopeRoles` 的调用照常工作;已传 `scopeRoles` 的调用会因新字段不认而 400(但改前没人用它,实际无影响) | | 必须同步上线 | 是。前后端一起上,否则前端新代码传 `scopeRoles` 到旧后端是 400 | --- ## 七、不影响范围 - **零影响**:接口的 HTTP 方法、URL 路径、路径参数 - **零影响**:不传 `scopeRoles` 时的全量覆盖行为(与改前一致) - **零影响**:存量 `order_batch_staff` 行(本次不迁移);下次保存时按新逻辑处理 - **零影响**:其他 staff 相关接口(查询、删除等) - **零影响**:扇出至子订单的链路(改的只是快照的构成方式,语义不变) --- ## 八、测试环境已验证 ✅ PR #8117 已合入 dev-v3(合并提交 094521f0b),当前测试环境部署 commit d30cd9561,本次变更已在线。 **编译验证**:CI 全绿,含 ArchTest / 单测 / 静态检查 **单测覆盖**(241 examples new + existing,全过): - 范围保存的删除窗口(0.5 → 1.5 间隔) - 导游位必须同时声明两个角色(缺一触发 582115) - staffList 超出范围拒绝(582115) - 回显值切换(toInsert → finalState) - 扇出快照来源(同上) - ready 回填按整期算(不按本范围算) - 不传 scopeRoles 时全量行为不变 **测试数据**:一律 `T8006-` 前缀,验证后已清理。 --- ## 十、相关文档 - 工单:[#8006](https://git.1814.love:8443/wx/HL/issues/8006) — 团期人员配置跨角色清空风险 - PR:[#8117](https://git.1814.love:8443/wx/HL/pulls/8117) — 本次修复 - 相关缺口:[#8122](https://git.1814.love:8443/wx/HL/issues/8122) — `scopeRoles` 完整性校验(已记录,待处理) --- ## 关联 / 联系人 ### 链接 - **Issue**: [#8006](https://git.1814.love:8443/wx/HL/issues/8006) - **PR**: [#8117](https://git.1814.love:8443/wx/HL/pulls/8117) - **Merge commit**: [094521f0b](https://git.1814.love:8443/wx/HL/commit/094521f0b) ### 联系人 - **后端负责人**: @wx