--- 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