docs: 团期 staff 保存 scopeRoles 元素级非空校验交接件(#8150)
覆盖 bfc10966e(PR #8177)对 BatchStaffConfigReqVO.scopeRoles 加的元素级
@NotBlank,两条对外可见变化:
1. [null] 由「HTTP 200 静默空操作」改为 code:400。旧行为是静默失败——
@Pattern 对 null 恒为 true 让它穿过校验层,整位守卫 touched 恒 false
不抛,删除窗口 {null} 命中 0 行、插入集合为空,于是库里一行没动却返 200。
2. [""] / [" "] 的 message 由一条变两条以 "; " 拼接。两条约束并列触发、
不是后者替换前者;拼接顺序不保证,已在正文写明前端不得按顺序或按精确
相等解析 message。
证据:网关活体实测四组(含一组阴性对照,证明 400 不是端点无差别返回),
部署三条独立判据(merge-base 祖先关系 + jar mtime + 活体行为)。
覆盖边界:本份只覆盖 scopeRoles 字段的入参校验契约。
顺带记一处已知缺口:Swagger 请求侧 staffList[].staffRole 的取值域文案只列
了 5 个角色,实际 @Pattern 放行 8 个(后 3 个由 #7079 引入),正文第六.5 节
以 STAFF_ROLE_PATTERN 常量为准写全。
Refs #8150
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
@@ -0,0 +1,387 @@
|
|||||||
|
---
|
||||||
|
schema: "hl-changelog/v2"
|
||||||
|
ticket: "8150"
|
||||||
|
title: "团期 staff 保存:scopeRoles 元素级非空校验,[null] 由静默 200 空操作改为 400 拒绝"
|
||||||
|
consumer: "admin"
|
||||||
|
author: "wx(GIT)"
|
||||||
|
change_type: "修改接口"
|
||||||
|
backend_status: "deployed"
|
||||||
|
gateway_status: "not_required"
|
||||||
|
frontend_status: "not_required"
|
||||||
|
target_release: "v2.1"
|
||||||
|
status_note: "backend_status=deployed:测试服 hl-order-service-v3 双实例(8086 / 8186)当前运行 sha=843de6401。本单修复提交 bfc10966e 不是该 sha 本身而是它的祖先,故用两条互相独立的判据:①git merge-base --is-ancestor bfc10966e 843de6401 → 退出码 0(是祖先),且 deploy-status.sh 登记 dev-v3 / 843de6401 / BEHIND=0/N;②jar mtime 2026-09-22 18:55:47,晚于 bfc10966e 的提交时刻 2026-09-22 15:24:50 +0800。两条之外还有第三条活体判据:本单契约在 18:5x 经网关实测四组请求取证(见第八节),其中三组的返回值在改动前不可能出现。gateway_status=not_required:本单零网关改动。PUT /v3/admin/group-batch/{productBatchId}/staff 是存量端点、路径未变,命中既有通配路由 Path=/v3/admin/**,未引入任何新路径段,也未改 hl-gateway 的路由或 Nacos 配置。frontend_status=not_required:本单收紧的是一个前端目前不会构造的取值。判据——对 mmg/hl-ui origin/v2.1 的调用方 grep,scopeRoles 一律由 roleMeta.memberRoles 这类字面量常量数组提供(GroupBatchStaffConfigModal.vue:153-154 写死 ['GUIDE','LEADER'] 与 ['PHOTOGRAPHER']),无任何一处会产出 null / 空串 / 纯空白元素;对 [\"\"] 与 [\" \"] 本单只改 message 文案长度、不改 code。⚠️ 覆盖边界:这是「现有实现不会命中」,不是「将来也不会」——若前端改为由下拉或接口回填拼 scopeRoles,未选中的空值会直接命中本单的 400,请按第四节的契约处理。"
|
||||||
|
updated_at: "2026-09-22"
|
||||||
|
base: "dev-v3"
|
||||||
|
---
|
||||||
|
|
||||||
|
# order-v3: 团期 staff 保存,scopeRoles 元素级非空校验(#8150)
|
||||||
|
|
||||||
|
> **存放目录**: `changelogs-v2/2026-09/`
|
||||||
|
>
|
||||||
|
> **服务**: hl-order-service-v3 (端口 8086 / 8186)
|
||||||
|
> **PR**: #8177
|
||||||
|
> **Issue**: #8150
|
||||||
|
> **日期**: 2026-09-22
|
||||||
|
> **影响范围**: 团期 staff 配置保存接口 `PUT /v3/admin/group-batch/{productBatchId}/staff` 的 `scopeRoles` 字段入参校验
|
||||||
|
|
||||||
|
> **📐 本份的覆盖边界**:本份只覆盖 `scopeRoles` 字段的**入参校验契约**。同端点的其他契约(`staffList` 语义、582115 / 582116 的判定规则、扇出行为)本单一律未改,见第七节与第十节列出的既有交接件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 关键变化
|
||||||
|
|
||||||
|
### 变化 1:`scopeRoles: [null]` 从「HTTP 200 静默空操作」变成 `code: 400`
|
||||||
|
|
||||||
|
这是本单的主体。改动前,`scopeRoles` 里混进一个 `null` 元素,接口会返回一个**和保存成功长得一模一样的 200**,而库里一行都没动。
|
||||||
|
|
||||||
|
为什么旧行为是静默失败,四步连起来看:
|
||||||
|
|
||||||
|
1. **JSR-380 规定 `@Pattern` 对 `null` 恒为 true**,所以 `[null]` 整条走完了参数校验,一个违约都不产生;
|
||||||
|
2. 它非 null 非空(**数组本身**非空,`@Size(min = 1)` 也过),整位守卫(582116)的短路条件不成立,不抛;
|
||||||
|
3. 但 `null` 这个元素一个配置位都 `contains` 不到 ⇒ 整位守卫内部的 `touched` 恒为 false,**也不抛**;
|
||||||
|
4. `scopeRoles` 同时是删除窗口 —— 窗口 `{null}` 删不到任何行;插入集合同时为空 ⇒ **库里一行没动,接口返 200**。
|
||||||
|
|
||||||
|
调用方拿到的不是错误,是一个空操作,**没有任何错误码提示它写错了**。这不是"写脏数据",是静默失败:前端以为保存成功、页面刷新后配置没变,而后端日志里也没有异常。
|
||||||
|
|
||||||
|
改动后返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"code":400,"message":"scopeRoles 的元素不能为空","data":null,"traceId":null,"success":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 变化 2:`scopeRoles: [""]` / `[" "]` 的 `message` 从一条变成两条拼接
|
||||||
|
|
||||||
|
这两个取值**改动前就已经是 400**(`@Pattern` 是容器元素约束,空串与纯空白匹配不上取值域),本单没有改变它们的 `code`。变的是 `message`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"code":400,"message":"scopeRoles 取值不在员工角色取值域内; scopeRoles 的元素不能为空","data":null,"traceId":null,"success":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
🔴 **两条约束是并列触发、不是后者替换前者**:`@NotBlank` 与 `@Pattern` 对空串同时失败,`GlobalExceptionHandler` 把多条 Bean Validation 违约的 message 用 `"; "` 拼接成一条字符串返回。
|
||||||
|
|
||||||
|
🔴 **拼接顺序不保证,前端不得按顺序解析、也不得按精确相等匹配 `message`**。Bean Validation 规范不保证同一字段上多个约束的执行顺序,上面示例里 `@Pattern` 在前只是本轮实现的偶然结果,换一个 Hibernate Validator 版本就可能颠倒。需要判定失败原因时:
|
||||||
|
|
||||||
|
- 判「是不是入参校验失败」→ 看 `code == 400`;
|
||||||
|
- 判「是哪个字段」→ 用 `message.contains("scopeRoles")`,不要用 `==`;
|
||||||
|
- 展示给用户 → 整串原样展示即可,它本身是可读的中文。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
`scopeRoles` 是 #8006 引入的**部分覆盖开关**:不传 = 整期全量覆盖(历史行为),传了则只在这些角色内覆盖、范围外的既有行一行不动。它同时是**删除窗口** —— 漏声明的同位行既不会被删也不会被重建。
|
||||||
|
|
||||||
|
正因为它兼任删除窗口,一个取不到任何配置位的 `scopeRoles` 会让整次保存退化成空操作。而字段原本只加了元素级 `@Pattern`,JSR-380 明文规定 `@Pattern` 对 `null` 恒为 true,于是 `[null]` 是唯一一个能穿过校验层、又在业务层什么都不做、还返 200 的取值。
|
||||||
|
|
||||||
|
本单补上元素级 `@NotBlank`,把这个洞封掉。选 `@NotBlank` 而不是 `@NotNull` 的理由在第六.6 节的对比表里 —— 不是为了可达性(`@NotNull` 同样能救回 `[null]`),是为了让空串与纯空白也拿到一条**点名"为空"**的文案,而不是只被告知"取值不在取值域内"。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、变更接口清单
|
||||||
|
|
||||||
|
| 方法 | 路径 | 变更类型 | 本单改了什么 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `PUT` | `/v3/admin/group-batch/{productBatchId}/staff` | 入参校验收紧 | `scopeRoles` 增加元素级 `@NotBlank`;`code`/响应结构/业务语义均未变 |
|
||||||
|
|
||||||
|
**未新增、未删除、未改名任何端点。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、接口详情
|
||||||
|
|
||||||
|
### 1. 保存团期 staff 配置(含扇出) `PUT /v3/admin/group-batch/{productBatchId}/staff`
|
||||||
|
|
||||||
|
**请求 VO**:`BatchStaffConfigReqVO`
|
||||||
|
**响应 VO**:`Result<BatchStaffConfigRespVO>`
|
||||||
|
**权限**:`GroupBatchPermissionGuard.PERMISSION_MANAGE`
|
||||||
|
|
||||||
|
#### 使用场景
|
||||||
|
|
||||||
|
团期详情页「配导游 / 配摄影」弹窗保存。传入列表即为**覆盖范围内**的最终状态,保存成功后异步扇出到团内所有活跃订单的 `order_staff_assignment`(`source=GROUP_BATCH`)。
|
||||||
|
|
||||||
|
#### 路径参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `productBatchId` | Long | 是 | **产品侧排期 ID**(`group_tour_batch.batch_id`),不是运营团期主键 `order_group_batch.group_batch_id`。两者 1:1 但值不同,传错不会报错、只会走不到团期 |
|
||||||
|
|
||||||
|
#### 请求参数
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 本单是否改动 | 说明 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `scopeRoles` | `List<String>` | 否 | **是(元素级校验)** | 本次保存覆盖的角色范围。**不传 = 整期全量覆盖**(历史行为);传了则只在这些角色内覆盖。取值域见第六.5 节。**数组不能为空数组**(`@Size(min = 1)`,清空语义只由 `staffList` 表达);🆕 **元素不能是 `null` / 空串 / 纯空白** |
|
||||||
|
| `staffList` | `List<Item>` | **是** | 否 | 覆盖范围内的最终状态。**显式传 `[]` 即清空该范围**。缺字段或字段名拼错一律 400(审计 F-03,Refs #6950 / #7377) |
|
||||||
|
| `staffList[].staffId` | Long | 是 | 否 | 用户域员工 ID |
|
||||||
|
| `staffList[].staffRole` | String | 是 | 否 | 员工角色,取值域见第六.5 节 |
|
||||||
|
| `staffList[].sortOrder` | Integer | 否 | 否 | 展示排序,默认 0 |
|
||||||
|
| `staffList[].remark` | String | 否 | 否 | 备注,≤500 字 |
|
||||||
|
|
||||||
|
#### 响应参数(本单未改动,列出供对照)
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `productBatchId` | String(Long 序列化为字符串) | 回显路径参数,恒非 null |
|
||||||
|
| `groupBatchId` | String(Long 序列化为字符串) | 运营团期 ID,由 `productBatchId` 反查。**本端点的 200 响应里恒非 null**(入口第一道守卫就是成团校验) |
|
||||||
|
| `staffList` | Array | 保存后的团期 staff 配置快照 |
|
||||||
|
| `staffList[].id` | Long | 记录 ID(`batch_staff_id`) |
|
||||||
|
| `staffList[].staffId` | Long | 用户域员工 ID |
|
||||||
|
| `staffList[].staffRole` | String | 员工角色 code |
|
||||||
|
| `staffList[].staffRoleName` | String | 角色中文名;`staffRole` 为 null 时为 null,取值不在枚举内时回落原 code |
|
||||||
|
| `staffList[].staffName` | String | 员工姓名(快照) |
|
||||||
|
| `staffList[].staffPhone` | String | 手机号,脱敏前 3 后 4 |
|
||||||
|
| `staffList[].avatarUrl` | String | 头像 URL(快照) |
|
||||||
|
| `staffList[].sortOrder` | Integer | 展示排序 |
|
||||||
|
| `staffList[].remark` | String | 备注 |
|
||||||
|
| `staffList[].reporterRank` | String | 报账人等级 `PRIMARY` / `SECONDARY` / `NONE`,空值归一为 `NONE`,恒非 null |
|
||||||
|
| `staffList[].reporterRankName` | String | 报账人等级中文名,与 `reporterRank` 同生同灭 |
|
||||||
|
| `affectedOrderCount` | int | 扇出影响的订单数(已触发异步写入的活跃订单数) |
|
||||||
|
|
||||||
|
#### 请求示例(正确用法,本单未改变它的行为)
|
||||||
|
|
||||||
|
```json
|
||||||
|
PUT /v3/admin/group-batch/80001/staff
|
||||||
|
|
||||||
|
{
|
||||||
|
"scopeRoles": ["GUIDE", "LEADER"],
|
||||||
|
"staffList": [
|
||||||
|
{"staffId": 40001, "staffRole": "LEADER", "sortOrder": 0, "remark": "首席领队"},
|
||||||
|
{"staffId": 40002, "staffRole": "GUIDE", "sortOrder": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 错误响应
|
||||||
|
|
||||||
|
| code | 触发条件 | 本单是否改动 |
|
||||||
|
|---|---|---|
|
||||||
|
| `400` | `scopeRoles` 元素为 `null` | 🆕 **本单新增**(改前是 200 静默空操作) |
|
||||||
|
| `400` | `scopeRoles` 元素为空串 / 纯空白 | 改前已是 400,本单只让 `message` 多带一条 |
|
||||||
|
| `400` | `scopeRoles` 传了 `[]`(空数组) | 否(`@Size(min = 1)`) |
|
||||||
|
| `400` | `scopeRoles` 元素不在取值域内 | 否 |
|
||||||
|
| `400` | 缺 `staffList` 字段 | 否 |
|
||||||
|
| `582115` | `staffList` 里出现 `scopeRoles` 范围外的角色 | 否,且**零写入** |
|
||||||
|
| `582116` | `scopeRoles` 只声明了半个配置位(如只传 `GUIDE` 不传 `LEADER`) | 否,且**零写入** |
|
||||||
|
| `589552` / `589553` | 团期未建团 / 未成团或已流团 | 否 |
|
||||||
|
|
||||||
|
🔴 **HTTP 状态行恒为 200,`body.code` 才是真相**。本服务的业务失败与入参校验一律返 HTTP 200(`GlobalExceptionHandler` 带 `@ResponseStatus(HttpStatus.OK)`),成功码是 `200` 不是 `0`。判成败请读 `body.code`,不要读 HTTP 状态码。
|
||||||
|
|
||||||
|
#### 空数据降级
|
||||||
|
|
||||||
|
本单不涉及。`staffList: []` 的"清空该范围"语义未变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、契约约束与正确调用方式
|
||||||
|
|
||||||
|
### `scopeRoles` 取值对照表
|
||||||
|
|
||||||
|
| payload 片段 | 结果 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 字段整个不传 | ✅ 整期全量覆盖 | 历史行为,未变 |
|
||||||
|
| `"scopeRoles": ["GUIDE","LEADER"]` | ✅ 只覆盖导游位 | 导游位并收这两个角色,必须同时传 |
|
||||||
|
| `"scopeRoles": ["PHOTOGRAPHER"]` | ✅ 只覆盖摄影位 | |
|
||||||
|
| `"scopeRoles": ["GUIDE"]` | ❌ `582116` | 只声明半个配置位,零写入 |
|
||||||
|
| `"scopeRoles": []` | ❌ `400` | 空数组被拒;清空语义只由 `staffList: []` 表达 |
|
||||||
|
| `"scopeRoles": [null]` | ❌ `400` 🆕 | **本单收紧**。改前返 200 且库里一行没动 |
|
||||||
|
| `"scopeRoles": ["", "GUIDE"]` | ❌ `400` | 改前已是 400,本单只让 `message` 多一条 |
|
||||||
|
| `"scopeRoles": [" "]` | ❌ `400` | 同上 |
|
||||||
|
| `"scopeRoles": null`(字段值为 null) | ✅ 等同不传 | 整期全量覆盖 |
|
||||||
|
|
||||||
|
### 前端拼 `scopeRoles` 时的两条实践
|
||||||
|
|
||||||
|
1. **过滤在前、校验在后**:如果 `scopeRoles` 由下拉选中项或接口回填拼出,先 `filter(Boolean)` 掉未选中产生的空值再发;空数组要整个字段不传,而不是传 `[]`。
|
||||||
|
2. **不要按 `message` 精确相等判分支**:多条约束并列失败时 `message` 是 `"; "` 拼接串且顺序不保证。要区分失败原因用 `code`,要展示就整串展示。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、数据库行为
|
||||||
|
|
||||||
|
**本单零数据库改动**:不加表、不加列、不加索引、无 Flyway 脚本。
|
||||||
|
|
||||||
|
唯一与库相关的行为差异是「改前 `[null]` 那一次请求对库零写入且返 200,改后它根本进不到 Service」—— 两种情况下库里都是零写入,差别只在调用方能不能知道。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、边界行为
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| `scopeRoles` 数组里混合合法值与空值(如 `["GUIDE", null]`) | 整条请求 400,**零写入**;Bean Validation 在进 Service 前就拦下,不存在"合法的那一半生效了" |
|
||||||
|
| 同一次请求同时违反多条约束 | `message` 为各条以 `"; "` 拼接,顺序不保证;`code` 恒为 `400` |
|
||||||
|
| `staffList` 里的 `staffRole` 为 null / 空串 | 由 `Item.staffRole` 自己的 `@NotBlank` + `@Pattern` 拦,本单未改 |
|
||||||
|
| 校验失败时的扇出 | 不发生。校验在 `@Valid` 绑定层,早于 Service,更早于异步扇出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六.5、枚举 / 数据字典
|
||||||
|
|
||||||
|
### `scopeRoles` 元素 与 `staffList[].staffRole` 的取值域(两者同一套)
|
||||||
|
|
||||||
|
判定依据是 `BatchStaffConfigReqVO.STAFF_ROLE_PATTERN` 常量,**共 8 个取值**:
|
||||||
|
|
||||||
|
| code | 中文名 |
|
||||||
|
|---|---|
|
||||||
|
| `LEADER` | 领队 |
|
||||||
|
| `GUIDE` | 导游 |
|
||||||
|
| `DRIVER` | 司机 |
|
||||||
|
| `PHOTOGRAPHER` | 摄影 |
|
||||||
|
| `OTHER` | 其他 |
|
||||||
|
| `GUIDE_ASSISTANT` | 导游助理 |
|
||||||
|
| `STUDY_TEACHER` | 研学老师 |
|
||||||
|
| `LIFE_TEACHER` | 生活老师 |
|
||||||
|
|
||||||
|
后 3 个由 #7079(`e61fc9ab6`)引入,**不是本单新加的**,本单一个取值都没动。
|
||||||
|
|
||||||
|
⚠️ **Swagger 上 `staffList[].staffRole` 的字段说明只列了前 5 个**(`LEADER=领队 / GUIDE=导游 / DRIVER=司机 / PHOTOGRAPHER=摄影 / OTHER=其他`),与 `@Pattern` 实际放行的 8 个不一致。**以本表为准** —— 本表取自 `STAFF_ROLE_PATTERN` 常量本身,那是校验实际执行的依据;另可交叉印证:响应侧 `staffList[].staffRoleName` 的字段说明已写全 8 个中文名("领队/司机/导游/摄影/其他/导游助理/研学老师/生活老师")。照请求侧 Swagger 文案写下拉会缺 3 项。
|
||||||
|
|
||||||
|
### 配置位与角色的对应
|
||||||
|
|
||||||
|
| 配置位 | 并收的角色 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 导游位 | `GUIDE` + `LEADER` | 触及就必须两个都传,否则 `582116` |
|
||||||
|
| 摄影位 | `PHOTOGRAPHER` | |
|
||||||
|
|
||||||
|
配置位的成员读数据字典(#7079):字典加一个角色后,原本完整的范围当天就会变成不完整并被拒,错误码文案里的「还缺少 X」即为要补进 `scopeRoles` 的角色。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六.6、修改前后对比
|
||||||
|
|
||||||
|
### 字段级
|
||||||
|
|
||||||
|
| 字段 | 改前约束 | 改后约束 |
|
||||||
|
|---|---|---|
|
||||||
|
| `scopeRoles` | `@Size(min = 1)` + 元素级 `@Pattern` | `@Size(min = 1)` + 元素级 `@NotBlank` + 元素级 `@Pattern` |
|
||||||
|
|
||||||
|
其余字段一字未改。
|
||||||
|
|
||||||
|
### 行为级
|
||||||
|
|
||||||
|
| 请求 | 改前 | 改后(现网) | 依据等级 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `"scopeRoles": [null]` | HTTP 200 / `code: 200`,库里零写入,**无任何错误提示** | `code: 400`,`message` = `scopeRoles 的元素不能为空` | 改前:**推导**(见下);改后:**网关活体实测** |
|
||||||
|
| `"scopeRoles": [""]` | `code: 400`,`message` 一条(`scopeRoles 取值不在员工角色取值域内`) | `code: 400`,`message` 两条以 `"; "` 拼接 | 改前:**单测层变异实测**;改后:**网关活体实测** |
|
||||||
|
| `"scopeRoles": [" "]` | 同上 | 同上(返回体与 `[""]` 逐字相同) | 同上 |
|
||||||
|
| `"scopeRoles": ["GUIDE","LEADER"]` | 正常进 Service | 不变 | 网关活体实测(作阴性对照) |
|
||||||
|
| `"scopeRoles": []` | `code: 400` | 不变 | 源码(`@Size(min = 1)`) |
|
||||||
|
| 字段不传 | 整期全量覆盖 | 不变 | 源码 |
|
||||||
|
|
||||||
|
🔎 **「改前」一列的依据分级,逐条说清楚**(改前的 jar 已不在测试服上,无法回放):
|
||||||
|
|
||||||
|
- `[null]` 改前返 200:**两段拼起来的推导**。①校验层:`BatchStaffConfigReqVOValidationTest` 的变异实测——移除元素级 `@NotBlank` 后,`Validator#validate` 对 `[null]` 返回 **0 条违约**(`Expected size: 1 but was: 0`),证明它确实能整条穿过校验层;②业务层:源码路径推导(整位守卫 `touched` 恒 false + 删除窗口 `{null}` 命中 0 行 + 插入集合为空),得出"零写入且返 200"。**这一段是源码推导,没有改前的网关读数**。
|
||||||
|
- `[""]` / `[" "]` 改前是一条 message:**单测层变异实测**——同一轮变异里,`[""]` 返回 **1 条违约、来自 `@Pattern`**。这是 Validator 直调的读数,不是网关读数。
|
||||||
|
|
||||||
|
⚠️ 这里订正一条**本仓代码注释里写错、并已同步订正**的结论:早前的注释写「`@NotBlank` 对 `""` / `" "` 带来的是错误文案**从** X **变成** Y」,实测证明是**两条并列、拼接**,不是替换。注释与相关测试 javadoc 已一并订正。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六.7、影响评估
|
||||||
|
|
||||||
|
对 `mmg/hl-ui` `origin/v2.1` 的调用方核查结论:
|
||||||
|
|
||||||
|
- `scopeRoles` 一律由**字面量常量数组**提供 —— `GroupBatchStaffConfigModal.vue:153-154` 写死 `['GUIDE','LEADER']`(导游位)与 `['PHOTOGRAPHER']`(摄影位),无任何一处会产出 `null` / 空串 / 纯空白元素;
|
||||||
|
- 因此现有实现**不会命中本单新增的 400**,行为零改动,无需前端配合改造。
|
||||||
|
|
||||||
|
⚠️ 这个结论的有效期限于"现有实现"。若后续改为由下拉选中项、接口回填或用户输入拼 `scopeRoles`,未选中产生的空值会直接命中本单的 400 —— 按第四节的两条实践处理即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、不影响范围
|
||||||
|
|
||||||
|
本单**未改动**以下任何一项,它们的契约保持原样:
|
||||||
|
|
||||||
|
- `staffList` 的语义("覆盖范围内的最终状态"、显式传 `[]` 即清空该范围)
|
||||||
|
- `582115`(范围外角色,零写入)与 `582116`(半位声明,零写入)的**判定规则与触发条件**
|
||||||
|
- 角色取值域本身(8 个取值一个没动)
|
||||||
|
- 配置位的划分与成员(导游位 = `GUIDE` + `LEADER`,摄影位 = `PHOTOGRAPHER`)
|
||||||
|
- 响应结构 `BatchStaffConfigRespVO` 的任何字段
|
||||||
|
- 扇出行为(异步写 `order_staff_assignment`,`source=GROUP_BATCH`,ORDER 专属行不受影响)
|
||||||
|
- 成团守卫(`589552` / `589553`)
|
||||||
|
- 同端点以外的任何接口
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、测试环境已验证
|
||||||
|
|
||||||
|
### 部署状态(三条独立判据)
|
||||||
|
|
||||||
|
| 判据 | 读数 |
|
||||||
|
|---|---|
|
||||||
|
| ① 祖先关系 + 登记 | `git merge-base --is-ancestor bfc10966e 843de6401` → 退出码 0;`deploy-status.sh` 登记 `hl-order-service-v3 / dev-v3 / 843de6401 / BEHIND=0(N) / ok` |
|
||||||
|
| ② jar 时间 | jar mtime `2026-09-22 18:55:47`,晚于 `bfc10966e` 的提交时刻 `2026-09-22 15:24:50 +0800` |
|
||||||
|
| ③ 活体行为 | 下面四组请求中,①②③ 的返回值在改动前的代码上不可能出现 |
|
||||||
|
|
||||||
|
部署前后对照(`deploy-status.sh`):
|
||||||
|
|
||||||
|
```
|
||||||
|
前:hl-order-service-v3 dev-v3 776c0023d BEHIND=10(Y) 2026-09-22 17:19:45 ok
|
||||||
|
后:hl-order-service-v3 dev-v3 843de6401 BEHIND=0(N) 2026-09-22 18:55:47 ok
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网关实测四组(2026-09-22 18:5x,`https://api.test.1814.love:9443`)
|
||||||
|
|
||||||
|
端点一律 `PUT /v3/admin/group-batch/999999999999999999/staff`。
|
||||||
|
|
||||||
|
**① `scopeRoles: [null]` —— 本单的主体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
请求: {"scopeRoles":[null],"staffList":[]}
|
||||||
|
响应: {"code":400,"message":"scopeRoles 的元素不能为空","data":null,"traceId":null,"success":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
**② `scopeRoles: [""]`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
请求: {"scopeRoles":[""],"staffList":[]}
|
||||||
|
响应: {"code":400,"message":"scopeRoles 取值不在员工角色取值域内; scopeRoles 的元素不能为空","data":null,"traceId":null,"success":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
**③ `scopeRoles: [" "]`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
请求: {"scopeRoles":[" "],"staffList":[]}
|
||||||
|
响应: {"code":400,"message":"scopeRoles 取值不在员工角色取值域内; scopeRoles 的元素不能为空","data":null,"traceId":null,"success":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
与 ② 的返回体**逐字相同**。
|
||||||
|
|
||||||
|
**④ `scopeRoles: ["GUIDE","LEADER"]` —— 阴性对照**
|
||||||
|
|
||||||
|
```json
|
||||||
|
请求: {"scopeRoles":["GUIDE","LEADER"],"staffList":[]}
|
||||||
|
响应: {"code":589553,"message":"团期尚未创建(该班期还没有任何订单),请先建团并完成成团后再操作","data":null,"traceId":null,"success":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
🔎 **第 ④ 组不是凑数的**:没有它,前三组的 400 也可能只是"这个端点对什么都返 400"。④ 用同一个不存在的 `productBatchId`、只换 `scopeRoles` 的取值,就穿过了绑定层抵达 Service 并拿到业务错误码 —— 证明前三组的 400 确实来自 `scopeRoles` 的元素级校验,而不是端点无差别拒绝。
|
||||||
|
|
||||||
|
🔎 **取证为什么用不存在的团期**:本单的校验发生在 Spring `@Valid` 绑定层、**在进 Service 之前**,所以 `productBatchId` 用一个不存在的值就够——前三组根本走不到 Service,不读不写任何真实团期数据。整轮实测**零数据污染**。
|
||||||
|
|
||||||
|
### 单测
|
||||||
|
|
||||||
|
`BatchStaffConfigReqVOValidationTest`(Validator 直调)与 `GroupBatchStaffAdminControllerTest`(`@WebMvcTest`)各有承载用例。后者除了断 `$.code == 400` 与 message 内容,还断了 `verifyNoInteractions(groupBatchStaffConfigService)`,证明请求在**进 Service 之前**就被拦下 —— 没有这条断言,"`@Valid` 这道关生没生效"与"Service 内部某处恰好也返 400"在其余断言下完全同形。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、已知缺口(不在本单范围)
|
||||||
|
|
||||||
|
1. **Swagger 请求侧 `staffList[].staffRole` 的取值域文案落后 3 个角色**(只列前 5 个)。这是文案缺口不是实现缺口,校验按 8 个取值执行。以第六.5 节的表为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、相关文档
|
||||||
|
|
||||||
|
| 文档 | 关系 |
|
||||||
|
|---|---|
|
||||||
|
| `21_8006_团期staff保存支持按角色范围覆盖-修改接口-管理后台.md` / `22_8006_团期人员保存支持按角色范围覆盖-修改接口-管理后台.md` | `scopeRoles` 字段的**完整契约**出处(部分覆盖语义、删除窗口、582115) |
|
||||||
|
| `22_8122_团期人员保存声明角色范围时必须整位覆盖-修改接口-管理后台.md` | `582116`(半位声明拒绝)的契约出处 |
|
||||||
|
| `12_7530_团期staff去重与报账人扇出限定本团-修改接口-管理后台.md` | 扇出行为 |
|
||||||
|
| `12_7535_团期接口返回值整改非破坏批-修改接口-管理后台.md` | 响应字段(`reporterRank` 等)的出处 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关联 / 联系人
|
||||||
|
|
||||||
|
- **Issue**: #8150
|
||||||
|
- **PR**: #8177(修复本体)、#8197(配套测试与注释订正)
|
||||||
|
- **后端**: wx
|
||||||
|
- **前端**: mmg(本单 `frontend_status: not_required`,判定依据见 frontmatter 的 `status_note` 与第六.7 节)
|
||||||
在新工单中引用
屏蔽一个用户