docs: 团期 staff 保存接口新增 scopeRoles 入参与范围覆盖能力(#8006)
changelog-filename-gate / validate (push) Failing after 2s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-21 23:09:29 +08:00
共同撰写人 Claude Opus 5
父节点 89811fdadc
当前提交 8fa0204a43
@@ -0,0 +1,370 @@
---
schema: "hl-changelog/v2"
ticket: "8006"
title: "团期 staff 保存支持按角色范围覆盖,不再跨配置位清空"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "gateway_status: not_required 待确认——新增入参 scopeRoles 可能涉网关字段白名单,但无网关实测数据;错误码/响应体改动对网关无影响。PR #8117 已合入 dev-v3(合并提交 094521f0b),当前测试环境部署 commit d30cd9561,本次变更已在线。"
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<String>` | ❌ | 元素取值: `LEADER` / `GUIDE` / `DRIVER` / `PHOTOGRAPHER` / `OTHER` / `GUIDE_ASSISTANT` / `STUDY_TEACHER` / `LIFE_TEACHER`;**不传 = 整期全量覆盖**(历史行为),**传了就必须 ≥ 1 项**(传空数组 400 拒绝) | **新增**。本次保存覆盖的角色范围。不传时全量覆盖整期(与改前一致),传了则只在这些角色内覆盖,范围外的既有行不动。**特别注意:导游位并收 `GUIDE` 与 `LEADER` 两个角色,保存导游位时必须同时传这两个,只传其中一个会导致 `582115` 拒绝**(见下方错误响应) |
| `staffList` | Body | `List<Item>` | ✅ | — | 本范围内要保存的人员列表。传空数组 `[]` 表示清空该范围内的人员(与改前一致) |
| `staffList[].staffId` | Body | Long | ✅ | — | 资源域人员 ID |
| `staffList[].staffRole` | Body | String | ✅ | 同上 `scopeRoles` 的取值域 | 人员在本配置中的角色。如果传了 `scopeRoles`,这里的每一项都必须在范围内,否则 `582115` 拒绝 |
| `staffList[].sortOrder` | Body | Integer | ❌ | — | 展示排序(缺省 0) |
#### 出参 `Result<BatchStaffConfigRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `productBatchId` | Long | 回显路径参数 |
| `groupBatchId` | Long | 运营团期 ID(由 productBatchId 反查得到) |
| `staffList` | `List<Item>` | **保存后的整期最终状态**(见下方说明) |
| `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