docs(changelog): #7987 #7988 #8013 网关实测通过,gateway_status 置 verified,并订正 #7987 一处契约偏差
changelog-filename-gate / validate (push) Failing after 2s

2026-09-20 18:22-18:46 经网关 api.test.1814.love:9443 实测(账号 cw_test_7444):

- #7988 GET .../vehicle-requirement:七个只读观测字段全部出现,字段名/类型与正文
  一字对应,逐字段对照无出入。
- #7987 GET .../status-logs:开窗(BATCH_VEHICLE_REQUIREMENT_REOPENED)与重配
  (BATCH_VEHICLE_DISPATCH_RECONFIGURED)两类事件均取到,extra 各项齐全。
- #8013 POST/GET .../share-groups:只改 costBearer 不改成员,写出的历史行是
  COST_BEARER_CHANGED 而非 MEMBER_ADDED,且按正文声明的口径用事后 GET 查证,
  没有拿 POST 响应体推断。

订正 #7987 一处契约偏差(5 个位置):重配事件的 operatorId 正文写字符串 "SYSTEM",
实测为 null,与库里其它 SYSTEM 类事件(如 BATCH_VEHICLE_REQUIREMENT_DONE)惯例一致,
判为文档写错而非实现错。前端若按原文档判空会得到相反预期,故订正与发布同一批。

backend_status 的判据是 merge-base --is-ancestor 对各自 squash 提交与测试服部署点
(fleet 311dc92ee / order-v3 e179e09bd)逐条为真,不是「已在主线」这种弱判据。

verified_at 留空:该字段归 frontend_status 用,与 gateway 的验证时刻是两码事。

同批还有 #7444 与 #7973 两份草稿未推送,gateway_status 如实保持 pending——
#7444 缺 reconfigure / restore-cancel happy path / DELETE 三处证据,
#7973 的 confirmCrossResident 入参未被真正走到(目标资源已占用,未触发 605036 分支)。
「同一个端点被调通了」不等于「本篇登记的那个入参被验过了」。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-20 19:00:10 +08:00
共同撰写人 Claude Opus 5
父节点 304477d880
当前提交 91cca38c2e
共修改 3 个文件,包含 1318 行新增和 0 行删除
@@ -0,0 +1,495 @@
---
schema: "hl-changelog/v2"
ticket: "7987"
title: "团期时间线新增开窗与重配两类事件,事件类型枚举扩展"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: "hl-ui"
target_release: ""
verified_at: ""
status_note: "gateway_status=verified 的依据:2026-09-20 18:22-18:46 经网关 https://api.test.1814.love:9443 实测(账号 cw_test_7444)。GET /v3/admin/order/group-batch/{id}/status-logs 在两个批次上分别取到 BATCH_VEHICLE_REQUIREMENT_REOPENED(开窗,extra 含 windowMinutes/blockedStage/expiresAt/scopeGroupCodes/scopeDates 五项齐全)与 BATCH_VEHICLE_DISPATCH_RECONFIGURED(重配),eventType/eventTypeName/changeType=DATA/extra 各字段逐一对照一致。⚠️ 本次实测发现并已订正一处契约偏差:重配事件的 operatorId 原写字符串 SYSTEM,实测为 null(与库里其它 SYSTEM 类事件惯例一致),判为文档写错而非实现错,已在字段表/字段值/JSON 示例/注意事项共 5 处改完。前端若按原文档判空会得到相反预期,故这条订正与发布同一批。 【上一轮原注】backend_status=deployed 的判据(2026-09-20 18:35 复核):hl-order-service-v3 测试服部署点 e179e09bd(2026-09-20 17:55:02 发布),`git merge-base --is-ancestor ab92377c7 e179e09bd` = true,故本篇端点的代码确已在测试服运行的字节里。⚠️ 这是一次**时点读数**:测试服由多会话共用,随时可能被滚到别的提交;origin/dev-v3 在本次复核时已前进到 f56692a51,落后的是部署点不是本篇。 gateway_status 保持 pending——本会话未对本篇端点做任何真实网关调用,正文示例值的来源已在各小节逐处标注(单元测试字面量 / @ApiModelProperty example 声明),不是抓包。待网关复验后再置 verified,**不得为了让门禁变绿改这个位**。"
updated_at: "2026-09-20"
base: "dev-v3"
---
# order-v3: 团期时间线新增开窗与重配两类事件
> **存放目录**: 二期(v3) → `changelogs-v2/{YYYY-MM}/`
>
> **服务**: hl-order-v3 (端口 8021)
> **PR**: #8039
> **Issue**: #7987
> **日期**: 2026-09-20
> **影响范围**: 管理后台 - 团期详情页 - 状态流水/时间线
---
## ⚠️ 关键变化
后端在团期时间线(`group_batch_status_log` 表)新增两种事件记录:
- **BATCH_VEHICLE_REQUIREMENT_REOPENED**(开窗事件)—— 管理员显式开启受控重配窗口时写入
- **BATCH_VEHICLE_DISPATCH_RECONFIGURED**(重配事件)—— fleet 回调确认重配生效时写入
**前端必须新增这两个 event_type 值的渲染分支**,否则这两类事件在管理后台团期时间线上将无法正常显示其标签名(见下文「③ 前端渲染缺口」)。
---
## 一、背景
#7987 实施"受控重配窗口"机制:管理员可在团期进入待出发后仍显式开一个时间限制的窗口,允许车务在此期间重新排车。此前这两件事的事实只落在需求行的三个字段(`reconfigure_reason` / `reconfigure_opened_by` / `reconfigure_opened_at`),但运营打开团期详情页时看不到这些列,无法追溯窗口何时开过。
本次变更补上团期时间线的留痕,让管理员在团期详情页就能看到「谁在何时以什么原因开了重配窗口」与「窗口内换车是否生效」。
---
## 二、变更接口清单
既存接口(#7304 落地)返回值扩展:
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期状态流水查询 | GET | `/v3/admin/order/group-batch/{id}/status-logs` | 响应新增 event_type 值 | 新增两种事件类型 |
---
## 三、接口详情
### 1. 团期状态流水查询 `GET /v3/admin/order/group-batch/{id}/status-logs`
**VO**: `GroupBatchStatusLogItemVO` (既存响应 VO,新增 eventTypeName 映射值)
#### 使用场景
管理后台团期详情页的状态流水/时间线面板,用于向运营展示该团期的历史状态变化与重要操作记录。本次变更使该时间线新增两类事件的可见性。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| id | Path | Long | ✅ | - | 团期主订单 ID |
#### 出参 `Result<List<GroupBatchStatusLogItemVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| logId | String | 流水记录 ID |
| groupBatchId | Long | 团期主订单 ID |
| changeType | String | 变更类型:STATUS(状态流转) / DATA(数据变更) |
| eventType | String | 事件类型代码(如 BATCH_VEHICLE_REQUIREMENT_REOPENED) |
| eventTypeName | String | 事件类型中文名(**新增两种**:团车受控重开·已开配车窗口 / 团车受控重配·车辆安排已变更) |
| fromStatus | String | 状态前值代码(如 PENDING_DEPARTURE) |
| fromStatusName | String | 状态前值中文名 |
| toStatus | String | 状态后值代码 |
| toStatusName | String | 状态后值中文名 |
| content | String | 事件文本内容(前端直接渲染) |
| reason | String | 事件原因/备注(可为 null) |
| operatorType | String | 操作人类型:ADMIN / SYSTEM / MQ |
| operatorId | String | 操作人 ID。⚠️ **2026-09-20 网关实测订正**:**`operatorType=SYSTEM` 时本字段是 `null`,不是字符串 `"SYSTEM"`**(与库里其它 SYSTEM 类事件如 `BATCH_VEHICLE_REQUIREMENT_DONE` 的惯例一致)。前端判空请按 `null` 处理 |
| operatorName | String | 操作人名称 |
| extra | Map<String, Object> | 结构化附加数据,字段随事件类型变化(见下文"新增事件详解") |
| changedAt | String | 事件发生时刻(ISO 8601) |
#### 新增事件类型详解
**BATCH_VEHICLE_REQUIREMENT_REOPENED 事件**
- `eventType`: `"BATCH_VEHICLE_REQUIREMENT_REOPENED"`
- `eventTypeName`: `"团车受控重开·已开配车窗口"`
- `changeType`: `"DATA"` (状态内数据变更,fromStatus = toStatus)
- `operatorType`: `"ADMIN"`
- `operatorId`: 开窗管理员 ID
- `operatorName`: 开窗管理员真名
- `content`: "开启受控重配窗口:允许车务在 X 分钟内重新安排本团车辆(授权 N 个乘车分组 / M 个行程日,2026-09-20 10:30:00 前有效)。此前团期已进入「待出发」,正常不可再配车。"
- `reason`: 管理员必填的开窗原因(如"团期特殊变更")
- `extra`: 结构化数据
```json
{
"requirementId": "需求行主键(字符串)",
"requirementVersion": 需求行版本号(整数),
"blockedStage": "PENDING_DEPARTURE",
"windowMinutes": 120,
"expiresAt": "2026-09-20T12:30:00",
"scopeGroupCodes": ["乘车分组编号数组"],
"scopeDates": ["2026-09-21", "2026-09-22"]
}
```
**BATCH_VEHICLE_DISPATCH_RECONFIGURED 事件**
- `eventType`: `"BATCH_VEHICLE_DISPATCH_RECONFIGURED"`
- `eventTypeName`: `"团车受控重配·车辆安排已变更"`
- `changeType`: `"DATA"` (状态内数据变更,fromStatus = toStatus)
- `operatorType`: `"SYSTEM"`
- `operatorId`: **`null`**(⚠️ **2026-09-20 网关实测订正**:原写 `"SYSTEM"`,实测为 `null`)
- `operatorName`: `"系统"`
- `content`: "本团车辆安排已被重新排定:车务在受控重配窗口内完成换车,新的配车计划(版本 20260920_v1)已生效,原计划作废。开窗授权人:张三;具体经办人见车务配车记录。"
- `reason`: 开窗时管理员填写的原因(沿用窗口的 reason,非重配操作人的备注)
- `extra`: 结构化数据
```json
{
"requirementId": "需求行主键(字符串)",
"requirementVersion": 需求行版本号(整数),
"planVersion": "fleet 配车计划版本号(字符串)",
"reconfigureOpenedBy": "开窗管理员 ID(与前一条事件的 operatorId 对应)",
"reconfigureOpenedAt": "2026-09-20T10:15:30"
}
```
#### 请求示例
```json
GET /v3/admin/order/group-batch/8000001/status-logs
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": [
{
"logId": "12345",
"groupBatchId": "8000001",
"changeType": "DATA",
"eventType": "BATCH_VEHICLE_REQUIREMENT_REOPENED",
"eventTypeName": "团车受控重开·已开配车窗口",
"fromStatus": "PENDING_DEPARTURE",
"fromStatusName": "待出发",
"toStatus": "PENDING_DEPARTURE",
"toStatusName": "待出发",
"content": "开启受控重配窗口:允许车务在 120 分钟内重新安排本团车辆(授权 2 个乘车分组 / 3 个行程日,2026-09-20T12:30:00 前有效)。此前团期已进入「待出发」,正常不可再配车。",
"reason": "临时增加3人,需调整座位分组与用车方案",
"operatorType": "ADMIN",
"operatorId": "1001",
"operatorName": "李四",
"extra": {
"requirementId": "7000001",
"requirementVersion": 2,
"blockedStage": "PENDING_DEPARTURE",
"windowMinutes": 120,
"expiresAt": "2026-09-20T12:30:00",
"scopeGroupCodes": ["GRP001", "GRP002"],
"scopeDates": ["2026-09-21", "2026-09-22", "2026-09-23"]
},
"changedAt": "2026-09-20T10:30:00"
},
{
"logId": "12346",
"groupBatchId": "8000001",
"changeType": "DATA",
"eventType": "BATCH_VEHICLE_DISPATCH_RECONFIGURED",
"eventTypeName": "团车受控重配·车辆安排已变更",
"fromStatus": "PENDING_DEPARTURE",
"fromStatusName": "待出发",
"toStatus": "PENDING_DEPARTURE",
"toStatusName": "待出发",
"content": "本团车辆安排已被重新排定:车务在受控重配窗口内完成换车,新的配车计划(版本 20260920_v1)已生效,原计划作废。开窗授权人:李四;具体经办人见车务配车记录。",
"reason": "临时增加3人,需调整座位分组与用车方案",
"operatorType": "SYSTEM",
"operatorId": null,
"operatorName": "系统",
"extra": {
"requirementId": "7000001",
"requirementVersion": 2,
"planVersion": "20260920_v1",
"reconfigureOpenedBy": "1001",
"reconfigureOpenedAt": "2026-09-20T10:30:00"
},
"changedAt": "2026-09-20T11:45:00"
}
],
"success": true
}
```
#### 空数据 / 降级响应
```json
{
"code": 200,
"data": [],
"success": true
}
```
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 接口返回的时间线按 `changedAt` 升序排列(从最早到最新)
- 两个新事件都属于 `DATA` 类(状态内数据变更),不涉及团期主状态 `batch_status` 变化
- 库里若有历史脏值或未知 event_type,后端 eventTypeLabel 返回 null,前端需兜底显示原始 code 值
---
## 四、契约约束与正确调用方式
> 本节说明后端接受请求、返回响应的规则。
### ✅ 正确 / ❌ 错误请求对照
| 场景 | 请求示例 | 结果 |
|------|---------|------|
| ✅ 查询既存团期的时间线 | `GET /v3/admin/order/group-batch/8000001/status-logs` | 200 + 时间线列表 |
| ✅ 无流水时返回空列表 | `GET /v3/admin/order/group-batch/9999999/status-logs`(不存在团期) | 200 + `data: []` |
| ❌ 团期 ID 非数字 | `GET /v3/admin/order/group-batch/abc/status-logs` | 400 |
### 字段约束与兼容性规则
- **eventType 兼容性**:库里可能存在本版本未定义的历史 event_type 值 → 后端 eventTypeLabel 返回 null → 前端应兜底显示原始 code 值,而不是报错
- **extra 字段**:根据 eventType 不同而变化(见下文详解);前端需根据 eventType 判断应读取哪些 extra 字段,勿假设所有事件的 extra 结构相同
- **operatorType 非 ADMIN 时**:⚠️ **2026-09-20 网关实测订正**:**`operatorId` 是 `null`、`operatorName` 是 `"系统"`——两个字段形态不同,不要当成同一回事**(原文把它们合并描述成「可能不是真实人名」,会让前端以为 `operatorId` 也有值) → 前端应按类型判断如何渲染,勿硬编码为"管理员"
- **reason 字段**:可为 null(仅新增事件强制填写,既有历史事件可能无此字段) → 前端应做 null check
---
## 六.5、枚举:新增事件类型
### eventType(GroupBatchLogEventType)
**所属字段**: `GroupBatchStatusLogItemVO.eventType` | **类型**: `String` | **枚举类**: `com.hulalv.order.groupbatch.enums.GroupBatchLogEventType`
新增两个枚举值(共有 N+2 个值):
| 值 | 中文名 | 说明 | changeType | 何时写入 |
|----|------|------|-----------|---------|
| `BATCH_VEHICLE_REQUIREMENT_REOPENED` | 团车受控重开·已开配车窗口 | 管理员显式开启受控重配窗口,允许车务在时间限制内重新排车 | DATA | 调用 POST `/v3/admin/order/group-batch/{id}/vehicle-requirement/reopen` 时 |
| `BATCH_VEHICLE_DISPATCH_RECONFIGURED` | 团车受控重配·车辆安排已变更 | fleet 就绪回调确认重配生效,新车辆计划已应用,原计划作废 | DATA | fleet Outbox 回调解除受控重开阻断时 |
### operatorType(操作人类型)
**所属字段**: `GroupBatchStatusLogItemVO.operatorType` | **类型**: `String`
既有三种值,新增事件涉及的分别是:
| 值 | 中文名 | 说明 | 示例事件 |
|----|------|------|---------|
| `ADMIN` | 管理员 | 真实的管理后台登录用户 | BATCH_VEHICLE_REQUIREMENT_REOPENED |
| `SYSTEM` | 系统 | 无 HTTP 请求上下文的自动操作(MQ / 定时任务 / 回调) | BATCH_VEHICLE_DISPATCH_RECONFIGURED |
| `MQ` | MQ 事件 | MQ 消费触发(如支付/退款回调) | 既有其他事件 |
---
## 四、前端需求与缺口说明
### ① eventTypeLabel 映射(前端渲染分支)
**后端实现**(`GroupBatchStatusLogService.java:237-248`):
```java
private String eventTypeLabel(String value) {
if (value == null) {
return null;
}
for (GroupBatchLogEventType type : GroupBatchLogEventType.values()) {
if (type.getValue().equals(value)) {
return type.getLabel();
}
}
return null; // 未知值返回 null
}
```
**当前状况**:
- 后端遍历枚举查找 event_type,找到则返回 label,否则返回 null
- 前端收到的 eventTypeName 字段值为 null 时,该行事件在时间线上**显示为空白**(既不报错、也不显示原始值,这是现状)
**前端必须新增的映射**:
```typescript
// 示例伪码
const eventTypeLabels = {
"BATCH_VEHICLE_REQUIREMENT_REOPENED": "团车受控重开·已开配车窗口",
"BATCH_VEHICLE_DISPATCH_RECONFIGURED": "团车受控重配·车辆安排已变更",
// ... 既有其他 event_type ...
};
```
**不加分支的后果**:这两类事件在管理后台团期时间线上无法显示其事件名称,运营只看得到时刻/操作人/备注,但看不出"这是什么事件",影响运营追溯。
### ② 重配事件的操作人恒为 SYSTEM(不能拿 extra 字段顶替)
**源码事实**(`GroupBatchService.java:1089-1118`,recordReconfigureTrail 方法):
```java
// 就绪回调中,无 HTTP 请求上下文,operatorResolver.resolve() 返回 SYSTEM
Operator operator = operatorResolver.resolve(); // → Operator.system()
groupBatchStatusLogService.recordDataChange(groupBatchId,
GroupBatchLogEventType.BATCH_VEHICLE_DISPATCH_RECONFIGURED,
batch == null ? null : GroupBatchStatus.fromCode(batch.getBatchStatus()),
content, window.reason(), extra);
```
**重配事件的字段值**:
- `operatorType`: `"SYSTEM"`
- `operatorId`: **`null`**(⚠️ **2026-09-20 网关实测订正**:原写 `"SYSTEM"`,实测为 `null`)
- `operatorName`: `"系统"`
- `extra.reconfigureOpenedBy`: 开窗管理员 ID(**不是**重配操作人)
- `extra.reconfigureOpenedAt`: 开窗时刻(**不是**重配时刻,重配时刻在 changedAt)
**这不是 bug,是设计**:
- 重配的真实经办人(谁在车务后台点的"重配"按钮)只存在于 fleet 侧的 `fleet_group_dispatch.updated_by`
- order-v3 是接收者,fleet 回调进来时没有经办人载荷
- `extra.reconfigureOpenedBy` 答的是"**谁授权开的窗**"(过去的管理动作),与"**谁换的车**"(当前的车务动作)是两个不同的人、不同的时刻
**前端若把 `extra.reconfigureOpenedBy` 显示成"经办人",就是把一个错误的责任人写到审计页面上**。
- ✅ 正确做法:operatorName 显示"系统",content 或 extra.reconfigureOpenedBy 里可补充说明"开窗人是谁"
- ❌ 错误做法:拿 extra.reconfigureOpenedBy 冒充 operatorId/operatorName 展示成经办人
### ③ 开窗事件的操作人是真实人(与重配事件不同)
**源码事实**(`GroupVehicleRequirementService.java:2305-2347`,recordReopenTrail 方法):
```java
public GroupVehicleReopenRespVO doReopen(Long groupBatchId,
GroupVehicleRequirementReopenReqVO req,
String operatorId) {
// ... 业务逻辑 ...
recordReopenTrail(groupBatchId, batch, active, req, windowMinutes, expiresAt);
}
private void recordReopenTrail(...) {
// recordDataChange 内部调 operatorResolver.resolve(),因为这是 HTTP 请求上下文
// 会从 AdminAuthInterceptor 注入的 AdminContext 里取当前登录管理员
groupBatchStatusLogService.recordDataChange(groupBatchId,
GroupBatchLogEventType.BATCH_VEHICLE_REQUIREMENT_REOPENED,
GroupBatchStatus.fromCode(batch.getBatchStatus()), content, req.getReason(), extra);
}
```
**开窗事件的操作人**:
- `operatorType`: `"ADMIN"`(不是 SYSTEM)
- `operatorId`: 调用 reopen 端点的管理员 ID(真实人)
- `operatorName`: 管理员真名
**两个事件的操作人不同的原因**:
- 开窗:用户通过管理后台 HTTP 端点主动触发 → 上下文有 AdminContext → operatorResolver 返回真实人
- 重配:fleet 异步回调 → 无 HTTP 请求上下文 → operatorResolver 返回 SYSTEM
**前端不能混淆**:别以为所有时间线事件的 operatorName 都是真实人,重配这条特殊。
---
## 五、数据库行为
新增行落在 `group_batch_status_log` 表:
| 事件 | 写入时机 | from_status | to_status | change_type | 示例 |
|------|----------|------------|-----------|-------------|------|
| BATCH_VEHICLE_REQUIREMENT_REOPENED | 调用 POST `/v3/admin/order/group-batch/{id}/vehicle-requirement/reopen` 时 | PENDING_DEPARTURE | PENDING_DEPARTURE | DATA | 管理员张三在 2026-09-20 10:30 开窗 |
| BATCH_VEHICLE_DISPATCH_RECONFIGURED | fleet 就绪回调解除受控重开阻断时 | PENDING_DEPARTURE | PENDING_DEPARTURE | DATA | fleet 确认换车后在 2026-09-20 11:45 生效 |
**关键**:两个事件都不改变团期 batch_status,`from_status` 与 `to_status` 相同。
---
## 六、边界行为
- **库里存在历史脏值** → 后端 eventTypeLabel 返回 null → 前端时间线该行显示空白或兜底处理
- **并发写入** → 开窗事务与重配事务独立,都跟随各自的外层事务(不丢失)
- **幂等性** → 开窗已有生效窗口时幂等返回既有令牌(不重复写库);普通首配回调、常规刷新回调若无受控重开阻断时 → CAS 0 行返回 → 不落本事件(常态)
---
## 六.6、修改前后对比
### 接口行为对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 时间线中开窗操作的可见性 | 三个字段存库但页面不展示 | 新增一条 DATA 类事件,时间线上可见 |
| 时间线中重配操作的可见性 | 无记录 | 新增一条 DATA 类事件,标明"车已换、谁开的窗、老计划作废" |
| 事件类型枚举 | 共 N 种 | 新增 2 种,共 N+2 种 |
| 前端事件渲染 | 既有 N 种 event_type 的映射 | 需新增 BATCH_VEHICLE_REQUIREMENT_REOPENED / BATCH_VEHICLE_DISPATCH_RECONFIGURED 两条映射 |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否。既存接口返回值扩展,老 event_type 不变
- **前端是否必须同步上线**: **是**。不新增渲染映射会导致新增两类事件在时间线上显示为空白
- **前端 workaround 清理点**:
- 若前端已有"event_type 未知时显示原始 code"的兜底逻辑,可临时显示 `BATCH_VEHICLE_REQUIREMENT_REOPENED` 等原始值
- 但正式上线必须使用中文标签,不能长期显示原始值(运营不认识)
---
## 七、不影响范围
- **仅影响**: 管理后台 - 团期详情页 - 状态流水/时间线面板(新增两类事件的渲染)
- **零影响**:
- 团期主状态推进(batch_status 机制不变)
- 用车需求状态机(需求行读写逻辑不变)
- 订单详情、C 端行程页面(无此数据源)
- 旧团期数据(存量团期的时间线不会补充新事件)
---
## 八、测试环境已验证
后端逻辑验证(提交 ab92377c7):
```
POST /v3/admin/order/group-batch/8000001/vehicle-requirement/reopen
请求体: { "scopeGroupCodes": ["GRP001"], "scopeDates": ["2026-09-21"], "reason": "test reason" }
→ 200, group_batch_status_log 新增 1 行 BATCH_VEHICLE_REQUIREMENT_REOPENED ✓
fleet 就绪回调(模拟)
request: {"groupBatchId": 8000001, "requirementId": 7000001, "requirementVersion": 2, "planVersion": "v1"}
→ 200, group_batch_status_log 新增 1 行 BATCH_VEHICLE_DISPATCH_RECONFIGURED ✓
GET /v3/admin/order/group-batch/8000001/status-logs
→ 200, 返回值中 eventTypeName 正确映射(后端已验证) ✓
前端渲染验证:**待前端补充映射后联调**
```
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|-------|-------|------|---------|
| #7986 | #7986 | 受控重配窗口核心机制(团车需求侧) | ✅ 有效 |
| #8039 | #7987 | 本 PR - 补团期时间线留痕 | ✅ 最新 |
---
## 十、相关文档
- **后端枚举定义**: [`hl-order-service-v3/src/main/java/com/hulalv/order/groupbatch/enums/GroupBatchLogEventType.java`](https://git.1814.love:8443/wx/HL/blob/dev-v3/hl-order-service-v3/src/main/java/com/hulalv/order/groupbatch/enums/GroupBatchLogEventType.java)
- BATCH_VEHICLE_REQUIREMENT_REOPENED 定义:第 314-344 行
- BATCH_VEHICLE_DISPATCH_RECONFIGURED 定义:第 347-376 行
- **时间线服务**: [`hl-order-service-v3/src/main/java/com/hulalv/order/groupbatch/service/GroupBatchStatusLogService.java`](https://git.1814.love:8443/wx/HL/blob/dev-v3/hl-order-service-v3/src/main/java/com/hulalv/order/groupbatch/service/GroupBatchStatusLogService.java)
- eventTypeLabel 实现:第 237-248 行
- **开窗留痕**: [`hl-order-service-v3/src/main/java/com/hulalv/order/groupbatch/service/GroupVehicleRequirementService.java`](https://git.1814.love:8443/wx/HL/blob/dev-v3/hl-order-service-v3/src/main/java/com/hulalv/order/groupbatch/service/GroupVehicleRequirementService.java)
- recordReopenTrail 方法:第 2329-2387 行
- **重配留痕**: [`hl-order-service-v3/src/main/java/com/hulalv/order/groupbatch/service/GroupBatchService.java`](https://git.1814.love:8443/wx/HL/blob/dev-v3/hl-order-service-v3/src/main/java/com/hulalv/order/groupbatch/service/GroupBatchService.java)
- recordReconfigureTrail 方法:第 1089-1118 行
---
## 关联 / 联系人
### 链接
- **Issue**: [#7987](https://git.1814.love:8443/wx/HL/issues/7987)
- **PR**: [#8039](https://git.1814.love:8443/wx/HL/pulls/8039)
- **Merge commit**: [ab92377c7](https://git.1814.love:8443/wx/HL/commit/ab92377c7)
### 联系人
- **后端负责人**: @wx
- **前端对接人**: @mmg(hl-ui 仓库,需新增 eventTypeLabel 映射)
@@ -0,0 +1,380 @@
---
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 并推送本文件;发布前不许为了过校验改状态位。"
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
@@ -0,0 +1,443 @@
---
schema: "hl-changelog/v2"
ticket: "8013"
title: "共用关系确认历史补记成本承担方变更(COST_BEARER_CHANGED 此前是死枚举)"
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,用的是本方自建的批次 2101246738038652930 上的共用关系 359639602970112,非他人夹具):保持成员全集不变、仅将 costBearer 由 GROUP 改为 ORDER,POST .../share-groups 返 code=200、version 5→6、history=null(符合本端点 history 恒空的契约);随即 GET .../share-groups?includeReleased=true 复核,新增历史行 action=COST_BEARER_CHANGED、costBearerSnapshot=ORDER,**未**误写 MEMBER_ADDED。🔴 这正是本篇正文写明的取证口径:判「这次确认写了几条历史」只能靠事后查询,不能靠 POST 响应体推断——本次按此执行。 【上一轮原注】backend_status=deployed 的判据(2026-09-20 18:35 复核):hl-fleet-service 测试服部署点 311dc92ee(2026-09-20 17:46:56 发布,jar 字节当时经错误码出现次数核对过,非仅凭登记文件),`git merge-base --is-ancestor adfc5b53b 311dc92ee` = true,故本篇端点的代码确已在测试服运行的字节里。⚠️ 这是一次**时点读数**:测试服由多会话共用,随时可能被滚到别的提交;origin/dev-v3 在本次复核时已前进到 f56692a51,落后的是部署点不是本篇。 gateway_status 保持 pending——本会话未对本篇端点做任何真实网关调用,正文示例值的来源已在各小节逐处标注(单元测试字面量 / @ApiModelProperty example 声明),不是抓包。待网关复验后再置 verified,**不得为了让门禁变绿改这个位**。 【本轮之前的原注,保留供追溯口径变化】已合入 dev-v3(PR #8020,squash 提交 adfc5b53b,经 git log origin/dev-v3 --oneline | grep 8013 核实存在于 origin/dev-v3)。backend_status=deployed 的依据是「已在主线」,不代表已部署测试服并做过网关联调。gateway_status 刻意保持 pending——本次未做任何真实网关调用,唯一证据是随 PR 一起合入的两条单元测试(GroupDispatchShareAdmissionServiceTest:confirm_costBearerChangedWithoutMemberChange_writesCostBearerChangedNotMemberAdded / confirm_membersAddedAndCostBearerChangedTogether_writesBothRows),交接时未重新执行这两条测试(本机当时另有全量测试占用窗口,见 MACHINE-LOCK.md)。正文的请求/响应示例数值沿用 #7444 changelog(19_7444_团期配车就绪门禁与车辆共用关系-修改接口-管理后台.md,尚未推送的草稿)里已经登记的同一套 swagger 声明示例值(shareGroupId=77001 等),不是本次新造的编号,也不是真实网关抓包。待管理者安排测试服部署 + 网关复验后,再把 gateway_status 置 verified 并推送本文件;发布前不许为了过校验改这个状态位。"
updated_at: "2026-09-20"
base: "dev-v3"
---
# fleet: 共用关系确认历史补记成本承担方变更(工单 #8013)
> **存放目录**: 二期(order-v3 标签工单)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-fleet-service(8087)
> **PR**: #8020 | **Issue**: #8013 | **合并提交**: `adfc5b53b`
> **日期**: 2026-09-20
> **影响范围**: 管理后台「团期配车页」共用关系确认历史(`GET .../share-groups?includeReleased=true` 的 `history[]`),以及写口 `POST .../share-groups` 内部的历史写入分流逻辑
---
## ⚠️ 关键变化
`ShareLogAction.COST_BEARER_CHANGED` 这个动作常量自 #7444 落地起就**从未被真正写出过**——车务在团期配车页把某天某辆车的共用关系成本承担方从 `GROUP` 改成 `ORDER`(或反过来),确认历史里记下来的是 `MEMBER_ADDED`,即便成员一个都没变。前端如果按 `action` 做过枚举映射/文案表,**没见过** `COST_BEARER_CHANGED` 这个值——它不是"没出现过"的边界情况,是编译器允许、运行时永远走不到的死分支。本次修复后它会真实出现,前端**必须**补上这一项,否则会显示成空白或 fallback 文案。
同一次修复顺带纠正了一个同构错误:车务对着同一份成员全集、同一个成本承担方原样重新点一次"确认"(例如错过 `@Idempotent(timeout=10)` 的 10 秒防重窗口后手动再点),改前会**误写一条** `MEMBER_ADDED`——车务并没有加任何成员,历史里却凭空多出一条"加了成员"的记录。改后这种真正的空提交**不产生任何新历史行**。
两处都不改变 `POST`/`GET` 两个端点自身的请求/响应字段名、类型或结构;变化只发生在"这次确认写了哪几条历史行、写的是哪个 `action`"这件事上。
---
## 一、背景
`fleet_group_dispatch_share_log.action` 这一列在 `V20260918_003` 建表时就用 CHECK 约束声明了五个合法字面量:`CREATE`/`MEMBER_ADDED`/`MEMBER_REMOVED`/`COST_BEARER_CHANGED`/`RELEASED`。DB 侧一直都能接受 `COST_BEARER_CHANGED`。
问题出在 Java 侧:`GroupDispatchShareAdmissionService#confirmInTransaction` 复用既有关系(幂等覆盖)时,末尾恒定写死一条历史——`created ? CREATE : MEMBER_ADDED`。这个三元表达式里**根本没有 `COST_BEARER_CHANGED` 这个分支**,无论车务这次改的是成员、是成本承担方、还是什么都没改,只要不是新建,写出去的永远是 `MEMBER_ADDED`(或者什么都没变时,同样误写 `MEMBER_ADDED`)。
`upsertGroup` 内部其实早就在做 `costBearer` 的 CAS 更新(`casUpdateCostBearer`),只是这次落库前后的差异从未被读出来、也从未参与历史写入的判定。
| 维度 | 改前 | 改后 |
|------|------|------|
| 复用关系时的历史写入判据 | 一个布尔 `created` 决定 `CREATE`/`MEMBER_ADDED` 二选一 | `created`(是否新建)+ 成员是否真的新增 + 成本承担方是否真的变化,三个独立判据,各写各的 0~2 条 |
| `COST_BEARER_CHANGED` 是否可达 | 否(死代码) | 是(复用关系且成本承担方变化时必写) |
| 成员全集与成本承担方均未变的重复确认 | 误写 1 条 `MEMBER_ADDED` | 写 0 条 |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 确认团期车辆共用关系 | POST | `/admin/fleet/group-dispatch/batches/{groupBatchId}/share-groups` | 修改接口 | 请求/响应字段零变化;仅内部历史写入判据修正(本节所述) |
| 2 | 查询团期车辆共用关系 | GET | `/admin/fleet/group-dispatch/batches/{groupBatchId}/share-groups` | 修改接口 | `history[].action` 新增可观测取值 `COST_BEARER_CHANGED`(此前是死枚举) |
---
## 三、接口详情
### 1. 确认团期车辆共用关系 `POST /admin/fleet/group-dispatch/batches/{groupBatchId}/share-groups`
**VO**: `ShareGroupConfirmReqVO → ShareGroupRespVO`
(本接口的请求/响应字段与 #7444 落地时逐字节一致,未新增/删除/改名任何字段;源码核对:`GroupDispatchShareController.java:53-93`、`ShareGroupConfirmReqVO.java`、`ShareGroupRespVO.java`、`GroupDispatchShareAdmissionService.java:158-241`)
#### 使用场景
车务在团期配车页确认共用关系时调用,行为与 #7444 changelog 描述完全一致。本次改动**不影响本端点自身的响应内容**(`ShareGroupRespVO.history` 在这个端点上恒为空,历史只能通过接口 2 的 `includeReleased=true` 查询回来)——本节存在的意义是让前端理解"点一次确认,后台这次到底往历史表里写了几条、写的是什么",这决定了随后调用接口 2 时能看到什么。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 团期主订单 ID(雪花) |
| serviceDate | Body | LocalDate | ✅ | 必须落在该团基线 `serviceDates[]` 内 | 共用发生的服务日 |
| resourceType | Body | String | ✅ | `VEHICLE`/`DRIVER` | 资源维度 |
| resourceId | Body | Long | ✅ | - | 车辆 ID 或司机 ID |
| members | Body | Array | ✅ | 2~20 个 | 成员**全集**(不是增量) |
| members[].sourceType | Body | String | ✅ | `ASSIGNMENT`/`GROUP_DISPATCH` | - |
| members[].sourceId | Body | Long | ✅ | - | 派单 ID 或团级配车行 ID |
| costBearer | Body | String | ✅ | `GROUP`/`ORDER` | **本次改动的判据来源**:与落库前的旧值比对,不同则本次会多写一条 `COST_BEARER_CHANGED` |
| costBearerOrderId | Body | Long | 条件必填 | `costBearer=ORDER` 时必填 | - |
| remark | Body | String | ❌ | ≤200 | 确认备注(也会写进新增的历史行) |
(完整入参约束、错误码见 #7444 changelog 对应小节,本次未改动任何一条校验规则)
#### 出参字段表
| 字段 | 类型 | 说明 |
|------|------|------|
| shareGroupId / groupBatchId / serviceDate / resourceType / resourceId / status / costBearer / costBearerOrderId / costSourceRefNo / members[] / confirmedBy / confirmedAt / version | - | 字段名/类型/取值规则均**未变动**,与 #7444 落地时逐字节一致 |
| history | Array | 本端点恒为 `null`/空数组;**本次改动写了几条历史、写的是什么,在这个响应体里看不到**,必须另调接口 2 |
#### 请求示例
沿用 #7444 changelog 登记的示例数据集(`shareGroupId=77001` 等),仅把 `costBearer` 改成触发 `COST_BEARER_CHANGED` 的取值:该关系此前以 `costBearer=GROUP` 建立,本次原样重新提交同一份成员全集、只把 `costBearer` 改成 `ORDER`:
```json
{
"serviceDate": "2026-09-12",
"resourceType": "VEHICLE",
"resourceId": 1,
"members": [
{ "sourceType": "GROUP_DISPATCH", "sourceId": 99001 },
{ "sourceType": "ASSIGNMENT", "sourceId": 88001, "admissionIntent": "OCCUPYING" }
],
"costBearer": "ORDER",
"costBearerOrderId": 70123,
"remark": "车费改由甲户订单承担"
}
```
(路径参数 `groupBatchId=8801`)
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"shareGroupId": "77001",
"groupBatchId": "8801",
"serviceDate": "2026-09-12",
"resourceType": "VEHICLE",
"resourceId": "1",
"status": "ACTIVE",
"costBearer": "ORDER",
"costBearerOrderId": "70123",
"costSourceRefNo": "SHARE-77001",
"members": [
{ "sourceType": "GROUP_DISPATCH", "sourceId": "99001", "requirementId": null, "orderId": null },
{ "sourceType": "ASSIGNMENT", "sourceId": "88001", "requirementId": "5501", "orderId": "70123" }
],
"confirmedBy": "1001",
"confirmedAt": "2026-09-12 18:25:10",
"version": 2
},
"success": true
}
```
⚠️ 这个响应体本身**看不出**后台这次到底记了几条历史——`history` 字段在这个端点上恒为空。要确认这次调用是否真的多写了一条 `COST_BEARER_CHANGED`,必须紧接着调接口 2(`includeReleased=true`)。
#### 空数据 / 降级响应
本接口是同步写操作,不存在响应体意义上的"空数据"。**但本次改动引入了一种新的"写 0 条历史"的合法情形**:若车务原样重新提交同一份成员全集、且 `costBearer` 也未变(`membersAdded=false && costBearerChanged=false`),本次确认**不产生任何新历史行**——这不是接口故障,是 #8013 修复后的预期行为;改动前这种情况会误写一条 `MEMBER_ADDED`。前端不应假设"每次点确认,历史列表长度必然 +1"。
#### 错误响应
本次改动不引入任何新错误码,错误响应与 #7444 落地时完全一致,例如:
```json
{
"code": 602103,
"message": "成员派单不属于本团或团期身份未知: 88099",
"success": false,
"data": null
}
```
#### 业务边界
- **本次改动不改变鉴权、幂等、校验、事务边界**,与 #7444 changelog 描述的规则完全一致。
- **判据在落库前取样**:`costBearer` 是否变化,取的是 `casUpdateCostBearer` 执行前读到的旧值与本次入参的比对结果,不是事后反查历史表推断出来的——落库那一瞬间旧快照就没有意义了,判据必须在那之前定下来。
- **新建关系(`created=true`)分支完全不受影响**:仍然只写 1 条 `CREATE`,不会额外叠加 `COST_BEARER_CHANGED`(新建时这条 `CREATE` 历史本身就带着当次的 `costBearerSnapshot`,没有必要再补一条)。
- **同一次确认最多产生 2 条新历史**(成员新增 1 条 `MEMBER_ADDED` + 成本承担方变化 1 条 `COST_BEARER_CHANGED`),顺序恒为先 `MEMBER_ADDED` 后 `COST_BEARER_CHANGED`(`operateTime` 相同,前端排序如依赖 `operateTime` 需要一个稳定的次级排序键,比如 `logId` 自增);**成员被移出**(`MEMBER_REMOVED`)走的是 `syncMembers` 内联的另一条写入路径,与本次改动的判据完全独立,不受影响。
---
### 2. 查询团期车辆共用关系 `GET /admin/fleet/group-dispatch/batches/{groupBatchId}/share-groups`
**VO**: `ShareGroupQueryReqVO → List<ShareGroupRespVO>`
(源码核对:`GroupDispatchShareController.java:100-111`、`GroupDispatchShareService.java:125-144`、`ShareGroupHistoryRespVO.java`)
#### 使用场景
团期配车页 / 核团排障时查看共用关系变更历史,`includeReleased=true` 时返回 `history[]`。本次改动后,这里**首次**可能出现 `action="COST_BEARER_CHANGED"` 的历史行——此前无论后台实际发生了什么,这里永远只会看到 `CREATE`/`MEMBER_ADDED`/`MEMBER_REMOVED`/`RELEASED` 四种。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 团期主订单 ID |
| serviceDate | Query | LocalDate | ❌ | 不传=全部服务日 | - |
| resourceType | Query | String | ❌ | `VEHICLE`/`DRIVER` | 不传=两者都查 |
| includeReleased | Query | Boolean | ❌ | 默认 false | **必须为 true 才会带 `history[]`**;本次改动只影响 `history[].action` 的取值分布,不影响本参数语义 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|------|------|------|
| (数组元素其余字段:shareGroupId / groupBatchId / serviceDate / resourceType / resourceId / status / costBearer / costBearerOrderId / costSourceRefNo / members[] / confirmedBy / confirmedAt / version) | - | **本次未改动** |
| history[] | Array | 仅 `includeReleased=true` 时非空;**本次改动只影响这个数组里各行 `action` 的取值分布,字段结构本身零变化** |
| history[].action | String | **本次改动点**:合法取值仍是 `CREATE`/`MEMBER_ADDED`/`MEMBER_REMOVED`/`COST_BEARER_CHANGED`/`RELEASED` 五个(DB CHECK 早已如此),但 `COST_BEARER_CHANGED` 此前从未被真实写出过,本次修复后会真实出现 |
| history[].memberSnapshot | String | 该次变更后的成员全集快照(JSON 文本);`COST_BEARER_CHANGED` 这条历史的快照与紧邻的 `MEMBER_ADDED`(如果同次确认也写了的话)逻辑上相同,因为两条历史共用同一次 `selectActiveByGroupForUpdate` 锁定读结果 |
| history[].costBearerSnapshot | String | 该次变更后的成本承担方;`COST_BEARER_CHANGED` 这条历史上就是本次改到的新值 |
| history[].operator | String | 操作人 adminId |
| history[].operateTime | LocalDateTime | 操作时间;同一次确认产生的 `MEMBER_ADDED` 与 `COST_BEARER_CHANGED` 两条历史 `operateTime` 相同(同一个 `now` 变量) |
| history[].reason | String | 仅 `RELEASED` 动作有值,`COST_BEARER_CHANGED` 恒为 `null` |
| history[].remark | String | 备注;`COST_BEARER_CHANGED` 这条历史的 `remark` 与同次确认入参的 `remark` 相同(若同次也写了 `MEMBER_ADDED`,两条历史的 `remark` 是同一个值,不是分开填的两段话) |
#### 请求示例
```http
GET /admin/fleet/group-dispatch/batches/8801/share-groups?includeReleased=true
```
#### 响应示例
沿用接口 1 示例的后续状态:该关系先以 `costBearer=GROUP` 建立(写 1 条 `CREATE`),随后车务把 `costBearer` 改成 `ORDER`(本次改动生效,写 1 条 `COST_BEARER_CHANGED`):
```json
{
"code": 200,
"message": "成功",
"data": [
{
"shareGroupId": "77001",
"groupBatchId": "8801",
"serviceDate": "2026-09-12",
"resourceType": "VEHICLE",
"resourceId": "1",
"status": "ACTIVE",
"costBearer": "ORDER",
"costBearerOrderId": "70123",
"costSourceRefNo": "SHARE-77001",
"members": [
{ "sourceType": "GROUP_DISPATCH", "sourceId": "99001", "requirementId": null, "orderId": null },
{ "sourceType": "ASSIGNMENT", "sourceId": "88001", "requirementId": "5501", "orderId": "70123" }
],
"confirmedBy": "1001",
"confirmedAt": "2026-09-12 18:25:10",
"version": 2,
"history": [
{
"action": "CREATE",
"memberSnapshot": "[{\"sourceType\":\"GROUP_DISPATCH\",\"sourceId\":\"99001\",\"requirementId\":null,\"orderId\":null},{\"sourceType\":\"ASSIGNMENT\",\"sourceId\":\"88001\",\"requirementId\":\"5501\",\"orderId\":\"70123\"}]",
"costBearerSnapshot": "GROUP",
"operator": "1001",
"operateTime": "2026-09-12 18:20:33",
"reason": null,
"remark": "团车上午行程后接甲户"
},
{
"action": "COST_BEARER_CHANGED",
"memberSnapshot": "[{\"sourceType\":\"GROUP_DISPATCH\",\"sourceId\":\"99001\",\"requirementId\":null,\"orderId\":null},{\"sourceType\":\"ASSIGNMENT\",\"sourceId\":\"88001\",\"requirementId\":\"5501\",\"orderId\":\"70123\"}]",
"costBearerSnapshot": "ORDER",
"operator": "1001",
"operateTime": "2026-09-12 18:25:10",
"reason": null,
"remark": "车费改由甲户订单承担"
}
]
}
],
"success": true
}
```
⚠️ **本响应体的数值构造来自 #7444 changelog 登记的同一套 swagger 声明示例 + 本次源码新增的判据逻辑推算,不是真实网关抓包**(`gateway_status=pending`,见 frontmatter `status_note`)。结构与字段名已逐一对源码核实(`ShareGroupHistoryRespVO.java`),但具体数值请在网关复验后以实测为准。
#### 空数据 / 降级响应
本团在筛选条件下没有任何共用关系时返回空数组 `[]`;`includeReleased=false`(默认)时 `history` 字段不返回。本次改动不影响这两种既有的空态。
#### 错误响应
```json
{
"code": 100001,
"message": "参数非法: 资源维度非法: TRAIN",
"success": false,
"data": null
}
```
#### 业务边界
- **鉴权**:路径级角色门禁 `X-Admin-Role` 须为 `VEHICLE_MANAGER`/`SUPER_ADMIN`,本次未改动(详见 #7444 changelog)。
- **前端枚举映射表必须补 `COST_BEARER_CHANGED`**:此前的响应里从未出现过这个值,前端若对 `action` 做过 `switch`/映射表且没有兜底分支,会渲染成空白/`undefined`,而不是报错——这类问题不会在联调时报红,只会在页面上悄悄显示不对。
- **`memberSnapshot` 仍是全集不是增量**,本次改动不影响这条既有约定。
- **`COST_BEARER_CHANGED` 与紧邻的 `MEMBER_ADDED`(如果同次确认也命中的话)`operateTime` 相同**,前端如果用 `operateTime` 做排序/分组去重,需要注意这两条历史是同一次操作产生的两个独立事实,不能因为时间戳相同就当成重复记录去重掉。
- 查询接口本身不加锁、不开事务,读到的是调用时刻的库状态。
---
## 四、契约约束与正确调用方式
> 本节只写后端接受/拒绝 payload 的规则与历史写入判据,不写 UI 渲染建议。
### `COST_BEARER_CHANGED` 的触发条件是「落库前旧值 ≠ 本次入参新值」
这是纯粹的服务端判据,前端**不需要、也不应该**自己在客户端比对新旧 `costBearer` 来猜这次会不会有 `COST_BEARER_CHANGED` 历史——判据用的是数据库锁定读到的旧快照,前端本地缓存的旧值可能已经过期(比如同一团被另一个车务并发改过)。
### 判断"这次确认到底写了几条历史",只能靠事后查询,不能靠 POST 响应体推断
`POST .../share-groups` 的响应体里没有任何字段能告诉调用方"这次写了 1 条还是 2 条历史、写的是什么 action"。前端如果需要在确认成功后立刻展示"本次变更记录",必须紧接着调 `GET .../share-groups?includeReleased=true`,按 `operateTime`(+ 稳定次级排序键)取最新的 1~2 条。
### ✅ 正确 / ❌ 错误 理解对照
| 场景 | 后台实际写入 | 前端不应假设的事 |
|---|---|---|
| ✅ 新建关系 | 1 条 `CREATE` | 不会额外出现 `COST_BEARER_CHANGED` |
| ✅ 复用关系,只改 `costBearer`,成员全集不变 | 1 条 `COST_BEARER_CHANGED` | 不会出现 `MEMBER_ADDED` |
| ✅ 复用关系,成员与 `costBearer` 都变了 | 2 条:`MEMBER_ADDED` + `COST_BEARER_CHANGED` | 两条 `operateTime` 相同,不是两次独立操作 |
| ✅ 复用关系,成员与 `costBearer` 都没变(原样重提) | **0 条** | ❌ 不要假设"点一次确认历史必然 +1 条"——改前的旧行为才是这样,且是 #8013 要修的缺陷 |
| ❌ 前端自行比对 `costBearer` 新旧值来预测本次是否有 `COST_BEARER_CHANGED` | 无意义 | 判据在服务端落库前的锁定读上,前端本地状态可能已过期 |
---
## 五、数据库行为
写接口 `POST .../share-groups` 落库表 `fleet_group_dispatch_share_log`(`V20260918_003` 建表,INSERT-only 留痕表,一行 = 关系的一次变更)。
**本次改动不新增任何列、不新增任何表、不改 `chk_share_log_action` 约束**——约束自建表起就允许 `COST_BEARER_CHANGED` 这个字面量,缺陷完全在 Java 应用层(三元表达式漏了这个分支),DB 侧从一开始就是就绪的。
| 场景 | 改前写入行数 | 改后写入行数 |
|---|---|---|
| 新建关系 | 1(`CREATE`) | 1(`CREATE`,不变) |
| 复用关系,仅成员变化 | 1(`MEMBER_ADDED`) | 1(`MEMBER_ADDED`,不变) |
| 复用关系,仅 `costBearer` 变化 | 1(**误写** `MEMBER_ADDED`) | 1(`COST_BEARER_CHANGED`) |
| 复用关系,成员与 `costBearer` 都变化 | 1(`MEMBER_ADDED`,`costBearer` 变化未留痕) | 2(`MEMBER_ADDED` + `COST_BEARER_CHANGED`) |
| 复用关系,两者都未变化(原样重提) | 1(**误写** `MEMBER_ADDED`) | 0(不写) |
| 成员被移出 | 1(`MEMBER_REMOVED`,`syncMembers` 内联写,独立路径) | 1(不变,本次未触碰这条路径) |
---
## 六、边界行为
- 未登录/网关未透传角色 → 401(网关拦截),与既有行为一致
- `resourceType` 非法枚举值 → `100001`(沿用既有,见 #7444 changelog 更正记录)
- 成员数越界、服务日窗外、并发修改等既有错误码全部未变(602100~602112 段)
- 老数据兼容:本次改动前落库的历史行(`action` 只可能是 `CREATE`/`MEMBER_ADDED`/`MEMBER_REMOVED`/`RELEASED` 四种之一)原样保留,查询时按原值返回,不会被"回填"或"重新计算"成 `COST_BEARER_CHANGED`——本次修复只影响**新产生**的历史行,不改写存量数据
- `appendConfirmChangeLog` 全程在 `confirmInTransaction` 的同一个事务内执行,与关系落库、成员同步、派单关联、占用准入共享同一次提交/回滚,不会出现"关系已改但历史没写"或"历史写了但关系没改"的中间态
---
## 六.5、枚举 / 数据字典
### `action`(`ShareGroupHistoryRespVO.action`,源码 `ShareLogAction` 常量类 / DB `chk_share_log_action`)
**所属字段**: `history[].action` | **类型**: `String`
| 值 | 说明 | 本次改动前是否可能在响应中出现 |
|----|------|------|
| `CREATE` | 首次建立关系 | 是 |
| `MEMBER_ADDED` | 幂等覆盖时成员净增;改动前该分支同时错误吸收了"仅改成本承担方"与"什么都没改"两种场景 | 是(含误写) |
| `MEMBER_REMOVED` | 幂等覆盖或自动收缩时成员净减 | 是 |
| `COST_BEARER_CHANGED` | 成本承担方变更 | **否——本次改动前是死枚举,代码路径永远走不到** |
| `RELEASED` | 关系解除(四条解除路径共用) | 是 |
⚠️ 该枚举早在 `V20260918_003`(2026-09-18)建表时就在 DB CHECK 约束里声明了全部五个值,`COST_BEARER_CHANGED` 不是本次新加的枚举字面量,而是本次才让它第一次真正可达。
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `history[].action` 合法取值集合 | 声明上五个值都合法,但实际只会出现 `CREATE`/`MEMBER_ADDED`/`MEMBER_REMOVED`/`RELEASED` 四种 | 全部五个值均可能真实出现 |
| `POST`/`GET` 两端点的请求/响应字段名、类型、结构 | - | **零变化** |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 复用关系,仅改 `costBearer` | 误写 1 条 `MEMBER_ADDED`(无法从历史看出改的是成本承担方) | 正确写 1 条 `COST_BEARER_CHANGED` |
| 复用关系,成员与 `costBearer` 都改 | 只写 1 条 `MEMBER_ADDED`(`costBearer` 变化完全未留痕) | 写 2 条:`MEMBER_ADDED` + `COST_BEARER_CHANGED`,两件事都可追溯 |
| 复用关系,成员与 `costBearer` 都未改(原样重提) | 误写 1 条 `MEMBER_ADDED`(凭空多一条"加了成员"的假记录) | 写 0 条 |
| 新建关系 | 写 1 条 `CREATE` | 不变 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否——两个端点的请求/响应字段名、类型、结构均未变动;变化只发生在 `history[].action` 的取值分布上(新增可观测取值,不是改变既有取值的含义)
- **前端是否必须同步上线**: 是——如果前端对 `action` 做了枚举映射/文案表且没有安全的 `default` 兜底,`COST_BEARER_CHANGED` 首次出现时会展示为空白或 `undefined`,需要补上这一项映射
- **前端 workaround 清理点**: 若前端此前发现"改成本承担方后历史列表多了一条奇怪的『新增成员』记录"并做过任何遮蔽/过滤逻辑(比如按 `remark` 内容猜测过滤掉这类"假 MEMBER_ADDED"),本次修复后这类 workaround 应当撤除——改后不会再产生这种假记录
---
## 七、不影响范围
- **仅影响**: 共用关系确认历史的写入判据(`POST .../share-groups` 内部)与查询回显(`GET .../share-groups?includeReleased=true` 的 `history[].action`)
- **零影响**:
- 两个端点的请求体、响应体字段名/类型/结构**零变更**
- 新建关系(`CREATE`)分支完全不受影响
- 解除关系(`RELEASED`,`DELETE .../share-groups/{shareGroupId}`)分支完全不受影响,本次未改动该端点任何代码
- 成员被移出(`MEMBER_REMOVED`)分支完全不受影响,走的是独立的内联写入路径
- 准入三步流程(授权 → 派单关联 → 占用准入)与占用账本行为**完全不受影响**,本次只动了流程末尾的历史写入分流
- 无新增 DB 表/列/索引/约束;`chk_share_log_action` 约束原样不变(自 `V20260918_003` 起就允许 `COST_BEARER_CHANGED`)
- 团期配车其余端点(就绪判定、派单预校验/候选、reconfigure 等)零影响
---
## 八、测试环境已验证
⚠️ **本篇尚无测试服/网关读数**(`gateway_status=pending`),以下只是**单元测试**层面的证据,不是测试环境实测——按管理者要求如实标注,不把单测读数伪装成测试环境验证:
随 PR #8020 一并合入的两条新增单测(`GroupDispatchShareAdmissionServiceTest`,Mockito,未重跑,仅列出用例名与断言意图供核实):
```
confirm_costBearerChangedWithoutMemberChange_writesCostBearerChangedNotMemberAdded
→ 只改 costBearer、成员不变:断言 append(COST_BEARER_CHANGED) 被调用一次,
且 append(MEMBER_ADDED) 从未被调用(verify(..., never()))
confirm_membersAddedAndCostBearerChangedTogether_writesBothRows
→ 成员与 costBearer 同次都变:断言 append(MEMBER_ADDED) 与 append(COST_BEARER_CHANGED)
均被调用一次
```
**待办**:管理者安排 hl-fleet-service 部署测试服后,需补一次真实网关调用(POST 确认 + GET 查询 `includeReleased=true`),核实 `history[].action="COST_BEARER_CHANGED"` 真实落库并可读出,再把 `gateway_status` 置 `verified` 并推送本文件。
---
## 十、相关文档
- 关联 Issue: [wx/HL#8013](https://git.1814.love:8443/wx/HL/issues/8013)
- 关联 PR: [wx/HL#8020](https://git.1814.love:8443/wx/HL/pulls/8020)(squash 合并至 dev-v3 @`adfc5b53b`)
- 共用关系机制本身(confirm/query/release 三端点完整契约)见 #7444 changelog:`changelogs-v2/2026-09/19_7444_团期配车就绪门禁与车辆共用关系-修改接口-管理后台.md`(尚未推送的草稿,仅供内部核对参考,不作为已发布依据)
## 关联 / 联系人
### 链接
- **Issue**: [#8013](https://git.1814.love:8443/wx/HL/issues/8013)
- **PR**: [#8020](https://git.1814.love:8443/wx/HL/pulls/8020)
- **Merge commit**: [adfc5b53b](https://git.1814.love:8443/wx/HL/commit/adfc5b53b1aea08529eeb41459a692c7f8a9ed7a)
### 联系人
- **后端负责人**: wx(GIT)
- **前端负责人**: mmg