docs: 订正 #8159 候选查询 changelog 的编造与漏项
changelog-filename-gate / validate (push) Failing after 2s

上一版由轻档代理生成,存在多处与源码不符的内容,逐处订正:

编造(源码无此事实)
- 服务端口 8060;五个表名 order_requirement / group_dispatch_line /
  group_batch / vehicle / driver 全部不存在
- 下游不可用返 code: 999 或 5xx
- 鉴权口径「订单顾问、财务相关角色可访」——真相是
  FleetAdminRoleGuardInterceptor 只放行 VEHICLE_MANAGER / SUPER_ADMIN,
  且权限点 fleet:group-dispatch:* 在 Java 侧零引用(本单 #8159 专门订正过)
- 出参 VO 写成 ShareMemberCandidateVO,真名 ShareMemberCandidateRespVO

写错(会让前端写错代码)
- 参数非法标成 HTTP 400:本服务业务失败恒 HTTP 200,须按 body.code 判
- 业务边界称浏览态 selectable 恒为 null:实际 ALREADY_IN_ANOTHER_GROUP
  与 CROSS_BATCH 两条判据不依赖 resourceId,浏览态照样返 false
- 四个 unselectableReason 的触发条件均不准确

漏项(数据损坏级)
- 漏掉 shareGroupId 必须由调用方比对这条契约义务。本读口会把正在编辑的
  关系的既有成员也置灰成 ALREADY_IN_ANOTHER_GROUP,前端须自行恢复;
  且 members 是全集不是增量,漏掉的既有成员会被写口静默移出关系
- 漏掉 602106 的渲染要求(提示刷新重选,不得自动重试)
- 漏掉 sourceType + sourceId 可直接透传给写口 members[]

依据:GroupDispatchShareController.java 类注释与 listMemberCandidates
的 @ApiOperation notes(origin/dev-v3)。

Refs #8159

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-22 18:36:37 +08:00
共同撰写人 Claude Opus 5
父节点 d609d3ec77
当前提交 34390d85ad
@@ -19,7 +19,7 @@ base: "dev-v3"
# 团级配车成员共用候选查询(#8159)
> **服务**: hl-fleet-service(端口 8060)
> **服务**: hl-fleet-service(经网关调用,无需关心服务端口)
> **PR**: #8188
> **Issue**: #8159
> **日期**: 2026-09-22
@@ -29,7 +29,13 @@ base: "dev-v3"
## ⚠️ 关键变化
新增查询端点返回「成员共用」候选列表。**重点提醒**:`resourceId` 字段语义需特别关注——未传时 `selectable` 返 `null`(未判定),**不能当 `false` 用禁用勾选**,也不能当 `true` 用放开勾选,需用户先确认资源维度。
新增查询端点,返回「成员共用」的候选行清单,喂给已有的「确认共用关系」写口。
**三条会直接影响你怎么写代码的点,按严重度排**:
1. 🔴 **`shareGroupId` 必须由前端比对,本接口不会替你做**(业务边界第 2 条)。编辑一个**已存在**的共用关系时,它自己的既有成员会被本接口置灰成 `ALREADY_IN_ANOTHER_GROUP`——前端要把 `shareGroupId` 等于「正在编辑的关系 ID」的行**恢复成可勾选**。并且**提交时 `members` 是全集不是增量**:漏掉的既有成员会被写口按「移出本关系」处理,**不报错、静默生效**。
2. 🔴 **`occupying` / `selectable` 是三态,`null` 是「未判定」不是「否」**。不传 `resourceId` 时 `occupying` 恒 `null`、`selectable` **永不为 `true`**——但它**可能是 `false`**(已属别的关系、或团期身份缺失这两条浏览态就判得出来),所以置灰要照常渲染。
3. **成功码是 `code: 200`,不是 `0`**;业务失败也返 HTTP 200,**一律按 `body.code` 判,不要按 HTTP 状态行判**。
---
@@ -66,7 +72,7 @@ base: "dev-v3"
| `resourceType` | Query | String | ✅ | `VEHICLE` / `DRIVER` | 资源维度。缺失或非法值返 `code: 100001` |
| `resourceId` | Query | Long | ❌ | — | 具体资源 ID(车 ID / 司机 ID)。**不传时 `occupying` / `selectable` 返 `null`**(未判定状态,见业务边界第 1 条) |
#### 出参 `Result<List<ShareMemberCandidateVO>>`
#### 出参 `Result<List<ShareMemberCandidateRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
@@ -88,8 +94,8 @@ base: "dev-v3"
| `occupiedDriverId` | String / null | 该行实际占用的司机 ID;未占用时 null |
| `occupiedDriverName` | String / null | 该行实际占用的司机名称 |
| `occupying` | Boolean / **null** | 本行在 `(serviceDate, resourceType, resourceId)` 上是否活跃占用。**`resourceId` 未传时为 `null`**(未判定) |
| `shareGroupId` | String / null | 本行所属的 ACTIVE 共用关系 ID;不属任何关系时 null |
| `selectable` | Boolean / **null** | 是否可勾选。`true` = 可选;`false` = 不可选(此时 `unselectableReason` 非空);**`null` = 未判定**(仅当 `resourceId` 未传、且本行未被永久性约束击中时出现) |
| `shareGroupId` | String / null | 本行所属的 ACTIVE 共用关系 ID;不属任何关系时 null。🔴 **调用方必须拿它和「正在编辑的关系 ID」比对**,见业务边界第 2 条 |
| `selectable` | Boolean / **null** | 是否可勾选。`true` = 可选;`false` = 不可选(此时 `unselectableReason` 非空);**`null` = 未判定**(`resourceId` 未传、且本行未被 `ALREADY_IN_ANOTHER_GROUP` / `CROSS_BATCH` 判死时出现)。🔴 **`resourceId` 未传时本字段永不为 `true`** |
| `unselectableReason` | String / null | 不可选原因枚举值(见六.5 章节);`selectable=true` 时为 null |
| `unselectableDetail` | String / null | 人类可读的补充说明,如「订单 HLxxxx 已在车 A 关系中」 |
@@ -154,7 +160,7 @@ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candida
}
```
下游不可用(权威基线 Feign 失败,失败关闭)时,接口返 `code: 999` 或 5xx,网关记日志,前端需重试或展示加载失败。
🔴 **区分「空」与「失败关闭」**:拿不到团期权威基线时,本接口**不降级返空**,而是**失败关闭**抛 `GROUP_BATCH_BASELINE_UNAVAILABLE`。⇒ `data: []` 只意味着「确实没有候选」,前端可以放心按「无可选成员」渲染,不必担心它其实是一次被吞掉的下游故障。
#### 错误响应
@@ -182,16 +188,42 @@ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candida
#### 业务边界
1. **`resourceId` 未传时的 null 三态语义**(最重要):
- `occupying`:**`null`**(= 未判定,不是 `false`)
- `selectable`:**`null`**(= 未判定,不是 `true` / `false`)
- `unselectableReason`、`unselectableDetail`:都为 null
**0. 本清单直接喂给写口**:返回的 `sourceType` + `sourceId` 就是「确认共用关系」写口 `members[]` 要的两个字段,**原样透传即可**,不需要再做映射。
前端在浏览态(用户尚未选择具体资源)**不能**把 `selectable == null` 当成 `false` 禁用勾选、也不能当成 `true` 放开;这是第三种状态,需等用户明确选定车或司机后再查一遍、获得真实判定。
**1. `resourceId` 未传时的 `null` 三态语义**
2. **候选是时点快照**:本接口返回的 `selectable=true` 是**查询那一刻**的结论。前端提交共用关系时仍可能撞到 `code: 602106`(该行在这期间被别人加进了另一个共用关系)。前端**必须保留提交失败的错误处理分支**,不能因为候选查询说可选就假定提交一定成功。
| 字段 | 传了 `resourceId` | 未传 `resourceId`(浏览态) |
|---|---|---|
| `occupying` | `true` / `false` | 恒 `null`(未判定) |
| `selectable` | `true` / `false` | `false` **或** `null`,**永不为 `true`** |
| `unselectableReason` | `selectable=false` 时非空 | 同左 |
3. **`group_batch_id` 为 NULL 的派单行不在本端点覆盖范围**:查询条件是 `eq(group_batch_id, ?)`,这类行无法查出;若前端观察到「某些派单没出现在候选里」,原因可能是该派单的 `group_batch_id` 字段为 null(结构性特征,非数据错误)。
`null` 的含义是**未判定**,不是「否」。不传 `resourceId` 时「当日占没占着这个资源」根本算不出来,所以 `occupying` 一律 `null`。
🔴 **但浏览态下 `selectable` 并不总是 `null`**:有两条判据不依赖 `resourceId`,浏览态照样能判死——
- **已属别的 ACTIVE 共用关系** → `selectable=false`,`unselectableReason=ALREADY_IN_ANOTHER_GROUP`
- **团期身份缺失**(`group_batch_id` / `requirement_id` 为空或团期不符)→ `selectable=false`,`unselectableReason=CROSS_BATCH`
⇒ 前端在浏览态**不能**把 `selectable == null` 当成 `false` 禁用勾选,也**不能**当成 `true` 放开;但**要照常渲染 `selectable === false` 的置灰与原因**——那些行在浏览态就已经定死了,选了具体资源也不会变回可选。
**2. 🔴 `shareGroupId` 必须由调用方比对——这是契约的一部分,不是可选优化**
本读口**不知道你正在编辑哪一个共用关系**,所以凡已属任一 ACTIVE 关系的行,一律置灰为 `ALREADY_IN_ANOTHER_GROUP`。而写口的 602106 拒的是「属于**另一个**关系」——**同一关系的既有成员,写口是收的**。
⇒ **给同一个 `shareGroupId` 追加第 3 个成员这个动作,本清单的置灰结构上覆盖不到。** 前端必须自己做两件事:
1. **把 `shareGroupId` 等于「正在编辑的关系 ID」的行视为可勾选**(忽略本接口给它的 `ALREADY_IN_ANOTHER_GROUP` 置灰)。不做这一步的表现是:编辑一个已有关系时,它自己的现有成员全部不可选,功能做不出来。
2. 🔴 **提交时 `members` 是全集不是增量**——要把该关系的**既有成员连同新成员一起**放进 `members`。**漏掉的既有成员会被写口按「移出本关系」处理。** 只提交新成员的写法不会报错,但会静默把原有成员全部踢出关系。
**3. `selectable=true` 不是「提交必成功」的承诺**
本清单是**时点快照、不加锁**。返回之后别的车务仍可能把某成员纳入另一个关系,提交时照样可能撞 `602106`。
⇒ 这种 602106 请渲染成「**有人先一步占了,请刷新候选清单重选**」,🔴 **不要当系统故障自动重试——重试必然同样失败**。这条竞态读口消除不了(加锁也不行,锁在响应返回那一刻就已放开),最终由写口的锁定读与唯一键兜底。
**4. 覆盖范围:未关联团期的派单行结构上不在本清单内**
本清单只列 `group_batch_id` 指向本团的派单行。`group_batch_id` 为空的派单行查不出来——但**这不是缺口**:这类行在写口同样恒被 602103 拒,「看不见」与「看得见也提交不了」**等价**。要让这类行可共用,得先补上团期身份(数据侧动作,不是前端能处理的)。
---
@@ -199,30 +231,41 @@ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candida
### ✅ 正确 / ❌ 错误请求对照
| 场景 | 请求 | 预期 |
🔴 **先说判据**:本服务的业务失败与入参校验**一律返 HTTP 200**(`GlobalExceptionHandler` 带 `@ResponseStatus(HttpStatus.OK)`),**`body.code` 才是真相**。前端判错请一律读 `body.code`,**不要读 HTTP 状态行**——按状态行判会把所有业务错误当成成功。
| 场景 | 请求 | 预期(HTTP 恒 200) |
|------|------|------|
| ✅ 浏览态,看全清单 | `?serviceDate=2026-12-25&resourceType=VEHICLE`(无 resourceId) | `data[]` 中所有行 `occupying=null, selectable=null` |
| ✅ 选定车后判可选 | `?serviceDate=2026-12-25&resourceType=VEHICLE&resourceId=2085539421276286978` | `data[]` 中各行 `occupying=true/false, selectable=true/false/null` |
| ✅ 跨服务日查询 | `?serviceDate=2026-12-26&resourceType=VEHICLE&resourceId=xxx` | 若 2026-12-26 在服务窗内,照常返回候选;若不在返 602104 |
| ❌ 维度错填 | `?serviceDate=2026-12-25&resourceType=HOUSE&resourceId=xxx` | 400 `100001: 参数非法: 资源维度非法: HOUSE` |
| ❌ 缺维度参数 | `?serviceDate=2026-12-25&resourceId=xxx`(无 resourceType) | 400 `100001: 参数非法: 资源维度不能为空` |
| ✅ 浏览态,看全清单 | `?serviceDate=2026-12-25&resourceType=VEHICLE`(无 resourceId) | `code: 200`;各行 `occupying=null`;`selectable` 为 `null` 或 `false`(被判死的行),**不会是 `true`** |
| ✅ 选定车后判可选 | `?serviceDate=2026-12-25&resourceType=VEHICLE&resourceId=2085539421276286978` | `code: 200`;各行 `occupying=true/false`,`selectable=true/false` |
| ✅ 同团其他服务日 | `?serviceDate=2026-12-26&resourceType=VEHICLE&resourceId=xxx` | 在该团服务日窗内则照常返回候选,否则 `code: 602104` |
| ❌ 维度错填 | `?serviceDate=2026-12-25&resourceType=HOUSE&resourceId=xxx` | `code: 100001`「参数非法: 资源维度非法: HOUSE」 |
| ❌ 缺维度参数 | `?serviceDate=2026-12-25&resourceId=xxx`(无 resourceType) | `code: 100001`「参数非法: 资源维度不能为空」 |
| ❌ 路径参数填成产品班期 ID | `/batches/{product_batch_id}/share-member-candidates?...` | 🔴 **不报参数错**,返 `code: 200` + `data: []`。失败形态是静默的,看起来像「接口没数据」 |
---
## 五、数据库行为
本接口仅读操作,无 DB 写入。查询涉及表:`order_requirement`(派车行),`group_dispatch_line`(团级配车行),`group_batch`(团期基础),`vehicle`(车辆),`driver`(司机)。
本接口**仅读**,无任何 DB 写入,不新增表、不改表结构、无 Flyway 脚本。
---
## 六、边界行为
- **未登录** → 401(网关统一鉴权)
- **无访问权限** → 403(订单顾问、财务相关角色可访)
- **团期不存在** → `data: []`(无候选)
- **资源维度非法** → 100001
- **服务日超出团期窗** → 602104
- **下游服务不可用** → 5xx,前端重试
**鉴权**(🔴 本单专门订正过一处长期误解,前端排查 403 时会用到):
- **未登录 / token 失效** → 网关 `JwtAuthFilter` 拦下
- **已登录但角色不符** → `FleetAdminRoleGuardInterceptor` 对 `/admin/fleet/**` **只放行 `VEHICLE_MANAGER` 与 `SUPER_ADMIN` 两个角色**
- 🔴 **鉴权的真相是角色,不是权限点**:`fleet:group-dispatch:view` / `fleet:group-dispatch:write` 这两个字符串在整个 Java 侧**零引用**,它们只是权限字典里的声明,不是运行时判据。⇒ **「给某个非车务角色开个只读权限点,让他看候选清单」这件事配置不出来**——那类角色连 `/admin/fleet/**` 都进不来。要改授权粒度得动拦截器,是另一件事。
**其余边界**(一律 HTTP 200,按 `body.code` 判):
- **资源维度缺失 / 非法** → `code: 100001`
- **服务日超出团期服务日窗** → `code: 602104`
- **团期权威基线拿不到** → **失败关闭**(不降级返空),见「空数据 / 降级响应」
- **本团本服务日无候选** → `code: 200` + `data: []`
**网关路由**:复用既有 `- Path=/admin/fleet/**` → `lb://hl-fleet-service`,本单**不新增路由**。
---
@@ -250,14 +293,16 @@ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candida
**所属字段**: `unselectableReason` | **类型**: `String`
| 值 | 含义 | 触发条件 |
|---|---|---|
| `CROSS_BATCH` | 602103 | 本行所属团期与目标团期不同 |
| `NOT_OCCUPYING` | 602102 | 本行在目标 `(serviceDate, resourceType, resourceId)` 上未占用 |
| `ALREADY_IN_ANOTHER_GROUP` | 602106 | 本行已属于另一个 ACTIVE 共用关系 |
| `GROUP_DISPATCH_INVALID` | 602105 | 团级配车行的基础数据不完整或状态异常 |
| 值 | 触发条件 | 浏览态(未传 `resourceId`)能否判出 | 对应写口错误码 |
|---|---|---|---|
| `CROSS_BATCH` | `group_batch_id` / `requirement_id` **为空**,或团期与本团不符 | ✅ 能 | 602103 |
| `NOT_OCCUPYING` | 派单不在**可改派的在飞态**,或当日**标记不用车** | ❌ 不能(需 `resourceId`) | 602102 |
| `ALREADY_IN_ANOTHER_GROUP` | 本行已属某个 ACTIVE 共用关系(此时 `shareGroupId` **非空**) | ✅ 能 | 602106 |
| `GROUP_DISPATCH_INVALID` | 团级配车行**当日不在此资源上** | ❌ 不能(需 `resourceId`) | 602105 |
**说明**:这些值**不是**接口级错误码,而是本字段的业务枚举。提交共用关系时若失败,才会返回 `code: 602103` / `602106` 等(整请求级错误码)。
**说明**:这一列值**不是**接口返回的 `code`,而是 `unselectableReason` 字段的取值。本接口只要能返回清单,`code` 恒为 `200`;上面那些 6021xx 是**提交共用关系**(写口)失败时才会出现在 `code` 里的。
🔴 **`ALREADY_IN_ANOTHER_GROUP` 这一行前端必须自己复算**:本读口不知道你在编辑哪个关系,会把正在编辑的那个关系的既有成员也置灰成它。判据是 `shareGroupId` 是否等于正在编辑的关系 ID,详见业务边界第 2 条。
---