文件
hl-api-changelog/changelogs-v2/2026-09/20_7988_团期配车刷新状态只读观测字段-修改接口-管理后台.md
T

381 行
28 KiB
Markdown
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
---
schema: "hl-changelog/v2"
ticket: "7988"
title: "团期配车刷新状态补只读观测口(读团期正式用车需求新增七个只读字段)"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "gateway_status=verified 的依据:2026-09-20 18:22-18:46 经网关实测(账号 cw_test_7444)GET /v3/admin/order/group-batch/2101524283048263681/vehicle-requirement,code=200,本篇登记的七个只读观测字段全部出现且字段名/类型与正文一字对应:planRefreshState=null / planRefreshReplayCount=0 / blockedStage=RESOURCE_PREPARING / planRefreshStalled=false / planRefreshStalledReason=null / planRefreshTimeoutAt=null / planRefreshReplayExhausted=false。逐字段对照无出入。⚠️ 正文示例值原来取自单元测试常量,现已有这组真实取值作为实测错开对照。 【上一轮原注】backend_status=deployed 的判据(2026-09-20 18:35 复核):hl-order-service-v3 测试服部署点 e179e09bd(2026-09-20 17:55:02 发布),`git merge-base --is-ancestor 587af48cd e179e09bd` = true,故本篇端点的代码确已在测试服运行的字节里。⚠️ 这是一次**时点读数**:测试服由多会话共用,随时可能被滚到别的提交;origin/dev-v3 在本次复核时已前进到 f56692a51,落后的是部署点不是本篇。 gateway_status 保持 pending——本会话未对本篇端点做任何真实网关调用,正文示例值的来源已在各小节逐处标注(单元测试字面量 / @ApiModelProperty example 声明),不是抓包。待网关复验后再置 verified,**不得为了让门禁变绿改这个位**。 【本轮之前的原注,保留供追溯口径变化】代码已合入 dev-v3(PR #8033,squash 提交 587af48cd,经 git log origin/dev-v3 --oneline | grep 7988 核实存在于 origin/dev-v3)。backend_status 刻意保持 pending——hl-order-service-v3 尚未部署测试服到该提交,未做任何真实网关调用;gateway_status 同样保持 pending。正文请求/响应示例的具体数值来自随 PR 一起合入的单元测试常量(GroupVehicleRequirementPlanRefreshObservationTest:groupBatchId=7201、requirementId=92001、blockedStage=PENDING_DEPARTURE、windowMinutes=120 等)与源码 @ApiModelProperty(example=...) 声明,逐一对源码核实过字段名/类型,但不是测试服网关抓包,具体取值以复验后实测为准。待管理者安排部署 + 网关复验后再把 backend_status/gateway_status 置 deployed/verified 并推送本文件;发布前不许为了过校验改状态位。 前端实证维持 pending(mmg 2026-09-20):planRefresh*/blockedStage 全仓零命中,前端从未为读刷新状态调 confirm(无 workaround 可撤),纯新增只读字段不接入零影响;停滞告警渲染(planRefreshStalled 判据/ReplayExhausted 文案分叉)属团期配车编辑页 #7442+#7444 挂起域,随配车排期一并接入。"
updated_at: "2026-09-20"
base: "dev-v3"
---
# order-v3: 团期配车刷新状态补只读观测口(工单 #7988)
> **存放目录**: 二期(order-v3 标签工单)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3
> **PR**: #8033 | **Issue**: #7988 | **合并提交**: `587af48cd`
> **日期**: 2026-09-20
> **影响范围**: 管理后台「团期配车」编辑页,读团期正式用车需求响应体新增七个只读字段(配车刷新状态观测块)
---
## ⚠️ 关键变化
改动前,"配车刷新到底死没死"这件事**只能通过写口的响应看到**——`requirement/confirm` 与 `vehicle-requirement/reopen` 两个写口的响应体里带着 `planRefreshState` 等状态列,而**只读**的 `GET .../vehicle-requirement` 没有。于是运营/车务要看一眼"刷新是不是卡住了",唯一手段就是去调 `confirm`;而 `confirm` 一旦走到受控重投分支,会**顺手把状态从 `FAILED` 改回 `PENDING`**——查看这个动作本身把现场改掉了,`FAILED` 因此在管理后台侧根本读不到。
本次在只读的 `GET .../vehicle-requirement` 响应体上补了七个只读字段,**全程不产生任何写**(已有单测 `get_stalledRequirement_performsNoWrite` 与反射断言 `get_declaredShape_carriesNoLockOrTransaction` 钉住这一点)。
🔴 **前端渲染口径必须看 `planRefreshStalled`,不能看 `planRefreshState`**:`planRefreshState=PENDING` 对应**三种互不相同的现实**(正常在途 / 命令已终态失败但状态列没回写 / 已超过时限卡死),三者在 `planRefreshState` 这一个字段上**逐字相同**,无法用它自己区分。详见「四、契约约束与正确调用方式」。
---
## 一、背景
团期正式用车需求上挂着一条"照这份需求刷新 fleet 配车计划"的异步链路(#7442 PR-C2 引入),状态机三态 `NONE`(列值 NULL)/`PENDING`/`DONE`/`FAILED`,配套 `blocked_stage`(阻断阶段快照)、`plan_refresh_replay_count`(人工受控重投次数,上限 5)等库列。这些库列此前只在两个**写口**的响应里露出:
- `POST .../requirement/confirm`(确认整份需求,会顺手触发一次受控重投)
- `POST .../vehicle-requirement/reopen`(重新开窗)
运营想知道"这个团的配车刷新到底怎么样了",只能调 `confirm`——而 `confirm` 会把 `FAILED` 重新入队成 `PENDING`。这意味着:**只要有人查看过一次,`FAILED` 这个最需要被看到的终态就从管理后台侧消失了**。本单在唯一的无副作用观测口——读团期正式用车需求——上补齐这七个字段,让"查一下"这个动作不再改变现场。
| 维度 | 改前 | 改后 |
|------|------|------|
| 看配车刷新状态的入口 | 只能调写口 `confirm`/`reopen` | 新增只读入口 `GET .../vehicle-requirement`(以及共用同一响应体的 `PUT`/`withdraw`/`waive`) |
| 查看动作是否会改变现场 | 会(`confirm` 顺手重投,`FAILED → PENDING`) | 不会(读口全程零写,含反射断言钉住方法上无 `@Lock4j`/`@Transactional`/`@Idempotent`) |
| `PENDING` 的三种现实是否可区分 | 无对应字段,读原值也分不开 | `planRefreshStalled`/`planRefreshStalledReason` 把三种现实拆开 |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 读团期正式用车需求 | GET | `/v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` | 修改接口 | 响应体新增 7 个只读字段(配车刷新观测块) |
⚠️ **该响应体 `GroupVehicleRequirementRespVO` 同时被 `PUT`/`withdraw`/`waive` 三个写口共用**(源码 javadoc 明确标注"四个端点共用这一个响应体"),所以这 7 个字段同样会出现在:
- `PUT /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement`(保存草稿)
- `POST /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/withdraw`(整份撤回)
- `POST /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement/waive`(声明整团免车)
这三个写口的字段值语义与 `GET` 完全相同(都是"该次写入后的现场"只读投影),本次未改动这三个端点自身的请求契约或业务逻辑,故不在上表单独列出,只在这里明确提示;三节共用「接口 1」的「六.5 枚举」「六.6 对比」解释。
`POST .../requirement/confirm` 与 `POST .../vehicle-requirement/reopen` 两个写口**一字未改**——它们的响应体分别是 `GroupBatchRequirementConfirmRespVO` 与 `GroupVehicleReopenRespVO`,是两个独立的 VO,本次未触碰。
---
## 三、接口详情
### 1. 读团期正式用车需求 `GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement`
**VO**: `(PathVariable groupBatchId, 无请求体) → GroupVehicleRequirementRespVO`
(源码核对:`GroupBatchRequirementController.java:180-192`、`GroupVehicleRequirementRespVO.java`、`GroupVehicleRequirementService.java:2480-2545` `fillPlanRefreshObservation`/`resolvePlanRefreshStalledReason`、`GroupVehiclePlanRefreshStalledReason.java`)
#### 使用场景
团期配车管理员编辑页打开时调用,回填该团当前正式用车需求(分组/逐日行/整份状态)。本次改动后,它**同时是"配车刷新死没死"在 admin 侧唯一的无副作用观测口**:页面可以在不触发任何写操作的前提下,展示"这个团的配车刷新是否需要人工介入、卡在哪一步、还能不能自己点确认重试"。该团期尚未形成正式需求时,`data` 整体为 `null`(这不是新行为,改动前就是如此),此时七个新字段自然也不存在。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 团期聚合主键 |
(本端点无请求体、无 Query 参数;本次改动未新增任何入参)
#### 出参字段表
| 字段 | 类型 | 说明 |
|------|------|------|
| requirementId | String | 正式需求主键(雪花) |
| groupBatchId | String | 团期聚合主键 |
| status | String | 需求状态:`DRAFT`/`CONFIRMED`/`DISPATCHED`/`DONE`/`PENDING_RECONFIRM`/`CANCELLED`;本次未改动 |
| version | Integer | 乐观锁版本号 |
| remark | String | 整份备注 |
| confirmedBy | String | 整份确认人(`DRAFT` 时为 `null`) |
| confirmedAt | LocalDateTime | 整份确认时间(`DRAFT` 时为 `null`) |
| **planRefreshState** | String | **【本次新增】** 配车计划刷新状态原值:`null`=从未登记过刷新(多数团期的正常态,不要当异常画)/`PENDING`=命令已发出等 fleet 回来/`DONE`=本轮刷新已闭环/`FAILED`=刷新已被判死。⚠️ 单看本字段判不了"要不要处置",看 `planRefreshStalled` |
| **planRefreshReplayCount** | Integer | **【本次新增】** 人工受控重投累计次数(管理员点确认触发的,不含自动重试);上限 5;从未重投过为 `0` 或 `null` |
| **blockedStage** | String | **【本次新增】** 团期阻断阶段快照:`null`=未阻断;非空(`RESOURCE_PREPARING`/`MATERIAL_PREPARING`/`PENDING_DEPARTURE`)表示该团正卡在这一步等重新配车。展示口径,不是门禁输入——真正挡住团期推进的是 `order_group_batch.vehicle_ready=false` |
| **planRefreshStalled** | Boolean | **【本次新增,本块的行动判据,恒非 null】** `true`=刷新不会自愈、必须有人处置(该团出不了团);`false`=无需动作(含从未登记刷新/正常在途/已闭环)。**判"要不要现在找人"看本字段,不要看 `planRefreshState`** |
| **planRefreshStalledReason** | String | **【本次新增】** 停滞归因:`STATE_FAILED`=刷新已被判死(自动重试耗尽或被 fleet 判定性终结)/`COMMAND_FAILED`=命令行已失败但状态列没回写(同样是死了)/`TIMEOUT`=超过本轮窗口时限仍无结果。`planRefreshStalled=false` 时恒为 `null` |
| **planRefreshTimeoutAt** | LocalDateTime | **【本次新增】** 本轮刷新的"再等就没意义"时刻 = 发起时刻 + 本轮窗口时长(管理员重开时自定,缺省 120 分钟)。仅 `planRefreshState=PENDING` 且已登记发起时刻时有值,其余为 `null` |
| **planRefreshReplayExhausted** | Boolean | **【本次新增,恒非 null】** 人工重投额度是否已耗尽:`true`=已达 5 次上限,再点确认只会收到 `809210`,提示应改为"联系后台排查";`false`=停滞时仍可由管理员重新确认触发一次重投。只在 `planRefreshStalled=true` 时才有行动意义 |
| groups[] | Array | 全部乘车分组(整团免车态为空数组);本次未改动 |
| groups[].groupId / groupCode / vehicleType / serviceStartDate / serviceEndDate / days[] | - | 分组回显字段,本次未改动 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/7201/vehicle-requirement
```
#### 响应示例
数值取自单元测试常量(`GroupVehicleRequirementPlanRefreshObservationTest`,非网关抓包,见 frontmatter `status_note`),展示 `FAILED` 停滞态(`get_planRefreshFailed_projectsStateFailedStalled` 用例覆盖的场景):
```json
{
"code": 200,
"message": "成功",
"data": {
"requirementId": "92001",
"groupBatchId": "7201",
"status": "CONFIRMED",
"version": 3,
"remark": null,
"confirmedBy": "10086",
"confirmedAt": "2026-09-18 09:00:00",
"planRefreshState": "FAILED",
"planRefreshReplayCount": 1,
"blockedStage": "PENDING_DEPARTURE",
"planRefreshStalled": true,
"planRefreshStalledReason": "STATE_FAILED",
"planRefreshTimeoutAt": null,
"planRefreshReplayExhausted": false,
"groups": []
},
"success": true
}
```
补充第二种取值组合——`PENDING` 且未超时(`get_pendingWithinWindow_projectsNotStalled` 用例),只摘录七个新字段:
```json
{
"planRefreshState": "PENDING",
"planRefreshReplayCount": 0,
"blockedStage": "PENDING_DEPARTURE",
"planRefreshStalled": false,
"planRefreshStalledReason": null,
"planRefreshTimeoutAt": "2026-09-20 13:20:00",
"planRefreshReplayExhausted": false
}
```
⚠️ 与上一个响应对比:**`planRefreshState` 都可能是 `PENDING`(本例)或 `FAILED`(上例),但同为 `PENDING` 时 `planRefreshStalled` 还可能是 `true`**(命令已终态失败但状态列没回写、或已超时)——这正是本次改动要解决的"单看原值分不清三种现实"的问题,务必配合下面「四、契约约束」一起读。
#### 空数据 / 降级响应
该团期尚未形成正式需求时,`data` 整体为 `null`(改动前既有行为,本次未改动)。**从未登记过任何刷新的团期**(多数团期的正常态)响应形如:
```json
{
"code": 200,
"data": {
"requirementId": "92001",
"groupBatchId": "7201",
"planRefreshState": null,
"planRefreshReplayCount": null,
"blockedStage": null,
"planRefreshStalled": false,
"planRefreshStalledReason": null,
"planRefreshTimeoutAt": null,
"planRefreshReplayExhausted": false
},
"success": true
}
```
⚠️ **`planRefreshState=null` 不是异常、不是数据缺失**,是状态机里的 `NONE`(列值 NULL),没走过受控重开的团期恒为此态,前端不应画成异常/报错样式。本端点无下游依赖,不存在降级路径(全程只读本地 DB,`planRefreshOutboxId` 非空时最多额外读一次 outbox 命令状态,见「业务边界」)。
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"success": false,
"data": null
}
```
#### 业务边界
- **鉴权**:`permissionGuard.require(..., PERMISSION_DEMAND_CONFIRM)`,与 `PUT`/`withdraw`/`waive` 同一权限码,本次未改动。
- **全程只读,零写**:本次改动新增的装配逻辑 `fillPlanRefreshObservation` 只做 `select`(团期存在性 + 需求主表 + 分组/逐日行 + 至多一次按 `planRefreshOutboxId` 读命令状态),不写任何表、不入队、不触发重投、不取锁、不开事务——已有单测 `get_stalledRequirement_performsNoWrite`(`verifyNoMoreInteractions` 钉死"该发生的读之外一次交互都不许有")与反射断言 `get_declaredShape_carriesNoLockOrTransaction`(`get` 方法上没有 `@Lock4j`/`@Transactional`/`@Idempotent`)双重覆盖。
- **命令状态查询是有条件的**:只有 `planRefreshOutboxId != null` 且当前不是已知终态(非 `NONE`/`DONE`)时才会去查一次 `order_fleet_command_outbox` 的命令状态;从未登记过刷新(`NONE`)或已闭环(`DONE`)时**不会**多发这次查询——`verifyNoInteractions(orderFleetCommandOutboxService)` 在两种场景下都被单测钉住,避免在存量团期上放大成全表的额外查询。
- **`NONE`/`DONE` 一律不判停滞,哪怕 `planRefreshOutboxId` 指向一行已失败的旧命令**:`reopen` 把状态归零时并不清 `planRefreshOutboxId` 这一列,如果直接套用"命令终态失败即停滞"的判据,会把一个刚刚重开、还没登记新刷新的团期误报成"已死",而那正是最不该报警的时刻。
- **`planRefreshTimeoutAt` 与受控重开守卫(`809209`)共用同一份阈值计算**:两处各算一遍会出现"页面说已超时、重开却报 809209 不让开"的自相矛盾,本次改动把阈值计算抽成同一个私有方法复用。
- **`reconfigureScope` 解析失败时兜底 120 分钟**,不会因为一条脏 JSON 让团期在页面上永远显示"还在跑"。
---
## 四、契约约束与正确调用方式
> 本节只写响应字段的正确解读方式,不写 UI 渲染建议之外的后端行为规则(本端点无写操作,没有 payload 校验规则可写)。
### 🔴 判"要不要现在找人"只看 `planRefreshStalled`,不要看 `planRefreshState`
`planRefreshState=PENDING` 对应三种互不相同的现实,在这一个字段上**逐字相同**:
| 现实 | `planRefreshState` | `planRefreshStalled` | `planRefreshStalledReason` |
|---|---|---|---|
| 正常在途,等 fleet 回调 | `PENDING` | `false` | `null` |
| 命令已终态失败,但状态列没回写(回写钩子没落成) | `PENDING` | `true` | `COMMAND_FAILED` |
| 超过本轮窗口时限仍无结果 | `PENDING` | `true` | `TIMEOUT` |
| 已被判死(自动重试耗尽或被 fleet 终结,状态列已回写) | `FAILED` | `true` | `STATE_FAILED` |
只读 `planRefreshState` 拿到的 `PENDING` 无法区分前三行——这正是本单要解决的问题。前端渲染逻辑必须以 `planRefreshStalled` 作为唯一的"要不要报警"判据。
### 渲染口径
- `planRefreshStalled=true` 必须是**显式告警态**(不是静默):
- `planRefreshReplayExhausted=false` → 引导文案「重新确认」
- `planRefreshReplayExhausted=true` → 文案改为「联系后台排查」,**别让运营继续点确认**(再点只会收到 `809210`)
- `planRefreshStalled=false` 且 `planRefreshState=PENDING` → 展示「刷新中,预计 `planRefreshTimeoutAt` 前出结果」
- `planRefreshState=null` → 不展示刷新相关的任何提示条(这是多数团期的正常态,不是"缺数据")
- `planRefreshState=DONE` → 不展示告警,可选展示"已完成"的静态标记
### 无新增错误码
本端点是纯读口,本次改动不引入任何新错误码;`809210`/`809209` 是既有错误码(`#7442`/`#7442 PR-C2` 已定义),仅在前端引导用户点击**其他写口**(`confirm`/`reopen`)时才可能命中,本端点自身不会抛出它们。
---
## 六、边界行为
- 未登录/网关未透传角色 → 401(网关拦截),既有行为
- 团期不存在 → `589500`,既有行为
- 该团期尚未形成正式需求 → `data=null`,既有行为,本次未改动
- 老数据兼容:改动前落库的存量需求行 `plan_refresh_state`/`plan_refresh_replay_count`/`blocked_stage` 三列均可能是 `NULL`(未登记过刷新),响应对应投影为 `planRefreshState=null`/`planRefreshReplayCount=null`/`blockedStage=null`、`planRefreshStalled=false`、`planRefreshReplayExhausted=false`,**不是异常**
- `reconfigure_scope` 解析失败(脏 JSON)时,超时窗口按默认 120 分钟兜底计算,不影响响应本身返回成功
---
## 六.5、枚举 / 数据字典
### `planRefreshState`(`GroupVehicleRequirementRespVO.planRefreshState`,源码 `GroupVehiclePlanRefreshState` 状态机枚举,列值 NULL 对应 `NONE`)
**所属字段**: `planRefreshState` | **类型**: `String`(可空)
| 值 | 中文 | 说明 |
|----|------|------|
| `null` | 未登记 | 状态机 `NONE`,尚未登记过任何刷新;多数团期的正常态 |
| `PENDING` | 刷新中 | 刷新命令已登记,等 fleet 刷完并把就绪意图投回来;需结合 `planRefreshStalled` 判断是否正常 |
| `DONE` | 已闭环 | 就绪回调已通过两级判定并应用,本轮刷新完成 |
| `FAILED` | 已判死 | 刷新已耗尽重试预算或被 fleet 判定性终结;不放行团期,出团门②持续拒绝 |
### `planRefreshStalledReason`(`GroupVehicleRequirementRespVO.planRefreshStalledReason`,源码 `GroupVehiclePlanRefreshStalledReason` 枚举,仅在读口投影中使用,不落库)
**所属字段**: `planRefreshStalledReason` | **类型**: `String`(可空,`planRefreshStalled=false` 时恒为 `null`)
| 值 | 中文 | 说明 |
|----|------|------|
| `STATE_FAILED` | 状态已判死 | `planRefreshState=FAILED`;去查 fleet 为什么刷不动 |
| `COMMAND_FAILED` | 命令已死未回写 | 状态列仍 `PENDING`,但命令行已终态失败;去查回写状态的终态钩子为什么没落成 |
| `TIMEOUT` | 已超时 | 状态列仍 `PENDING`,命令未终态失败,但已超过本轮窗口时限;去查命令是否卡在队列里 |
⚠️ 本枚举**只进响应,不进库、不进 `WHERE`**——它是三条既有判据(状态列/命令行状态/超时)在读的那一刻算出来的结论,不是持久化状态。
### `blockedStage`(`GroupVehicleRequirementRespVO.blockedStage`,值域同团期状态机的阶段码)
**所属字段**: `blockedStage` | **类型**: `String`(可空)
| 值 | 说明 |
|----|------|
| `null` | 未阻断 |
| `RESOURCE_PREPARING` | 团期阶段:资源准备中 |
| `MATERIAL_PREPARING` | 团期阶段:物资准备中 |
| `PENDING_DEPARTURE` | 团期阶段:待出团 |
⚠️ 本字段是**展示口径**,不是门禁输入:真正挡住团期推进的是 `order_group_batch.vehicle_ready=false`,本字段只是"卡在哪一步"的留痕快照。
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `GET .../vehicle-requirement` 响应 `planRefreshState` | 不存在(只在 `confirm`/`reopen` 两个写口的响应里有) | **新增**只读投影 |
| 同响应 `planRefreshReplayCount` | 不存在 | **新增** |
| 同响应 `blockedStage` | 不存在 | **新增** |
| 同响应 `planRefreshStalled` | 不存在(任何端点都没有这个派生结论) | **新增**,全站唯一来源 |
| 同响应 `planRefreshStalledReason` | 不存在 | **新增**,全站唯一来源 |
| 同响应 `planRefreshTimeoutAt` | 不存在 | **新增**,全站唯一来源 |
| 同响应 `planRefreshReplayExhausted` | 不存在 | **新增**,全站唯一来源 |
| `PUT`/`withdraw`/`waive` 三个写口的响应(共用同一 VO) | 同样没有这七个字段 | 同样新增(值 = 该次写入后的现场) |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 查看配车刷新是否卡住 | 只能调 `confirm`,而它会顺手做一次受控重投,把 `FAILED` 改回 `PENDING`——**查看动作本身会改变现场** | 调只读的 `GET` 即可看到,**零副作用** |
| `PENDING` 的三种现实(在途/命令已死未回写/超时)是否可区分 | 不可区分(原值三者相同) | 可区分(`planRefreshStalled`/`planRefreshStalledReason`) |
| `FAILED` 终态在 admin 侧是否可读到 | 事实上读不到(查看会把它改成 `PENDING`) | 可以,因为查看不再产生写 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否——本次只在既有响应体上新增字段,不删除、不改名、不改变既有字段(`requirementId`/`status`/`groups[]` 等)的类型或取值规则;旧前端忽略这七个新字段,行为与改动前逐字节一致
- **前端是否必须同步上线**: 否,是纯新增只读字段,前端可延后接入;接入前不影响现有编辑页回填逻辑
- **前端 workaround 清理点**: 若前端此前为了看一眼 `FAILED` 状态而在编辑页刻意调用过 `confirm`(哪怕不点击真正的确认按钮,只为了读响应里的状态字段),这类 workaround **必须撤除**——继续这么用会重新触发受控重投,产生非预期的写副作用(把 `FAILED` 悄悄改回 `PENDING`,还会消耗一次人工重投额度)
---
## 七、不影响范围
- **仅影响**: `GET /v3/admin/order/group-batch/{groupBatchId}/vehicle-requirement` 响应体新增的七个只读字段(以及共用同一 VO 的 `PUT`/`withdraw`/`waive` 三个写口的响应)
- **零影响**:
- `POST .../requirement/confirm`、`POST .../vehicle-requirement/reopen` 两个写口的请求/响应契约**一字未改**(各自独立的 VO,本次未触碰)
- `GET`/`PUT`/`withdraw`/`waive` 四端点原有字段(`requirementId`/`status`/`version`/`remark`/`confirmedBy`/`confirmedAt`/`groups[]` 等)**零变更**
- 无新增错误码,既有错误码(`589500`/`809209`/`809210` 等)行为不变
- **无新增 DB 列/表/索引**:`plan_refresh_state`/`plan_refresh_replay_count`/`blocked_stage`/`plan_refresh_requested_at`/`plan_refresh_outbox_id` 五列均由 `#7442 PR-C2` 建好,本次只是把已有列在只读口上多投影一次,外加一个只存在于响应层、不落库的派生枚举 `GroupVehiclePlanRefreshStalledReason`
- 受控重开守卫(`809209`/`809210`)的判定逻辑本身未改,本次只是把它复用的阈值计算函数抽出来给只读投影共用
- 团期配车其余端点(就绪判定、共用关系、派单预校验/候选等,见 #7444/#8013 changelog)零影响
---
## 八、测试环境已验证
⚠️ **本篇尚无测试服/网关读数**(`backend_status=pending`、`gateway_status=pending`,hl-order-service-v3 尚未部署到该提交),如实标注为**待部署后补**,不把单测读数伪装成测试环境验证。
以下是随 PR #8033 一并合入的单元测试证据(`GroupVehicleRequirementPlanRefreshObservationTest`,纯 Mockito,交接时未重新执行,仅列出用例名供核实其覆盖范围):
```
get_planRefreshFailed_projectsStateFailedStalled — FAILED 终态可见且判为停滞(STATE_FAILED)
get_noPlanRefreshRegistered_projectsQuiet — 从未登记过刷新一律不报警,且不多查 outbox
get_planRefreshDoneWithStaleFailedCommand_projectsQuiet — DONE 不因残留失败命令行被误报,且不多查 outbox
get_pendingWithinWindow_projectsNotStalled — PENDING 且未超时 = 不停滞,给出 timeoutAt
get_pendingWithFailedCommand_projectsCommandFailed — PENDING 但命令已死 = 停滞(COMMAND_FAILED)
get_pendingBeyondWindow_projectsTimeout — PENDING 且超时 = 停滞(TIMEOUT)
get_pendingWithUnparsableScope_fallsBackToDefaultWindow — 脏 JSON 兜底默认 120 分钟窗口
get_replayCount_marksExhaustedAtLimit(参数化 4 组) — replayCount≥5 时 planRefreshReplayExhausted=true
get_stalledRequirement_performsNoWrite — 读链路零写(verifyNoMoreInteractions 收口)
get_declaredShape_carriesNoLockOrTransaction — 方法签名上无 @Lock4j/@Transactional/@Idempotent
```
**待办**:管理者安排 hl-order-service-v3 部署测试服到 `587af48cd` 之后,需补一次真实网关调用(至少覆盖"从未登记"「PENDING 未超时」「FAILED」三种形态),核实七个字段的真实返回值,再把 `backend_status`/`gateway_status` 置 `deployed`/`verified` 并推送本文件。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7988](https://git.1814.love:8443/wx/HL/issues/7988)
- 关联 PR: [wx/HL#8033](https://git.1814.love:8443/wx/HL/pulls/8033)(squash 合并至 dev-v3 @`587af48cd`)
- 配车刷新状态机与受控重开守卫的背景见 `#7442 PR-C2`(`GroupVehiclePlanRefreshState`/`GroupVehiclePlanRefreshStateMachineConfig` 源文件头注释)
## 关联 / 联系人
### 链接
- **Issue**: [#7988](https://git.1814.love:8443/wx/HL/issues/7988)
- **PR**: [#8033](https://git.1814.love:8443/wx/HL/pulls/8033)
- **Merge commit**: [587af48cd](https://git.1814.love:8443/wx/HL/commit/587af48cda93139f3fd4aa7ca06bc7b9419be52b)
### 联系人
- **后端负责人**: wx(GIT)
- **前端负责人**: mmg