diff --git a/changelogs-v2/2026-09/22_8150_团期staff保存scopeRoles元素级非空校验-修改接口-管理后台.md b/changelogs-v2/2026-09/22_8150_团期staff保存scopeRoles元素级非空校验-修改接口-管理后台.md index a01b56ec..a6e32c40 100644 --- a/changelogs-v2/2026-09/22_8150_团期staff保存scopeRoles元素级非空校验-修改接口-管理后台.md +++ b/changelogs-v2/2026-09/22_8150_团期staff保存scopeRoles元素级非空校验-修改接口-管理后台.md @@ -8,7 +8,10 @@ change_type: "修改接口" backend_status: "deployed" gateway_status: "not_required" frontend_status: "not_required" -target_release: "v2.1" +frontend_owner: "" +frontend_ref: "" +target_release: "" +verified_at: "" 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" @@ -79,9 +82,9 @@ base: "dev-v3" ## 二、变更接口清单 -| 方法 | 路径 | 变更类型 | 本单改了什么 | -|---|---|---|---| -| `PUT` | `/v3/admin/group-batch/{productBatchId}/staff` | 入参校验收紧 | `scopeRoles` 增加元素级 `@NotBlank`;`code`/响应结构/业务语义均未变 | +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|---|---|---|---|---| +| 1 | 保存团期 staff 配置(含扇出) | PUT | `/v3/admin/group-batch/{productBatchId}/staff` | 修改接口 | `scopeRoles` 增加元素级 `@NotBlank`(入参校验收紧);`code`、响应结构、业务语义均未变 | **未新增、未删除、未改名任何端点。** @@ -91,32 +94,27 @@ base: "dev-v3" ### 1. 保存团期 staff 配置(含扇出) `PUT /v3/admin/group-batch/{productBatchId}/staff` -**请求 VO**:`BatchStaffConfigReqVO` -**响应 VO**:`Result` -**权限**:`GroupBatchPermissionGuard.PERMISSION_MANAGE` +**VO**: `BatchStaffConfigReqVO` → `Result` + +**权限**: `GroupBatchPermissionGuard.PERMISSION_MANAGE`(`hl-gateway` 侧命中既有通配路由 `Path=/v3/admin/**`) #### 使用场景 团期详情页「配导游 / 配摄影」弹窗保存。传入列表即为**覆盖范围内**的最终状态,保存成功后异步扇出到团内所有活跃订单的 `order_staff_assignment`(`source=GROUP_BATCH`)。 -#### 路径参数 +#### 入参 -| 参数 | 类型 | 必填 | 说明 | -|---|---|---|---| -| `productBatchId` | Long | 是 | **产品侧排期 ID**(`group_tour_batch.batch_id`),不是运营团期主键 `order_group_batch.group_batch_id`。两者 1:1 但值不同,传错不会报错、只会走不到团期 | +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---|---| +| `productBatchId` | path | Long | 是 | — | **产品侧排期 ID**(`group_tour_batch.batch_id`),不是运营团期主键 `order_group_batch.group_batch_id`。两者 1:1 但值不同,传错不会报错、只会走不到团期 | +| `scopeRoles` | body | `List` | 否 | 数组 `@Size(min = 1)`;🆕 元素 `@NotBlank` + 元素 `@Pattern` | 本次保存覆盖的角色范围。**不传 = 整期全量覆盖**(历史行为);传了则只在这些角色内覆盖。取值域见第六.5 节。**数组不能是空数组**(清空语义只由 `staffList` 表达);🆕 **元素不能是 `null` / 空串 / 纯空白**——本单唯一的行为变化就在这里 | +| `staffList` | body | `List` | **是** | `@NotNull` + `@Valid` | 覆盖范围内的最终状态。**显式传 `[]` 即清空该范围**。缺字段或字段名拼错一律 400(审计 F-03,Refs #6950 / #7377)。本单未改动 | +| `staffList[].staffId` | body | Long | 是 | `@NotNull` | 用户域员工 ID。本单未改动 | +| `staffList[].staffRole` | body | String | 是 | `@NotBlank` + `@Pattern` | 员工角色,取值域见第六.5 节。本单未改动 | +| `staffList[].sortOrder` | body | Integer | 否 | — | 展示排序,默认 0。本单未改动 | +| `staffList[].remark` | body | String | 否 | `@Size(max = 500)` | 备注,≤500 字。本单未改动 | -#### 请求参数 - -| 字段 | 类型 | 必填 | 本单是否改动 | 说明 | -|---|---|---|---|---| -| `scopeRoles` | `List` | 否 | **是(元素级校验)** | 本次保存覆盖的角色范围。**不传 = 整期全量覆盖**(历史行为);传了则只在这些角色内覆盖。取值域见第六.5 节。**数组不能为空数组**(`@Size(min = 1)`,清空语义只由 `staffList` 表达);🆕 **元素不能是 `null` / 空串 / 纯空白** | -| `staffList` | `List` | **是** | 否 | 覆盖范围内的最终状态。**显式传 `[]` 即清空该范围**。缺字段或字段名拼错一律 400(审计 F-03,Refs #6950 / #7377) | -| `staffList[].staffId` | Long | 是 | 否 | 用户域员工 ID | -| `staffList[].staffRole` | String | 是 | 否 | 员工角色,取值域见第六.5 节 | -| `staffList[].sortOrder` | Integer | 否 | 否 | 展示排序,默认 0 | -| `staffList[].remark` | String | 否 | 否 | 备注,≤500 字 | - -#### 响应参数(本单未改动,列出供对照) +#### 出参(本单未改动,列出供对照) | 字段 | 类型 | 说明 | |---|---|---| @@ -150,8 +148,61 @@ PUT /v3/admin/group-batch/80001/staff } ``` +#### 响应示例 + +保存成功(`code` 为 `200`,本单未改变响应结构): + +```json +{ + "code": 200, + "message": "操作成功", + "data": { + "productBatchId": "80001", + "groupBatchId": "90211", + "staffList": [ + { + "id": 770001, + "staffId": 40001, + "staffRole": "LEADER", + "staffRoleName": "领队", + "staffName": "刘领队", + "staffPhone": "138****6677", + "avatarUrl": null, + "sortOrder": 0, + "remark": "首席领队", + "reporterRank": "NONE", + "reporterRankName": "非报账人" + } + ], + "affectedOrderCount": 3 + }, + "traceId": null, + "success": true +} +``` + +🔎 本示例按 `BatchStaffConfigRespVO` 的字段逐个列出,用于说明结构;本单未改动响应结构中的任何一个字段。成功路径的活体读数不在本单的取证范围内(本单取证用的是不存在的团期,见第八节),需要成功响应的真实样本请见第十节 #8006 的交接件。 + +#### 空数据 / 降级响应 + +本单不涉及。`staffList: []` 的「清空该范围」语义未变,该情形下 `data.staffList` 返回空数组(不是 `null`),`affectedOrderCount` 照常返回扇出订单数。 + #### 错误响应 +🆕 `scopeRoles` 元素为 `null`(**本单新增的拒绝**): + +```json +{"code":400,"message":"scopeRoles 的元素不能为空","data":null,"traceId":null,"success":false} +``` + +`scopeRoles` 元素为空串 / 纯空白(改前已是 400,本单只让 `message` 多带一条): + +```json +{"code":400,"message":"scopeRoles 取值不在员工角色取值域内; scopeRoles 的元素不能为空","data":null,"traceId":null,"success":false} +``` + +全部错误码: + | code | 触发条件 | 本单是否改动 | |---|---|---| | `400` | `scopeRoles` 元素为 `null` | 🆕 **本单新增**(改前是 200 静默空操作) | @@ -165,9 +216,14 @@ PUT /v3/admin/group-batch/80001/staff 🔴 **HTTP 状态行恒为 200,`body.code` 才是真相**。本服务的业务失败与入参校验一律返 HTTP 200(`GlobalExceptionHandler` 带 `@ResponseStatus(HttpStatus.OK)`),成功码是 `200` 不是 `0`。判成败请读 `body.code`,不要读 HTTP 状态码。 -#### 空数据降级 +#### 业务边界 -本单不涉及。`staffList: []` 的"清空该范围"语义未变。 +- **本单唯一的行为变化在 `scopeRoles` 的元素上**:`[null]` 由「HTTP 200 + 静默空操作」改为 `code: 400`。`scopeRoles` 不传、传合法值、以及 `staffList` 侧的一切语义都与改前逐字相同。 +- **`scopeRoles` 不传 = 整期全量覆盖**,传了 = 只在这些角色内覆盖;`[]` 一直被拒(`@Size(min = 1)`),清空语义只由 `staffList: []` 表达。这三条改前改后一致。 +- **`[null]` 改前不报错,但它做的事情是「什么都没做」**:`scopeRoles` 含 `null` 时下游范围计算得到空范围,接口返 200、`affectedOrderCount` 为 0、库里零写入。调用方若据 200 判定「已保存」,实际保存从未发生 —— 这正是本单要关掉的静默失败。 +- **`[""]` / `[" "]` 改前已是 400**,本单只让 `message` 多带一条。**不要对这两个值的 `message` 写全等比较**:它是 `@Pattern` 与 `@NotBlank` 两条 violation 由 `GlobalExceptionHandler` 以 `"; "` 拼接而成,且 **拼接顺序不属于契约**(Bean Validation 不保证多个约束的执行顺序)。要判就判 `code == 400`,或判 `message` **包含**你关心的那个子串。 +- **校验发生在 Spring `@Valid` 绑定层,在进 Service 之前**:因此 `productBatchId` 是否真实存在不影响这三种 400 的出现。反过来,拿到 `589552` / `589553` 说明请求已经穿过绑定层、是团期状态问题,不是参数格式问题。 +- **HTTP 状态行恒为 200**,成功码是 `200` 不是 `0`,判成败一律读 `body.code`。 ---