diff --git a/changelogs-v2/2026-09/22_8006_团期人员保存支持按角色范围覆盖-修改接口-管理后台.md b/changelogs-v2/2026-09/22_8006_团期人员保存支持按角色范围覆盖-修改接口-管理后台.md new file mode 100644 index 00000000..9a9f2557 --- /dev/null +++ b/changelogs-v2/2026-09/22_8006_团期人员保存支持按角色范围覆盖-修改接口-管理后台.md @@ -0,0 +1,323 @@ +--- +schema: "hl-changelog/v2" +ticket: "8006" +title: "团期 staff 保存新增可选入参 scopeRoles,支持按角色范围覆盖" +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: "backend_status=deployed: hl-order-service-v3 测试服部署 sha=7811104b4,是本单合并提交 094521f0b(PR #8117)的后代,且区间内零提交触及 GroupBatchStaffConfigService(该核验由本工单此前 AC 完成,本会话直接引用,未重新验证)。gateway_status=not_required: git show 094521f0b --stat --name-only 核对,本单改动的 9 个文件全在 hl-order-service-v3 模块内,未涉及 hl-gateway 任何路由/Nacos 配置;PUT /v3/admin/group-batch/{productBatchId}/staff 是存量端点、路径本身未变,命中的是既有通配路由 order-service-v3(predicates: Path=/v3/admin/**,hl-gateway/src/main/resources/application.yml:223,自 #3264 起生效),本单没有引入任何新路径段。frontend_status=pending: mmg/hl-ui 的 origin/v2.1 分支(sha b5666b38,2026-09-22 03:37)src/api/orderV2GroupBatch.js:298 的 saveGroupBatchStaff(groupBatchId, staffList, scopeRoles, config) 已带该参数、GroupBatchStaffConfigModal.vue:256 与 __tests__/GroupBatchStaffConfigModal.spec.js:143 已对接并有断言——这是源码事实,不等于该分支已合并进前端主干或已部署到某个可联调环境;按规范后端不代填 implemented/released/verified,是否完成需前端自行回写 frontend_owner 与 verified_at。" +updated_at: "2026-09-22" +base: "dev-v3" +--- + +# order-v3: 团期 staff 保存新增可选入参 scopeRoles,支持按角色范围覆盖 + +> **存放目录**: `changelogs-v2/2026-09/` +> +> **服务**: hl-order-service-v3 (端口 8006) +> **PR**: #8117 +> **Issue**: #8006 +> **日期**: 2026-09-22 +> **影响范围**: 团期 staff 配置保存接口 `PUT /v3/admin/group-batch/{productBatchId}/staff` + +--- + +## ⚠️ 关键变化 + +`PUT /v3/admin/group-batch/{productBatchId}/staff` 新增**可选**请求体字段 `scopeRoles`(角色范围数组)。**不传时行为与改前逐字一致**(整期全量覆盖:请求体 `staffList` 即为该团期的最终名单,未包含的人员会被移除)。 + +这不是一个纯粹的新增字段——它同时改变了写入行为的语义边界:团期 staff 实际按**配置位**分两个弹窗维护(导游位 = GUIDE + LEADER、摄影位 = PHOTOGRAPHER)。改前只有"整期全量覆盖"一种写法,任一侧保存时若没有把另一侧的既有行原样带回来,另一侧的人就会被静默清空。传入 `scopeRoles` 后,保存只在声明的角色范围内覆盖,范围外的既有行一行不动。 + +--- + +## 一、背景 + +`order_batch_staff` 表原先的保存语义是"整期全删再全插":每次 `PUT` 都把该团期名下所有角色的既有行软删,再按请求体重建。团期 staff 页面按配置位分两个弹窗各自维护(导游位 / 摄影位),这就要求任一侧保存时都必须把另一侧的既有行原样带回请求体——漏带一次,另一侧的人就被清空且没有任何提示(工单 #8006 描述的缺陷)。 + +本单给保存接口加了一条可选的"范围覆盖"路径:传 `scopeRoles` 只软删并重建这些角色的行,其余角色的行不受影响;不传则保持改前的整期全删语义,零行为变化。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 保存团期 staff 配置 | PUT | `/v3/admin/group-batch/{productBatchId}/staff` | 请求体新增可选字段 | 新增 `scopeRoles`(角色范围覆盖),未传时行为不变 | + +--- + +## 三、接口详情 + +### 1. 保存团期 staff 配置(含扇出) `PUT /v3/admin/group-batch/{productBatchId}/staff` + +**VO**: `BatchStaffConfigReqVO → BatchStaffConfigRespVO` + +#### 使用场景 + +团期详情页"导游位""摄影位"两个弹窗各自维护本位人员时调用。改前两个弹窗必须各自把对方弹窗的既有人员原样带回请求体,否则会被整期全删覆盖清空;本次新增 `scopeRoles` 后,每个弹窗可以只声明并提交自己负责的角色范围,互不影响。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| productBatchId | Path | Long | ✅ | 雪花 ID | 产品侧排期 ID(`group_tour_batch.batch_id`,非运营团期主键) | +| scopeRoles | Body | List\ | ❌(**新增**) | 传了不能是空数组(空数组 400);元素取值域同 `staffList[].staffRole` | 本次保存覆盖的角色范围。**不传 = 整期全量覆盖**(历史行为,团期内所有角色的既有配置先全删再按 `staffList` 重建);传了则只在这些角色内覆盖,范围外角色的既有行一行不动。**导游位必须同时传 `GUIDE` 与 `LEADER`**——导游位并收这两个角色,只传其中一个而选了另一个会被拒(582115)。`staffList` 里出现范围外角色一律拒绝且**零写入**(582115) | +| staffList | Body | List\ | ✅ | 缺失 400;显式传 `[]` 即清空覆盖范围内的配置 | 覆盖范围内的最终状态。不传 `scopeRoles` 时 `[]` = 清空整期;传了 `scopeRoles` 时 `[]` = 只清空这些角色 | +| staffList[].staffId | Body | Long | ✅ | - | 用户域员工 ID | +| staffList[].staffRole | Body | String | ✅ | 取值域:`LEADER`/`GUIDE`/`DRIVER`/`PHOTOGRAPHER`/`OTHER`/`GUIDE_ASSISTANT`/`STUDY_TEACHER`/`LIFE_TEACHER` | 员工角色。若传了 `scopeRoles`,本字段取值必须落在 `scopeRoles` 声明的范围内 | +| staffList[].sortOrder | Body | Integer | ❌ | 默认 0 | 展示排序 | +| staffList[].remark | Body | String | ❌ | ≤500 字符 | 备注 | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| data.productBatchId | String | 产品侧排期 ID(回显路径参数,雪花号以字符串返回) | +| data.groupBatchId | String | 运营团期 ID(`order_group_batch` 主键,由 `productBatchId` 反查),本端点 200 响应中恒非 null | +| data.staffList | Array | **保存后的整期最终状态**(不是本次提交的子集)。不传 `scopeRoles` 时它就是本次提交的名单;传了 `scopeRoles` 时它是覆盖后的整期全量——范围保存的调用方一次拿到完整名单,无需再补一次 GET | +| data.staffList[].id | String | 记录 ID(雪花号,字符串返回) | +| data.staffList[].staffId | String | 用户域员工 ID | +| data.staffList[].staffRole | String | 员工角色(**导游位落库时会按人员真实类型在 GUIDE/LEADER 之间归一**,见四节) | +| data.staffList[].staffRoleName | String | 员工角色中文名 | +| data.staffList[].staffName | String | 员工姓名(快照) | +| data.staffList[].staffPhone | String | 员工手机(脱敏,前 3 后 4) | +| data.staffList[].avatarUrl | String | 头像 URL(快照,可为 null) | +| data.staffList[].sortOrder | Integer | 展示排序 | +| data.staffList[].remark | String | 备注 | +| data.staffList[].reporterRank | String | 报账人等级:`PRIMARY`/`SECONDARY`/`NONE`,历史空值归一为 `NONE`,恒非 null | +| data.staffList[].reporterRankName | String | 报账人等级中文名 | +| data.affectedOrderCount | Integer | 扇出影响的活跃订单数 | + +#### 请求示例 + +```json +{ + "scopeRoles": ["PHOTOGRAPHER"], + "staffList": [ + { "staffId": 40002, "staffRole": "PHOTOGRAPHER", "sortOrder": 2, "remark": "新摄影备注" } + ] +} +``` + +字段值取自本单已合并的真库集成测试 `GroupBatchStaffSaveConfigScopedMysqlTest#scopedToPhotographer_keepsGuideAndDriverRowsAlive`(该团期夹具此前已有 `staffId=1(LEADER)`、`staffId=4(DRIVER)` 两行存量配置)。 + +#### 响应示例 + +按上述请求提交后,该团期同时存在的存量导游位(LEADER)、司机行与本次提交的摄影位行全部回显在 `staffList`(真库断言:`aliveBatchRows(BATCH)` 返回 `["1:LEADER","4:DRIVER","40002:PHOTOGRAPHER"]`,`affectedOrderCount` 取自夹具里挂在该团期下的活跃订单数=1): + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "productBatchId": "8006900", + "groupBatchId": "80106", + "staffList": [ + { "id": "9001", "staffId": "1", "staffRole": "LEADER", "staffRoleName": "领队", "staffName": "存量领队", "staffPhone": "138****0001", "avatarUrl": null, "sortOrder": 1, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人" }, + { "id": "770002", "staffId": "40002", "staffRole": "PHOTOGRAPHER", "staffRoleName": "摄影", "staffName": "新摄影", "staffPhone": "139****0002", "avatarUrl": "https://cdn.example/avatar/40002.png", "sortOrder": 2, "remark": "新摄影备注", "reporterRank": "NONE", "reporterRankName": "非报账人" }, + { "id": "9003", "staffId": "4", "staffRole": "DRIVER", "staffRoleName": "司机", "staffName": "存量司机", "staffPhone": "138****0004", "avatarUrl": null, "sortOrder": 3, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人" } + ], + "affectedOrderCount": 1 + } +} +``` + +⚠️ 本例按 `BatchStaffConfigRespVO` 字段映射规则重新组装:`id="9001"`/`"9003"` 与除 `avatarUrl`/`remark` 外的其余字段取自该 IT 真实夹具与断言,`staffPhone` 按 `SensitiveDataUtil.maskPhone`(前 3 后 4)逐字计算;新增行的 `id="770002"` 是服务端写入时生成的雪花号,该 IT 只断言了 `staffId:staffRole` 组合、未断言具体 ID 值,本例按字段真实格式(字符串化雪花号)给出示意值,不代表某次具体运行的原始输出。整份响应信封本身未经网关实测捕获(本会话未做该端点的真实网关调用,详见八节)。 + +#### 空数据 / 降级响应 + +`scopeRoles` 传了但 `staffList` 传空数组 `[]`:清空该角色范围内的配置,范围外行不受影响。 + +```json +{ + "code": 200, + "message": "成功", + "success": true, + "data": { + "productBatchId": "8006900", + "groupBatchId": "80106", + "staffList": [ + { "id": "9001", "staffId": "1", "staffRole": "LEADER", "staffRoleName": "领队", "staffName": "存量领队", "staffPhone": "138****0001", "avatarUrl": null, "sortOrder": 1, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人" }, + { "id": "9003", "staffId": "4", "staffRole": "DRIVER", "staffRoleName": "司机", "staffName": "存量司机", "staffPhone": "138****0004", "avatarUrl": null, "sortOrder": 3, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人" } + ], + "affectedOrderCount": 1 + } +} +``` + +#### 错误响应 + +`scopeRoles=["GUIDE"]`,但 `staffList` 里提交了一名真实类型为 `LEADER` 的人员(导游位漏传 `LEADER`): + +```json +{ + "code": 582115, + "message": "提交的人员角色(LEADER)超出本次保存声明的角色范围(GUIDE),请检查配置位与人员是否匹配", + "success": false, + "data": null +} +``` + +`scopeRoles` 传空数组 `[]`: + +```json +{ + "code": 400, + "message": "scopeRoles 传了就不能是空数组;要整期全量覆盖请整个字段不传", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- **权限**:网关 `/v3/admin/**` 统一鉴权;本端点额外要求 `GroupBatchPermissionGuard.PERMISSION_MANAGE`(#7455),无权限一律拒绝且**零写入** +- **不传 `scopeRoles`**:行为与改前逐字一致,整期全量覆盖,现有前端代码零改动即可继续工作 +- **传 `scopeRoles` 但为空数组**:400(`@Size(min=1)` 校验),清空语义只能由 `staffList=[]` 表达,不能用空 `scopeRoles` 表达 +- **导游位并收 GUIDE + LEADER**:`scopeRoles` 只传其中一个、`staffList` 里出现了另一个,一律 582115 且**零写入**(连软删都不执行) +- **`staffList` 里出现范围外角色**:一律 582115,零写入(两道守卫:一道按请求声明的角色查,另一道按落库后解析出的真实角色再查一遍——导游位人员的 `staffRole` 会按其在资源域的真实类型在 `GUIDE`/`LEADER` 之间归一,只查第一道会漏掉这条) +- **团期未成团**:589552(已成团但未建团 589553);两个错误码在本次改动前就存在,未受影响 +- **`staffId` 重复**:589582,整批不写库(改前既有校验,未受影响) + +--- + +## 四、契约约束与正确调用方式 + +### ✅ 正确 / ❌ 错误 payload 对照 + +| 场景 | payload | +|------|---------| +| ✅ 不传 scopeRoles,整期全量覆盖(历史行为) | `{ "staffList": [...] }` | +| ✅ 只覆盖摄影位 | `{ "scopeRoles": ["PHOTOGRAPHER"], "staffList": [{ "staffId": 40002, "staffRole": "PHOTOGRAPHER" }] }` | +| ✅ 只覆盖导游位(必须两个角色一起声明) | `{ "scopeRoles": ["GUIDE", "LEADER"], "staffList": [{ "staffId": 1, "staffRole": "LEADER" }] }` | +| ✅ 清空摄影位(不动导游位/司机) | `{ "scopeRoles": ["PHOTOGRAPHER"], "staffList": [] }` | +| ❌ 导游位只传 GUIDE,人员却是 LEADER | `{ "scopeRoles": ["GUIDE"], "staffList": [{ "staffId": 1, "staffRole": "LEADER" }] }` → 582115 | +| ❌ scopeRoles 传空数组 | `{ "scopeRoles": [], "staffList": [] }` → 400 | +| ❌ staffList 缺失 | `{ "scopeRoles": ["PHOTOGRAPHER"] }` → 400 | + +### 切换到范围保存时的必要动作 + +若前端按配置位拆分弹窗各自保存,每个弹窗提交时都必须传 `scopeRoles`,且**导游位弹窗必须同时把 `GUIDE` 与 `LEADER` 放进 `scopeRoles`**(哪怕本次提交的人员只有其中一种真实类型)——否则另一种类型的存量人员会在下一次同角色保存时找不到删除窗口,变成无法再被删除的幽灵行。两个弹窗各自只传自己的 `scopeRoles`,不需要互相携带对方的既有名单。 + +--- + +## 五、数据库行为 + +| 场景 | 数据库表 | 操作 | +|------|----------|------| +| 不传 scopeRoles(整期全量覆盖) | `order_batch_staff` | 软删该团期全部角色的旧行,再批量 INSERT 新行(与改前逐字一致) | +| 传 scopeRoles(范围覆盖) | `order_batch_staff` | 只软删 `scopeRoles` 命中角色的旧行,范围外角色的旧行不执行任何 UPDATE/DELETE | +| 保存成功后 | `order_staff_assignment` | 异步扇出:对团内每个活跃订单,软删该订单 `source=GROUP_BATCH` 的旧行、按**保存后的整期最终状态**重新插入(范围保存时扇出的不是本次提交的子集,而是覆盖后的整期全量,避免把范围内的清空误传播为整期清空) | +| 校验失败(582115 / 400 等) | - | 零写入:不软删、不 INSERT、不触发扇出 | + +--- + +## 六、边界行为 + +- **未登录** → 401(网关拦截) +- **无 `PERMISSION_MANAGE` 权限** → 拒绝,零写入 +- **团期未建团** → 589553;**已建团未成团** → 589552(本次改动前已有校验,未变) +- **`staffId` 在请求体内重复** → 589582,整批零写入(本次改动前已有校验,未变) +- **人员角色与资源域真实类型不符**(如把领队配成摄影)→ 582114(本次改动前已有校验,未变) +- **提交角色超出 scopeRoles 声明范围** → 582115,零写入(本次新增) +- **老数据兼容**:历史保存请求(不含 `scopeRoles` 字段)解析后 `scopeRoles=null`,服务端按整期全量覆盖处理,与改前行为逐字一致 + +--- + +## 六.5、枚举 / 数据字典 + +### scopeRoles / staffList[].staffRole(`SettlementStaffRoleEnum`) + +**所属字段**: `BatchStaffConfigReqVO.scopeRoles`、`BatchStaffConfigReqVO.staffList[].staffRole` | **类型**: `String` / `List` + +| 值 | 中文 | 说明 | +|----|------|------| +| `LEADER` | 领队 | 导游位成员之一 | +| `GUIDE` | 导游 | 导游位成员之一。落库时若该人员资源域真实类型为 `LEADER`,会归一存成 `LEADER` | +| `DRIVER` | 司机 | 由车务派车投影产生,一般不由本接口写入;不属于任何配置位,不受 scopeRoles 范围校验约束 | +| `PHOTOGRAPHER` | 摄影 | 摄影位唯一成员 | +| `OTHER` | 其他 | 兜底位;不属于任何配置位,不受 scopeRoles 范围校验约束 | +| `GUIDE_ASSISTANT` | 导游助理 | 不属于导游位/摄影位任一配置位 | +| `STUDY_TEACHER` | 研学老师 | 不属于导游位/摄影位任一配置位 | +| `LIFE_TEACHER` | 生活老师 | 不属于导游位/摄影位任一配置位 | + +--- + +## 六.6、修改前后对比 + +### 字段级对比 + +| 字段 | 改前 | 改后 | +|------|------|------| +| `staffList`(请求体) | 唯一入参,传入即为整期最终状态 | 不变 | +| `scopeRoles`(请求体) | 不存在 | **新增可选字段**;不传行为与改前逐字一致 | + +### 行为级对比 + +| 行为 | 改前 | 改后 | +|------|------|------| +| 保存范围 | 恒为整期全量覆盖 | 不传 scopeRoles=整期全量覆盖(不变);传了 scopeRoles=只覆盖声明的角色 | +| 跨配置位清空风险 | 任一弹窗保存漏带另一侧既有行 → 另一侧被静默清空 | 各弹窗传各自的 scopeRoles 即可互不影响,不再需要互相携带对方名单 | +| 提交角色超出范围时 | 无此校验(scopeRoles 不存在) | 582115,零写入 | +| 扇出快照 | 用的是本次提交的 toInsert | 不传 scopeRoles 时二者相同;传了 scopeRoles 时改为用保存后的**整期最终状态**扇出 | + +--- + +## 六.7、影响评估 + +- **是否破坏向后兼容**:否。`scopeRoles` 是纯新增可选字段,不传时请求/响应结构与既有行为逐字不变,历史前端代码零改动即可继续工作。 +- **前端是否必须同步上线**:否(不采用范围保存可以继续用旧写法);**但**若要修复"两个弹窗互相清空对方"的问题,前端必须改为按配置位分别传 `scopeRoles`,且导游位弹窗必须把 `GUIDE` 与 `LEADER` 一起传入。 +- **前端 workaround 清理点**:若前端此前用"保存前先 GET 回另一侧既有名单、拼进本次 staffList 一起提交"的方式规避跨配置位清空问题,改用 `scopeRoles` 后可以去掉这个 workaround,弹窗只需提交本位人员。 + +--- + +## 七、不影响范围 + +- **仅影响**:`PUT /v3/admin/group-batch/{productBatchId}/staff` 一个端点的请求体解析与保存范围 +- **零影响**: + - `GET /v3/admin/group-batch/{productBatchId}/staff`(查询团期 staff 配置列表)请求/响应结构 + - `GET /v3/admin/group-batch/{productBatchId}/staff/candidates`、`.../staff/candidates/page`(候选列表/候选分页) + - `PUT /v3/admin/group-batch/{productBatchId}/staff/{staffId}/reporter-rank`(设置报账人等级) + - 订单侧 staff 增删改接口(`source=ORDER` 的行本次改动前后均不受团期保存的删除窗口影响) + - 团期成团判定、四 ready 闸门的判定条件本身(仅回填时改用整期最终状态计算,判定逻辑未变) + +--- + +## 八、测试环境已验证 + +**后端部署**:`hl-order-service-v3` 测试服部署 sha `7811104b4`,是本单合并提交 `094521f0b`(PR #8117)的后代,且区间内零提交触及 `GroupBatchStaffConfigService`。该核验由本工单此前 AC 完成,本会话直接引用、未重新验证(如实披露)。 + +**真库集成测试**(`GroupBatchStaffSaveConfigScopedMysqlTest` / `GroupBatchStaffSaveConfigBaselineMysqlTest`,均为 PR #8131 新增,已合入 dev-v3;MySQL 8.0.33 Testcontainers + 真 MyBatis-Plus + 真 `GroupBatchStaffConfigService` Bean,非纯 Mockito): + +- `scopedToPhotographer_keepsGuideAndDriverRowsAlive`:同一份请求体只提交摄影位一人 + `scopeRoles=["PHOTOGRAPHER"]`,导游位(LEADER)与司机的存量行在保存后仍然存活(`deleted_at IS NULL`),订单侧 `order_staff_assignment` 的 `GROUP_BATCH` 副本也保留了这两行;`ready` 回填按整期最终状态计算,触发 `markGuideReady`(因为导游位仍有人)。 +- 阳性对照(Baseline 类同一请求体、不传 `scopeRoles`):同样的存量导游位/司机行**被整期全删**,`ready` 回填触发的是 `markGuideNotReady`——两轮唯一差异是 `scopeRoles` 字段,终态却完全相反,证明这套断言组合对"范围覆盖是否生效"具备分辨力,不是恒真结论。 +- `BatchStaffConfigReqVOValidationTest`(Bean Validation 层,@Valid 触发路径同 Controller):`scopeRoles` 不传合法;传合法角色集合通过;传空数组 `[]` 被拒,违规信息命中 `scopeRoles` 字段、消息含"不能是空数组";传入取值域外的角色被拒,消息含"不在员工角色取值域内"。 + +**受限说明**:本会话本次未对该端点执行真实网关调用(无该操作所需的测试服管理员会话),三节的请求/响应示例按 `BatchStaffConfigReqVO`/`BatchStaffConfigRespVO` 字段映射规则、依据上述真库 IT 的夹具与断言重新组装,已在三节逐条标注哪些字段取自真实断言、哪些是格式示意值。 + +--- + +## 十、相关文档 + +- 关联 Issue: [wx/HL#8006](https://git.1814.love:8443/wx/HL/issues/8006) +- 关联 PR: [wx/HL#8117](https://git.1814.love:8443/wx/HL/pulls/8117)(后端修复实现)、[wx/HL#8131](https://git.1814.love:8443/wx/HL/pulls/8131)(补充真库集成测试) + +## 关联 / 联系人 + +### 链接 + +- **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