docs(changelog): #6950 团期人员配置候选列表新增接口 + staffRole 白名单校验
changelog-filename-gate / validate (push) Successful in 2s

新增 GET /v3/admin/group-batch/:groupBatchId/staff/candidates,导游位并收
GUIDE/LEADER 并按 staffId 去重,摄影位只收 PHOTOGRAPHER,其余位返 582113。

同时记录本次行为变更:PUT .../staff 的 staffList[].staffRole 新增
@Pattern 白名单校验,越界值由原先原样入库改为返回 400。

2026-09-03 于测试环境网关实测 10 条正负向用例全部通过,
backend_status=deployed / gateway_status=verified。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
jw
2026-09-03 14:57:42 +08:00
共同撰写人 Claude Opus 5
父节点 411a549548
当前提交 d2c39b6c24
@@ -0,0 +1,441 @@
---
schema: "hl-changelog/v2"
ticket: "6950"
title: "团期人员配置候选列表(导游位并收 GUIDE/LEADER)"
consumer: "admin"
author: "jw(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-03"
status_note: "PR #6962 已合入 dev-v3;2026-09-03 经测试环境网关实测,6 条正负向用例全部通过。前端尚未接入"
updated_at: "2026-09-03"
base: "dev-v3"
---
# 团期人员配置: 新增人员配置候选列表接口,并修正 staffRole 取值校验
> **存放目录**: 二期(order-v3)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3
> **PR**: #6962
> **Issue**: #6950
> **日期**: 2026-09-02
> **影响范围**: 管理后台「团期详情 → 配置导游 / 配置摄影」弹窗的人员资源库列表
---
## ⚠️ 关键变化
本次除新增候选列表接口外,**另有一处行为变更**,调用方必须知道:
**保存团期人员配置 `PUT /v3/admin/group-batch/:groupBatchId/staff` 的 `staffList[].staffRole` 加了枚举白名单校验。**
- 前端以前以为:`staffRole` 只做非空校验,传什么都能存进去。
- 实际现在是:白名单外的取值 **返回 400**,不再原样入库。
- 白名单:`LEADER` / `GUIDE` / `DRIVER` / `PHOTOGRAPHER` / `OTHER`。
另外该字段的 Swagger 描述此前 **漏了 `GUIDE`**,本次补全。若前端此前按旧描述认为只有 4 个取值,现在是 5 个。
---
## 一、背景
「配置导游」「配置摄影」弹窗需要一份可选人员列表,此前 order-v3 侧没有这个接口,前端无从取候选池。
**资源域零改动**:`GET /internal/staff/list-available` 本就存在(其 Swagger 注释写明「供管理后台团批分配人员的下拉选择框使用」),order-v3 侧只补了 Feign 方法与降级处理。
**导游位为什么并收两类**(2026-09-02 jw 裁决):资源域字典里 `GUIDE` 是导游、`LEADER` 是领队,两者在团期现场都可能承担带团职责,由配置人按实际情况挑,**服务端不替业务做取舍**。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 查询团期人员配置候选列表 | GET | `/v3/admin/group-batch/:groupBatchId/staff/candidates` | **新增接口** | 导游位并收 GUIDE/LEADER,摄影位只收 PHOTOGRAPHER |
| 2 | 保存团期人员配置 | PUT | `/v3/admin/group-batch/:groupBatchId/staff` | **请求体新增校验** | `staffList[].staffRole` 加枚举白名单,越界返 400 |
---
## 三、接口详情
### 1. 查询团期人员配置候选列表 `GET /v3/admin/group-batch/:groupBatchId/staff/candidates`
**VO**: `StaffCandidateRespVO`
#### 使用场景
管理后台「团期详情 → 配置导游 / 配置摄影」弹窗打开时调用,用于渲染可选人员资源库列表。
弹窗按配置位分别调用:导游弹窗传 `role=GUIDE`,摄影弹窗传 `role=PHOTOGRAPHER`。
列表中 `assigned=true` 的项应预置为已勾选状态,供配置人在原有选择基础上增删。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 团期批次 ID。**这里是产品侧 `group_tour_batch.batch_id`**,不是订单侧运营团期主键,与 `order_batch_staff.group_batch_id` 同源 |
| `role` | Query | String | ✅ | 只接受 `GUIDE` / `PHOTOGRAPHER` | 配置位。`GUIDE`=导游位(并收 GUIDE/LEADER,可选多人);`PHOTOGRAPHER`=摄影位。**其余取值(含 `DRIVER`)返回 582113** |
> `DRIVER` 被显式拒绝:司机由车务派车产生,不在团期人员配置里手工指定。
#### 出参 `Result<List<StaffCandidateRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `staffId` | Long | 人员 ID |
| `staffName` | String | 姓名 |
| `staffPhone` | String | 手机号,**前 3 后 4 脱敏**(与 `getConfig` 口径一致) |
| `staffType` | String | 资源域人员类型:`GUIDE` / `LEADER` / `PHOTOGRAPHER` |
| `avatarUrl` | String | 头像 URL;资源域无头像或取头像失败时为 `null` |
| `assigned` | Boolean | 是否已被本团期选中。**`true` 时前端应显示为已勾选** |
| `assignedRole` | String | 已选中时对应的 `order_batch_staff.staff_role`;未选中为 `null` |
> `assigned` 与 `assignedRole` 要分开判:存在「已选但角色为空」的历史数据,此时 `assigned=true` 而 `assignedRole=null`,前端**不能**用 `assignedRole != null` 判断勾选态。
#### 请求示例
```http
GET /v3/admin/group-batch/1823456789012345678/staff/candidates?role=GUIDE
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{
"staffId": 1823456789012345678,
"staffName": "张三",
"staffPhone": "138****8000",
"staffType": "GUIDE",
"avatarUrl": "https://oss.example.com/avatar/1.jpg",
"assigned": true,
"assignedRole": "LEADER"
},
{
"staffId": 1823456789012345679,
"staffName": "李四",
"staffPhone": "139****1234",
"staffType": "LEADER",
"avatarUrl": null,
"assigned": false,
"assignedRole": null
}
]
}
```
#### 空数据 / 降级响应
资源域无可用人员时返回空数组,不报错:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": []
}
```
头像服务取不到时**不阻断列表**,`avatarUrl` 降级为 `null`,其余字段照常返回。
#### 错误响应
`role` 传了 `GUIDE` / `PHOTOGRAPHER` 之外的值(如 `DRIVER`):
```json
{
"code": 582113,
"message": "人员配置位不合法,只支持导游位与摄影位",
"success": false,
"data": null
}
```
资源域人员查询失败:
```json
{
"code": 582103,
"message": "员工信息查询失败,请稍后重试",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口,**不产生任何写入**,可安全重复调用
- 同一人同时属于 `GUIDE` 与 `LEADER` 时按 `staffId` 去重,只返回一条
- `assigned` 反映的是本团期当前配置状态,与 `role` 入参无关:传 `role=GUIDE` 时,
已被配成摄影位的人不会出现在结果里,但导游位候选中若有人已被选中则 `assigned=true`
- 资源域仅返回启用状态人员,停用人员不在候选池
- 头像属展示增强,取不到时降级为 `null`,**不影响可选性**
---
### 2. 保存团期人员配置 `PUT /v3/admin/group-batch/:groupBatchId/staff`
**VO**: `BatchStaffConfigReqVO`
**本次只改请求体校验,路径、出参均不变。**
#### 使用场景
「配置导游 / 配置摄影」弹窗点击保存时调用,整批覆盖该团期的人员配置。
本次变更后,前端必须保证 `staffRole` 取值落在白名单内,否则整个请求被拒、无一条生效。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 团期批次 ID,同接口 1 |
| `staffList` | Body | Array | ✅ | — | 整批覆盖语义:传入列表即最终配置,未包含的人员被移除 |
| `staffList[].staffId` | Body | Long | ✅ | `@NotNull` | 人员 ID |
| `staffList[].staffRole` | Body | String | ✅ | **本次新增** `@Pattern`:`LEADER`\|`GUIDE`\|`DRIVER`\|`PHOTOGRAPHER`\|`OTHER` | **变更前**仅 `@NotBlank`,任意非空值原样入库;**变更后**越界返 400 |
| `staffList[].sortOrder` | Body | Integer | ❌ | — | 展示排序 |
| `staffList[].remark` | Body | String | ❌ | `@Size(max=500)` | 备注 |
#### 出参 `Result<BatchStaffConfigRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `staffList` | Array | 保存后的人员配置列表,字段同 `getConfig` |
| `affectedOrderCount` | Integer | 本次保存扇出影响的子订单数 |
#### 请求示例
```json
{
"staffList": [
{
"staffId": 1002,
"staffRole": "GUIDE",
"sortOrder": 0
}
]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"staffList": [
{
"id": "2095403473712418817",
"staffId": 1002,
"staffRole": "GUIDE",
"staffName": "李雪梅",
"staffPhone": "138****1002",
"avatarUrl": null,
"sortOrder": 0,
"remark": null
}
],
"affectedOrderCount": 0
}
}
```
#### 空数据 / 降级响应
传入空 `staffList` 数组表示清空该团期的人员配置,返回 200 且 `staffList` 为空数组,
不报错。这也是回退误配置的正规手段。
#### 错误响应
`staffRole` 越界(**本次新增行为**):
```json
{
"code": 400,
"message": "员工角色只能是 LEADER / GUIDE / DRIVER / PHOTOGRAPHER / OTHER",
"success": false,
"data": null
}
```
#### 业务边界
- **整批覆盖**语义,不是增量追加;未包含在 `staffList` 里的既有人员会被移除
- 校验在 `@Valid` 阶段完成,**任一条目越界则整个请求被拒,不会部分写入**
- `@Pattern` **区分大小写**,`guide` 不等于 `GUIDE`
- 空串会同时触发 `@Pattern` 与 `@NotBlank`,消息合并返回
- 存量 `order_batch_staff` 数据不迁移,新校验只在下次保存时触发
---
## 四、契约约束与正确调用方式
- **`role` 必传且只接受两个值**:`GUIDE`、`PHOTOGRAPHER`。不要传 `DRIVER`——司机由车务派车投影产生,
不在团期人员配置里手工指定,传了会返回 582113。
- **勾选态判断用 `assigned`,不要用 `assignedRole != null`**。存在「已选但角色为空」的历史数据,
用后者会漏掉这批人。
- **`groupBatchId` 是产品侧 `group_tour_batch.batch_id`**,不是订单侧运营团期主键。传错会返回空列表而非报错。
- **保存前先过白名单**:`staffRole` 只能是 `LEADER` / `GUIDE` / `DRIVER` / `PHOTOGRAPHER` / `OTHER`,
且区分大小写。整批中任一条越界会导致整个请求 400、无一条生效。
- **保存是整批覆盖**:每次提交需带上该团期的完整人员列表,只传增量会导致其余人员被清除。
- 错误码 `582113` 是业务码,HTTP 状态仍为 200,判断成败要读响应体的 `code` 字段。
---
## 五、数据库行为
仅描述外部可观察行为:
- 接口 1(GET candidates)**只读,不产生任何写入**。
- 接口 2(PUT staff)按整批覆盖语义重写该团期的人员配置:提交列表中的人员被保留或新增,
未包含的既有人员被移除;响应的 `affectedOrderCount` 表示随之扇出更新的子订单数量。
- 越界校验发生在写入之前,**校验失败时数据库无任何变更**。
- 本次变更**不涉及表结构调整**,也不对存量数据做迁移或回填。
---
## 六、边界行为
- `role` 非法 → **582113**,不是 400(业务码,HTTP 仍 200)
- 资源域无可用人员 → 返回 `[]`,不报错、不阻断弹窗
- 头像服务异常 → `avatarUrl` 为 `null`,列表照常返回(头像属展示增强)
- 资源域只返回 `status=1` 的人员,按 `sortOrder`、`staffId` 排序
- 同一人同时命中 `GUIDE` 与 `LEADER` 两类 → **按 `staffId` 去重,只出现一次**
- 已选人员 `staffRole` 为 `null`(历史脏数据)→ `assigned=true`、`assignedRole=null`,**接口不 500**
- 未登录 → 401(网关拦截)
---
## 六.5、枚举 / 数据字典
### `role`(Query 参数,配置位)
| 取值 | 含义 | 实际拉取的资源域 `staffType` |
|------|------|------------------------------|
| `GUIDE` | 导游位 | **`GUIDE` + `LEADER` 两类并收**,按 `staffId` 去重,可选多人 |
| `PHOTOGRAPHER` | 摄影位 | 只收 `PHOTOGRAPHER` |
| 其他(含 `DRIVER`) | — | 拒绝,返回 582113 |
### `staffType`(响应字段,资源域人员类型)
本接口可能返回 `GUIDE`(导游)、`LEADER`(领队)、`PHOTOGRAPHER`(摄影)三种。
资源域完整字典还包含 `GUIDE_ASSISTANT` / `LIFE_TEACHER` / `STUDY_TEACHER` / `OTHER`,但**不会出现在本接口响应里**。
### `staffRole`(保存接口入参,取值域对齐 `SettlementStaffRoleEnum`)
| 取值 | 含义 |
|------|------|
| `LEADER` | 领队 |
| `GUIDE` | 导游 |
| `DRIVER` | 司机(**由车务派车投影产生,一般不由本接口写入**) |
| `PHOTOGRAPHER` | 摄影 |
| `OTHER` | 其他 |
---
## 六.6、错误码
段位 `582100-582199`,owner `hl-order-service-v3`(`AssignmentErrorCode`)。
| 码 | 符号 | 消息 | 触发 |
|---|---|---|---|
| `582113` | `STAFF_CANDIDATE_ROLE_INVALID` | 人员配置位不合法,只支持导游位与摄影位 | **本次新增**。`role` 不是 `GUIDE` / `PHOTOGRAPHER` |
| `582103` | `STAFF_INFO_FETCH_FAILED` | 员工信息查询失败,请稍后重试 | 既有码。资源域 `list-available` 返回失败或空结果对象 |
| `400` | — | 员工角色只能是 LEADER / GUIDE / DRIVER / PHOTOGRAPHER / OTHER | **本次新增**。保存接口 `staffRole` 越界 |
---
## 七、不影响范围
- **仅影响**:管理后台团期详情的「配置导游 / 配置摄影」弹窗
- **零影响**:
- 资源域 `hl-resource-service`(**结构与接口零改动**,只是被新调用方使用)
- 团期人员保存后的子订单扇出逻辑(`order_staff_assignment`)
- 车务派车产生的司机行
- 已有的 `GET` / `PUT /v3/admin/group-batch/:groupBatchId/staff` 出参
- 存量 `order_batch_staff` 数据(不迁移,`staffRole` 新校验只在下次保存时触发)
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-03 于测试环境网关实测,真实鉴权(管理端 admin 账号)。
- 网关:`https://api.test.1814.love:9443`
- 路由:`/v3/admin/**` 由网关直挂 hl-order-service-v3(**无 `/order-v3` 前缀**)
- 分支:`dev-v3`
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | `?role=GUIDE` | 200,并收 GUIDE+LEADER | ✅ 200,7 条,staffType 去重 = `[GUIDE, LEADER]`,staffId 唯一 |
| 2 | `?role=PHOTOGRAPHER` | 200,只含摄影 | ✅ 200,1 条,staffType 去重 = `[PHOTOGRAPHER]` |
| 3 | `?role=DRIVER` | 582113 | ✅ `582113 人员配置位不合法,只支持导游位与摄影位` |
| 4 | `?role=XXX` 非法值 | 582113 | ✅ 同上 |
| 5 | 缺 `role` 参数 | 400 | ✅ `400 缺少必要参数: role` |
| 6 | 无 Authorization | 401 | ✅ `401 缺少有效的 Authorization 头` |
**脱敏核对**:`138****1002` / `139****1011` / `139****1010`,前 3 后 4 生效。
**头像降级核对**:用例 1 中多条 `avatarUrl` 为 `null` 未阻断返回;用例 2 中摄影师返回真实 OSS 地址。
本地单元与架构测试(提交信息记载):
```
GroupBatchStaffConfigServiceTest 27 例全过 ✓
LayerEnforcement / MapperBoundary / RedLine /
ErrorCodeRegistry 45 例全过 ✓
```
### 保存接口 `staffRole` 白名单实测(本次行为变更)
同日于同一网关实测 `PUT /v3/admin/group-batch/:groupBatchId/staff`:
| # | payload `staffList[0].staffRole` | 期望 | 实测 |
|---|---|---|---|
| 7 | `"SUPERVISOR"` 越界值 | 400 | ✅ `400 员工角色只能是 LEADER / GUIDE / DRIVER / PHOTOGRAPHER / OTHER` |
| 8 | `"guide"` 小写 | 400 | ✅ 同上(正则区分大小写) |
| 9 | `""` 空串 | 400 | ✅ `400 …; 该字段不能为空`(@Pattern 与 @NotBlank 同时触发) |
| 10 | `"GUIDE"` 白名单内 | 放行 | ✅ 200,`affectedOrderCount: 0` |
用例 7–9 在 `@Valid` 阶段即被拒,**不产生任何写入**。用例 10 会落库,测试后已用空 `staffList` 数组 PUT 还原,
复核 `GET .../staff` 返回 `data: []`,与测试前一致。
**仍待补**:空候选池分支未构造(需一个无可用人员的团期)。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| **本 PR #6962** | **#6950** | 新增候选列表接口 + `staffRole` 白名单校验 | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#6950](https://git.1814.love:8443/wx/HL/issues/6950)
- 关联 PR: [wx/HL#6962](https://git.1814.love:8443/wx/HL/pulls/6962)
- 合入提交: `809462321`(feat),merge `35d0a8f2c` → `dev-v3`
- 需求与契约: `docs/group/团期模块接口文档-v2.0.html` GB-ADM-014
---
## 关联 / 联系人
- **Issue**: #6950
- **PR**: #6962(合并提交 `35d0a8f2c`,落入 `dev-v3`)
- **服务**: hl-order-service-v3
- **后端**: jw
- **前端**: 待认领(`frontend_status: pending`)
- **口径裁决**: 2026-09-02 jw —— 导游位并收 `GUIDE` 与 `LEADER`,摄影位只收 `PHOTOGRAPHER`,`DRIVER` 显式拒绝