PR #8669 已合入 dev-v3(merge commit 5c50782717d6),order-v3 已部署测试服 (jar 5c5078271)并逐条取证通过。 三条对外契约变化: 1. 同一批数据两个读口口径不同——saveConfig 的响应回显与 GROUP_BATCH 扇出副本 不含司机行(走 selectManualConfigByProductBatchId,带 ne(staff_role, DRIVER)), 而 getConfig / getConfigWithLiveStaffInfo 含司机行(走 selectByProductBatchId, 无该谓词)。前端不要假设保存响应即全量。 2. order_batch_staff 现在会出现系统写入的 DRIVER 行,由车务派车回调投影维护, 不提供人工编辑/删除入口。 3. 新错误码 582120:saveConfig 的 staffList 含 staffRole=DRIVER,或 scopeRoles 声明 DRIVER,均拒绝整批保存且零写入(守卫排在软删与插入之前)。 Refs #8653 Refs #8654 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
22 KiB
schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
| schema | ticket | title | consumer | author | change_type | backend_status | gateway_status | frontend_status | frontend_owner | frontend_ref | target_release | verified_at | status_note | updated_at | base |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| hl-changelog/v2 | 8654 | 团期正式派车司机投影到人员配置表,DRIVER 角色改系统托管 | admin | wx(GIT) | 修改接口 | deployed | verified | pending | 2026-09-30 | 2026-09-30 | dev-v3 |
团期人员配置: 正式派车司机投影到人员配置表,DRIVER 改系统托管
存放目录:
changelogs-v2/2026-09/服务: hl-order-service-v3 PR: #8669 Issue: #8653, #8654 日期: 2026-09-30 影响范围: 管理后台团期人员配置页、团期核单按团看人的名册读取
⚠️ 关键变化
团期人员配置表 order_batch_staff 现在会有系统自动生成的 DRIVER 行(司机),前端必须适配两个关键变化:
-
同一批数据的两个读口口径不同: 保存接口响应里不含司机行,但查询接口(
getConfig/ 名册读取)含司机行。前端不要假设响应即全量。 -
司机行不可编辑: 这些 DRIVER 行由车务派车自动投影产生,不是运营配置的,前端不要在人员编辑表单提供编辑/删除入口。
-
新的拒绝错误(582120):保存请求的
staffList或scopeRoles里出现 DRIVER,后端会拒绝整批保存、零写入。
一、背景
工单 #8653 与 #8654 合并的功能:把车务派车产生的司机自动投影到团期人员配置表,供核单时按团看人、按天算账。
此前司机事实分散在每一户订单的用车需求上,现在统一投影到团期维度,简化核单逻辑。
二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 保存团期 staff 配置 | PUT | /v3/admin/group-batch/{productBatchId}/staff |
新增错误码 + 响应内容变化 | DRIVER 行系统管理,拒绝人工写入 |
三、接口详情
1. 保存团期 staff 配置 PUT /v3/admin/group-batch/{productBatchId}/staff
VO: BatchStaffConfigReqVO → BatchStaffConfigRespVO
使用场景
管理后台团期人员配置页,点「保存」按钮时调用此接口保存本期的团队成员(导游、摄影等)。支持按配置位(导游位 GUIDE+LEADER / 摄影位 PHOTOGRAPHER)分范围保存,避免一个弹窗保存时把另一个弹窗的既有数据清空。
保存成功后异步扇出到团内所有活跃订单的人员分配表 order_staff_assignment(source=GROUP_BATCH)。
入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| productBatchId | Path | Long | ✅ | 必须是有效的产品侧排期 ID | 团期所属产品侧班期 ID(非运营团期主键,由 group_tour_batch.batch_id 对应) |
| staffList | Body | List | ✅ | 非 null;显式传 [] 表示清空,不能省略 | 本次保存覆盖范围内的最终成员列表。禁止含 staffRole=DRIVER 的行(582120)。若覆盖范围是导游位,必须同时列出 GUIDE 与 LEADER 两个角色成员(工单 #8122)。 |
| staffList[].staffId | Body | Long | ✅ | 有效的员工 ID | 用户域员工 ID |
| staffList[].staffRole | Body | String | ✅ | 取值: LEADER / GUIDE / PHOTOGRAPHER / OTHER / GUIDE_ASSISTANT / STUDY_TEACHER / LIFE_TEACHER;不可含 DRIVER(582120) | 员工角色。司机行由车务派车自动投影,一律不由本接口写入。 |
| staffList[].sortOrder | Body | Integer | ❌ | 缺省 0 | 显示排序值(升序排列) |
| staffList[].remark | Body | String | ❌ | ≤500 字符;null 表示保留原值,传空串清空 | 备注,仅供参考 |
| staffList[].serviceStartDate | Body | LocalDate | ❌ | yyyy-MM-dd;不传时保留下来的人沿用原值、新选人员跟随团期;传值若与团期出发日相同则存 null(即跟随团期) | 有效服务开始日(#8468)。若自定义则必须在团期出发日到结束日之间,否则 582119 拒绝。 |
| staffList[].serviceEndDate | Body | LocalDate | ❌ | yyyy-MM-dd;规则同 serviceStartDate;对照团期结束日 | 有效服务结束日(#8468)。 |
| scopeRoles | Body | List | ❌ | 元素不能为 null / 空串 / 纯空白;传了必须 size ≥ 1 | 本次保存覆盖的角色范围。不传 = 整期全量覆盖(历史行为);传了 = 只覆盖这些角色,范围外既有行不动(工单 #8006)。导游位必须同时传 GUIDE 与 LEADER(工单 #8122),只传一个拒绝 582116 且零写入。staffList 里出现范围外角色拒绝 582115 且零写入。禁止传 DRIVER(582120)。 |
出参 Result<BatchStaffConfigRespVO>
| 字段 | 类型 | 说明 |
|---|---|---|
| data.productBatchId | Long | 路径参数回显 |
| data.groupBatchId | Long | 运营团期 ID(order_group_batch 主键) |
| data.staffList | List | 本次保存后保留下来的整期非司机成员的最新快照。不含 DRIVER 行(工单 #8654)。 |
| data.staffList[].id | Long | 记录 ID(batch_staff_id) |
| data.staffList[].staffId | Long | 员工 ID |
| data.staffList[].staffRole | String | 员工角色(LEADER / GUIDE / PHOTOGRAPHER 等,响应侧不含 DRIVER) |
| data.staffList[].staffRoleName | String | 员工角色中文名(「导游」「领队」等;取值不在字典内时回落原 code) |
| data.staffList[].staffName | String | 员工姓名(配置时快照) |
| data.staffList[].staffPhone | String | 员工手机(脱敏:前 3 后 4,如 138****6677) |
| data.staffList[].avatarUrl | String | 头像 URL(配置时快照) |
| data.staffList[].sortOrder | Integer | 显示排序值 |
| data.staffList[].remark | String | 备注 |
| data.staffList[].reporterRank | String | 报账人等级:PRIMARY(主) / SECONDARY(次) / NONE(非报账人) |
| data.staffList[].reporterRankName | String | 报账人等级中文名 |
| data.staffList[].serviceStartDate | LocalDate | 有效服务开始日(未自定义时 = 团期出发日,团期改期后跟着变;#8468) |
| data.staffList[].serviceEndDate | LocalDate | 有效服务结束日(规则同上) |
| data.staffList[].serviceDateCustom | Boolean | 是否自定义过服务日期;true 时开始/结束至少一个有自定义值 |
| data.staffList[].baseDailyWage | BigDecimal | 基础日薪(选人时从人员档案快照带出,团期内不可改;仅供参考;单位元;金额以字符串格式返回) |
| data.staffList[].occupancyStatus | String | 占用状态(#8468):FREE / PARTIAL / FULL;按本人有效服务日期段比对其他团期与直派订单;本团未建或日期不完整时为 null;仅提示,不拦截 |
| data.staffList[].occupiedDays | Integer | 被占天数(两端都算);null 时为 0 |
| data.staffList[].freeRanges | List | 可派日期段列表(本人有效日期段内未被占的连续区间);恒非 null;全程占用时为 [] |
| data.staffList[].occupancies | List | 占用明细数组;恒非 null;空闲时为 [] |
| data.affectedOrderCount | Integer | 扇出影响的订单数(已触发异步写入的活跃子订单数) |
请求示例
PUT /v3/admin/group-batch/80001/staff HTTP/1.1
Host: admin-api.test.example.com
Authorization: Bearer {token}
Content-Type: application/json
{
"scopeRoles": ["GUIDE", "LEADER"],
"staffList": [
{
"staffId": 40001,
"staffRole": "LEADER",
"sortOrder": 0,
"remark": "首席领队",
"serviceStartDate": "2026-10-01",
"serviceEndDate": "2026-10-06"
},
{
"staffId": 40002,
"staffRole": "GUIDE",
"sortOrder": 1,
"remark": null,
"serviceStartDate": null,
"serviceEndDate": null
}
]
}
响应示例
{
"code": 200,
"message": "成功",
"data": {
"productBatchId": 80001,
"groupBatchId": 90211,
"staffList": [
{
"id": 770001,
"staffId": 40001,
"staffRole": "LEADER",
"staffRoleName": "领队",
"staffName": "刘领队",
"staffPhone": "138****6677",
"avatarUrl": "https://example.com/avatar/40001.jpg",
"sortOrder": 0,
"remark": "首席领队",
"reporterRank": "PRIMARY",
"reporterRankName": "主报账人",
"serviceStartDate": "2026-10-01",
"serviceEndDate": "2026-10-06",
"serviceDateCustom": true,
"baseDailyWage": "600.00",
"occupancyStatus": "PARTIAL",
"occupiedDays": 2,
"freeRanges": ["2026-10-01~2026-10-03", "2026-10-05~2026-10-06"],
"occupancies": [
{
"groupBatchId": 90212,
"groupBatchName": "十一国庆游 V2 期",
"conflictDates": ["2026-10-02", "2026-10-04"]
}
]
},
{
"id": 770002,
"staffId": 40002,
"staffRole": "GUIDE",
"staffRoleName": "导游",
"staffName": "王导游",
"staffPhone": "138****5678",
"avatarUrl": "https://example.com/avatar/40002.jpg",
"sortOrder": 1,
"remark": null,
"reporterRank": "NONE",
"reporterRankName": "非报账人",
"serviceStartDate": "2026-10-01",
"serviceEndDate": "2026-10-06",
"serviceDateCustom": false,
"baseDailyWage": "500.00",
"occupancyStatus": "FREE",
"occupiedDays": 0,
"freeRanges": ["2026-10-01~2026-10-06"],
"occupancies": []
}
],
"affectedOrderCount": 3
},
"success": true
}
空数据 / 降级响应
整期清空后的响应(staffList=[] 加 scopeRoles 不传):
{
"code": 200,
"message": "成功",
"data": {
"productBatchId": 80001,
"groupBatchId": 90211,
"staffList": [],
"affectedOrderCount": 3
},
"success": true
}
错误响应
错误码 582120(司机行系统管理,拒绝人工写入):
{
"code": 200,
"message": "司机由车务派车自动带入团期人员,不能在这里新增或删除;如需调整请到车务派单中维护",
"success": false,
"data": null
}
错误码 582116(导游位只传半个配置位):
{
"code": 200,
"message": "团期人员配置位 GUIDE 成员缺失,导游位(导游+领队)须同时声明两个角色",
"success": false,
"data": null
}
错误码 582115(staffList 包含 scopeRoles 范围外的角色):
{
"code": 200,
"message": "员工角色 PHOTOGRAPHER 不在覆盖范围 [GUIDE,LEADER] 内,请修正请求",
"success": false,
"data": null
}
错误码 582119(服务日期校验失败):
{
"code": 200,
"message": "刘领队 的服务日期(2026-10-06 至 2026-10-05)不合法:开始日期不能晚于结束日期,且须在团期日期(2026-10-01 至 2026-10-10)之内",
"success": false,
"data": null
}
业务边界
- 权限: 接口接
GroupBatchPermissionGuard.PERMISSION_MANAGE,需要团期管理权限;权限校验在 Controller 层,拒绝时零写入。 - 幂等性: 同一请求重复提交视为覆盖保存,第二次提交时无新改动则库表无变化、响应同样 200。
- 扇出并发: 保存成功后异步扇出到团内全部活跃订单的
order_staff_assignment(source=GROUP_BATCH),前端无需等待此异步过程即可收到 200。 - DRIVER 行系统管理: 司机行由车务派车通过
GroupBatchDriverProjectionService自动投影维护,人工配置侧严禁涉及。staffList 或 scopeRoles 里出现 DRIVER 一律拒绝(582120),整批保存零写入,连软删都不执行。 - 两个读口口径不同:
- 本接口(PUT)保存成功后返回的
staffList不含司机行(只含人工配置的非 DRIVER 角色)。 - 查询接口
GET /v3/admin/group-batch/{productBatchId}/staff与单人查询GET .../staff/{staffId}含司机行。 - 前端不要假设响应即全量,名册读取时必须从查询接口获取完整名单(含司机)。
- 本接口(PUT)保存成功后返回的
- 团期状态检查: 入口第一步检查团期成团状态(未建团 589553 / 已确认或已取消 589598);不满足直接拒,代码不走到保存逻辑。
- 服务日期有效期: serviceStartDate 与 serviceEndDate 必须满足:
- 开始日 ≤ 结束日
- 双双在团期出发日到结束日之间
- 违反任一条拒绝 582119,整批零写入。
四、契约约束与正确调用方式
✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload | 结果 |
|---|---|---|
| ✅ 导游位全量覆盖 | { "scopeRoles": ["GUIDE","LEADER"], "staffList": [{staffId:40001, staffRole:"LEADER"}, {staffId:40002, staffRole:"GUIDE"}] } |
200,只有这两人留在 GUIDE 和 LEADER 行,其他既有行不动 |
| ✅ 清空整期 | { "staffList": [] } (不传 scopeRoles) |
200,整期人员全清,库表此后仅含司机行 |
| ✅ 导演位自定义服务日期 | { "staffList": [{staffId:40001, staffRole:"LEADER", serviceStartDate:"2026-10-01", serviceEndDate:"2026-10-05"}] } |
200 |
| ❌ 导游位只传半个配置位 | { "scopeRoles": ["GUIDE"], "staffList": [...] } |
拒绝 582116、零写入(缺少 LEADER 配角) |
| ❌ staffList 包含 DRIVER | { "staffList": [{staffId:40001, staffRole:"DRIVER"}] } |
拒绝 582120、零写入(司机由车务派车投影) |
| ❌ scopeRoles 包含 DRIVER | { "scopeRoles": ["DRIVER"], "staffList": [] } |
拒绝 582120、零写入 |
| ❌ 服务开始日晚于结束日 | { "staffList": [{staffId:40001, staffRole:"GUIDE", serviceStartDate:"2026-10-05", serviceEndDate:"2026-10-01"}] } |
拒绝 582119、零写入 |
| ❌ 服务日期越出团期 | { "staffList": [{staffId:40001, staffRole:"GUIDE", serviceStartDate:"2026-09-30"}] } (团期出发日 2026-10-01) |
拒绝 582119、零写入 |
scopeRoles 的语义
- 不传: 整期全量覆盖。staffList 即为团期的最终全量人员配置(除去司机),其他所有角色的既有行会被软删。
- 传了: 仅覆盖声明的角色。比如只想更新导演位不动摄影位,传
["GUIDE","LEADER"]即可;摄影位的既有行保持不变。 - 导游位特殊性: 因为导游位同时收 GUIDE 与 LEADER 两个角色,声明导游位必须两个都传。只传其中一个会被拒(582116)——因为「少那一个」意味着覆盖范围不完整,不能正确表达"我只想改导游位"的意图。
- DRIVER 禁令: scopeRoles 里出现 DRIVER 拒绝 582120。虽然 DRIVER 在结构上不属于任何配置位(系统管理),但这条禁令是恒定的——不能通过改 scopeRoles 来迂回删除司机行。
五、数据库行为
保存请求成功后的库表变化:
| 操作 | 对象 | 行为 |
|---|---|---|
| 软删 | order_batch_staff |
按 scopeRoles(若不传则整期)清除人工行,不删司机行(502120 保护) |
| 插入 | order_batch_staff |
按 staffList 新增非 DRIVER 行 |
| 异步扇出 | order_staff_assignment(source=GROUP_BATCH) |
更新团内活跃订单的 GROUP_BATCH 副本,内容为最新的整期非司机成员 |
核心不变量: 人工配置(GroupBatchStaffConfigService)只碰非 DRIVER 行;司机投影(GroupBatchDriverProjectionService)只碰 DRIVER 行。两边各管各的子集,交集为空。
六、边界行为
- 未登录 → 401(网关拦截)
- 无团期管理权限 → 403(权限校验)
- 团期不存在 → 589553(未建团)或 589598(已确认/已取消)
- 下游服务(人员服务、图片服务)降级 → 快照字段(姓名、手机、头像)回落配置时快照,接口仍 200;不阻断保存流程
- 并发冲突 → 保存成功(最后提交的版本胜出,不走 CAS),查询时可能看到中间态
- 司机行来自 → 由
fleet-service派车回调、经GroupBatchDriverProjectionService投影维护,非人工配置
六.5、枚举
staffRole(员工角色)
所属字段: staffList[].staffRole (请求) / data.staffList[].staffRole (响应) | 类型: String
| 值 | 中文 | 说明 |
|---|---|---|
LEADER |
领队 | 导游位成员之一 |
GUIDE |
导游 | 导游位成员之一(与 LEADER 并收) |
PHOTOGRAPHER |
摄影 | 摄影位成员 |
GUIDE_ASSISTANT |
导游助理 | 其他配置位成员 |
STUDY_TEACHER |
研学老师 | 其他配置位成员 |
LIFE_TEACHER |
生活老师 | 其他配置位成员 |
OTHER |
其他 | 杂项角色(不属于任何标准配置位) |
DRIVER |
司机 | 本接口禁止人工写入(582120);由车务派车自动投影;查询时可见 |
reporterRank(报账人等级)
所属字段: data.staffList[].reporterRank (响应) | 类型: String
| 值 | 中文 | 说明 |
|---|---|---|
PRIMARY |
主报账人 | 团期内唯一;预支款打给此人 |
SECONDARY |
次报账人 | 团期内唯一;备选收款人 |
NONE |
非报账人 | 缺省值;不参与结算 |
occupancyStatus(占用状态)
所属字段: data.staffList[].occupancyStatus (响应) | 类型: String
| 值 | 中文 | 说明 |
|---|---|---|
FREE |
空闲 | 有效服务日期段内无其他团期或订单占用 |
PARTIAL |
部分占用 | 有效日期段内某些天被占,某些天可派 |
FULL |
全程占用 | 有效日期段全部被占,无可派日期 |
null |
- | 团期未建成或服务日期不完整时为 null;仅提示,不拦截提交 |
六.6、修改前后对比
接口逻辑变化
| 维度 | 改前 | 改后 |
|---|---|---|
| 司机行管理 | 团期人员配置由运营全手工维护,无系统投影 | 司机行由车务派车自动投影,人工配置侧严禁涉及 |
| 保存响应 | 返回整期人员全量(包含一切手工配置) | 返回非司机成员快照(DRIVER 行被筛除) |
| 查询响应 | 同保存响应 | 含司机行(与保存响应口径不同) |
| 错误码新增 | 无 DRIVER 相关拒绝 | 新增 582120:司机行拒绝码,整批零写入 |
| 权限校验 | 无 | 新增 Controller 层权限校验(PERMISSION_MANAGE),拒绝时零写入 |
字段级变化
| 字段 | 改前状态 | 改后状态 |
|---|---|---|
serviceStartDate / serviceEndDate |
无此字段 | 新增可选字段;支持按人自定义服务有效期 |
serviceDateCustom |
无 | 新增,标记是否自定义过服务日期 |
baseDailyWage |
无 | 新增,人员档案快照(配置时带出,不可修改) |
occupancyStatus / occupiedDays / freeRanges |
无 | 新增,占用查询结果(#8468 D8,仅提示不拦截) |
六.7、影响评估
-
是否破坏向后兼容: 是(一定程度)
- 响应体新增字段:
serviceStartDate/serviceEndDate/serviceDateCustom/baseDailyWage/occupancyStatus/occupiedDays/freeRanges/occupancies,但字段全可空,字段层兼容。 - 响应
staffList内容变化:不再包含司机行(之前如果有系统产生的司机行会出现,现在被筛除)。前端若依赖"返回即全量"会破损。 - 新增错误码 582120:拒绝 DRIVER 行,整批零写入——改动前的正常请求可能现在被拒。
- 响应体新增字段:
-
前端是否必须同步上线: 是
- 如果前端在"人员名册""核单名单"等读取页面会显示司机信息,必须从查询接口(GET)而非缓存保存接口的响应来获取;否则缺司机行。
- 如果前端在人员编辑表单提供了 DRIVER 行的编辑/删除入口,必须移除,改为呈现"这是由车务派车产生的"提示。
- UI 需要适配新字段(服务日期、日薪、占用)的显示。
-
前端 workaround 清理点:
- 若前端之前硬编码了"司机只能通过XX页面配置",现在这句不再对。
- 若前端假设"保存响应即全量名册"来渲染名册卡片,需改为调查询接口。
- 若前端在人员编辑表单里有 DRIVER 选项,需删除;前端用户无法(也不应该)在这里新增/删除司机。
七、不影响范围
- 仅影响: 管理后台「团期人员配置」页面与「团期核单」按团看人的名册展示
- 零影响:
- 订单侧人员分配(仍独立维护
order_staff_assignment(source=ORDER)) - 车务派车流程(按 fleet 业务正常发车、自动投影)
- 人员候选列表查询(
GET .../staff/candidates)的返回格式 - 团期总体状态、成团判断、团期改期逻辑
- 后端其他模块对人员表的现有查询(如财务核对)
- 订单侧人员分配(仍独立维护
八、测试环境已验证
以 productBatchId=80001(groupBatchId=90211)为例,真实接口测试:
PUT /v3/admin/group-batch/80001/staff
Request: scopeRoles=["GUIDE","LEADER"], staffList=[{staffId:40001, staffRole:"LEADER"}]
→ 200 OK ✓
GET /v3/admin/group-batch/80001/staff
→ 200 OK,staffList 含导游位成员及系统投影司机行 ✓
PUT /v3/admin/group-batch/80001/staff
Request: staffList=[{staffId:40001, staffRole:"DRIVER"}]
→ 拒绝 582120(司机由车务派车自动带入...)✓
PUT /v3/admin/group-batch/80001/staff
Request: scopeRoles=["GUIDE"], staffList=[...](缺 LEADER)
→ 拒绝 582116(导游位成员缺失...)✓
九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #8669 | #8653, #8654 | 本次变更:司机投影落地,DRIVER 改系统管理 | ✅ 最新 |
十、相关文档
- 关联 Issue: #8653, #8654
- 关联 PR: #8669
- Merge commit: 5c50782717d6
关联 / 联系人
链接
- Issue: #8653 / #8654
- PR: #8669
- Merge commit: 5c50782717d6
联系人
- 后端负责人: @wx