docs(changelog): #7965 改派幂等重放 assignmentSlotId 口径订正 + 补实测读数
changelog-filename-gate / validate (push) Failing after 2s
changelog-filename-gate / validate (push) Failing after 2s
该文件的初稿被 #8023 那次提交(ba78f96)连带提交上来了,内容是未修正版。本次补齐并订正: 1. 口径订正(重要):初稿沿用工单与 PR #7997 的说法「重放的响应体里这个字段消失了」, 该说法对响应体不成立。NON_NULL inclusion 只挂在回执写 outcome_json 的私有 CANONICAL_MAPPER 上,响应 VO 无 @JsonInclude、全仓无 default-property-inclusion 配置; 网关实测响应原文里 "warningCode": null、"vehicleFeeAdjustmentReason": null 均原样带出。 ⇒ 前端改前看到的是 "assignmentSlotId": null(键在、值为 null),不是键消失。 已同步改掉「按键可能不存在判空」这条会误导前端的契约约束。 2. 请求/响应示例换成 TEST 实测原文(2026-09-20 15:26,两次调用响应体 MD5 相同)。 3. 补「八、测试环境已验证」:网关双调读数、重放佐证、存量 19 行统计与反推前提核验、 本地 fleet 全量 verify 读数。 4. 补前端现状核查与「重放路径确实会走到」的依据(hl-ui useAssignFlow.js:757 复用 requestId)。 三个校验器均 PASS。 Refs wx/HL#7965
这个提交包含在:
@@ -12,7 +12,7 @@ frontend_owner: "mmg"
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: "改派 POST /admin/fleet/assignments/{assignmentId}/change 带 requestId 时,同一 requestId 的幂等重放此前恒返回 assignmentSlotId=null——而响应用 NON_NULL 省略 null,等于这个字段在重放的响应体里直接消失,首调却带着真实值。本次把回执冻结口径改回照抄首调的值,重放与首调返回同一个 assignmentSlotId。入参、路径、其余出参字段、错误码、判权一律不变;首次调用的行为也完全不变,只有「同 requestId 重放」这一条路径的返回值变了。后端已合并 dev-v3(d31bb3209)并部署 TEST。"
|
||||
status_note: "改派 POST /admin/fleet/assignments/{assignmentId}/change 带 requestId 时,同一 requestId 的幂等重放此前恒返回 assignmentSlotId=null,而首次调用返回真实值——同一个 requestId 拿到两份不一样的「同一个结果」。本次把回执冻结口径改回照抄首调的值,重放与首调返回同一个 assignmentSlotId。入参、路径、其余出参字段、错误码、判权一律不变;首次调用的行为也完全不变,只有「同 requestId 重放」这一条路径的返回值变了。后端已合并 dev-v3(d31bb3209)并部署 TEST,网关双调实测两次响应体逐字节一致(工单 #7965 AC-1)。"
|
||||
updated_at: "2026-09-20"
|
||||
base: "dev-v3"
|
||||
---
|
||||
@@ -31,7 +31,7 @@ base: "dev-v3"
|
||||
|
||||
## ⚠️ 关键变化
|
||||
|
||||
1. **只有「同一 `requestId` 重放」这条路径的返回值变了**:`assignmentSlotId` 从恒 `null`(响应体里该字段直接消失)改为**与首次调用同值**。
|
||||
1. **只有「同一 `requestId` 重放」这条路径的返回值变了**:`assignmentSlotId` 从恒 `null` 改为**与首次调用同值**。
|
||||
2. **首次调用的行为一个字都没变**:它本来就返回真实值。
|
||||
3. **不是新增字段、不是删字段**:`assignmentSlotId` 一直在响应 VO 里;变的是它在重放时的取值。
|
||||
4. **入参、路径、方法、其余出参、错误码、判权全部不变。**
|
||||
@@ -42,7 +42,9 @@ base: "dev-v3"
|
||||
|
||||
改派带 `requestId` 是幂等键:网络重试、前端重复提交时,第二次调用不会再改一次派单,而是把首次冻结的结果原样重放回来。
|
||||
|
||||
`#7067`「派单去槽位化」把回执写侧的 `assignmentSlotId` 无条件冻结成了 `null`,而产出侧(`AssignmentService` → `AssignmentConverter` → `ChangeAssignmentRespVO`)仍在计算并返回真实值。于是**同一个 `requestId` 调两次,拿到两份不一样的「同一个结果」**:首调有这个字段,重放没有(响应用 `NON_NULL` 省略 null,字段整个不出现)。
|
||||
`#7067`「派单去槽位化」把回执写侧的 `assignmentSlotId` 无条件冻结成了 `null`,而产出侧(`AssignmentService` → `AssignmentConverter` → `ChangeAssignmentRespVO`)仍在计算并返回真实值。于是**同一个 `requestId` 调两次,拿到两份不一样的「同一个结果」**:首调是真实值,重放是 `null`。
|
||||
|
||||
> 口径订正:响应体**不省略 null 字段**(`NON_NULL` 只挂在回执写 `outcome_json` 的私有序列化器上,响应 VO 没有 `@JsonInclude`,实测响应里 `"warningCode": null` 原样带出)。所以改前前端看到的是 `"assignmentSlotId": null`——**键在、值为 null**,不是键消失。工单与 PR 正文里「重放的响应体里这个字段消失了」的说法只对数据库里的 `outcome_json` 成立,对响应体不成立。
|
||||
|
||||
这不是「哪个值才对」的问题——`assignmentSlotId` 该不该退役是另一件事;**错的是两条路径不同值**,这是幂等回执的定义性失败。本次只把两侧拉齐:回执照抄首调的值。将来若真要退役这个字段,写口与回执一起退,两侧仍然同值。
|
||||
|
||||
@@ -84,64 +86,89 @@ base: "dev-v3"
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| assignmentSlotId | Long | 展示用历史槽位号,不参与任何判定。**本次变更点**:首调一直是真实值;同 `requestId` 重放此前恒 `null`(字段在响应体里消失),现在与首调同值 |
|
||||
| assignmentSlotId | Long | 展示用历史槽位号,不参与任何判定。**本次变更点**:首调一直是真实值;同 `requestId` 重放此前恒 `null`,现在与首调同值。序列化为**字符串**(防 JS 精度丢失) |
|
||||
| previousAssignmentGroupId | Long | 改派前的派车组身份(不变) |
|
||||
| newAssignmentGroupId | Long | 改派后的新派车组身份(不变) |
|
||||
| assignmentStatus | String | 改派后派单状态(不变) |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
(测试服实测请求体,2026-09-20 15:26,改派 `assignmentId=359407200621432832` 换司机)
|
||||
|
||||
```json
|
||||
{
|
||||
"effectiveDate": "2027-08-28",
|
||||
"newDriverId": 2089686360926380033,
|
||||
"effectiveDate": "2026-11-06",
|
||||
"newDriverId": 2065272146627641345,
|
||||
"sendItinerarySms": false,
|
||||
"reason": "#7965 AC-1 取证",
|
||||
"requestId": "ac1-7965-20260920"
|
||||
"requestId": "ac1-7965-20260920-002"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
(同一个 `requestId` 连调两次,两次响应体一致)
|
||||
(测试服实测:同一个 `requestId` 连调两次,两次响应体 **MD5 相同**)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"assignmentId": 359948910304825344,
|
||||
"assignmentSlotId": 359947685400285184,
|
||||
"previousAssignmentGroupId": 359947685400285184,
|
||||
"newAssignmentGroupId": 359948910271270912,
|
||||
"assignmentId": "359963532525178880",
|
||||
"assignmentSlotId": "359407200600461312",
|
||||
"previousAssignmentGroupId": "359407200600461312",
|
||||
"newAssignmentGroupId": "359963532273520640",
|
||||
"assignmentStatus": "assigned",
|
||||
"effectiveDate": "2027-08-28",
|
||||
"affectedDays": 1
|
||||
"effectiveDate": "2026-11-06",
|
||||
"affectedDays": 1,
|
||||
"protocolPrice": "300.00",
|
||||
"vehicleFeeAutoTotal": "300.00",
|
||||
"vehicleFeeAutoComplete": true,
|
||||
"vehicleFeeTotal": "300.00",
|
||||
"vehicleFeeSource": "AUTO",
|
||||
"vehicleFeeAdjustmentReason": null,
|
||||
"dailyVehicleFees": [
|
||||
{ "serviceDate": "2026-11-06", "chargeable": true, "calendarPrice": "300.00", "assignmentPrice": "300.00", "source": "CALENDAR", "calendarPriceMissing": null }
|
||||
],
|
||||
"otherVehicleCount": 0,
|
||||
"warningCode": null,
|
||||
"warningMessage": null,
|
||||
"otherVehicles": [],
|
||||
"sendItinerarySms": false,
|
||||
"itinerarySmsEventId": null,
|
||||
"itinerarySmsStatus": "NOT_SENT",
|
||||
"dailyDifferences": null
|
||||
},
|
||||
"traceId": null,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
注意响应里 `vehicleFeeAdjustmentReason`、`warningCode` 等**为 null 时键仍在**——本接口响应不省略 null 字段。
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口不返回空数据形态:要么改派成功返回上述结构,要么返回错误码。
|
||||
|
||||
`assignmentSlotId` 本身**可以是 `null`**(该派单没有历史槽位号、也取不到锚点组身份时),此时首调与重放**同为 `null`**——不变量是「两次同值」,不是「一定非空」。
|
||||
`assignmentSlotId` 本身**可以是 `null`**(该派单没有历史槽位号、也取不到锚点组身份时),此时首调与重放**同为 `null`**——不变量是「两次同值」,不是「一定非空」:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"assignmentId": 359948910304825344,
|
||||
"assignmentId": "359963532525178880",
|
||||
"assignmentSlotId": null,
|
||||
"previousAssignmentGroupId": null,
|
||||
"newAssignmentGroupId": 359948910271270912,
|
||||
"newAssignmentGroupId": "359963532273520640",
|
||||
"assignmentStatus": "assigned"
|
||||
},
|
||||
"traceId": null,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:响应用 `NON_NULL` 省略 null 字段,所以 `assignmentSlotId` 为 null 时**该键不出现**,不是出现一个 `null`。前端取值要按「键可能不存在」写。
|
||||
> 该字段为 null 时**键仍然在**,值是 `null`,不会消失。前端按「值可能为 null」判空即可,不必判键是否存在。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
@@ -176,10 +203,10 @@ base: "dev-v3"
|
||||
|------|------|
|
||||
| ✅ 改派提交 | 为「本次操作」生成一个 `requestId`,重试时**原样复用**,不要每次重新生成 |
|
||||
| ✅ 重试后读 `assignmentSlotId` | 直接用,现在与首调同值;**不需要**为重放路径写兜底 |
|
||||
| ✅ 渲染 `assignmentSlotId` | 按「键可能不存在」判空(该字段为 null 时整个键被省略) |
|
||||
| ✅ 渲染 `assignmentSlotId` | 按「值可能为 null」判空;键恒存在,且是**字符串**形态的雪花 ID,别按 number 解析 |
|
||||
| ❌ 把 `assignmentSlotId` 当业务主键 / 幂等键 / 筛选键 | 它是展示用历史槽位号,不参与判定;身份走 `newAssignmentGroupId` |
|
||||
| ❌ 重试时换一个新 `requestId` | 会被当成一次新的改派,真的再改一次 |
|
||||
| ❌ 依赖「重放时该字段缺失」来区分首调与重放 | 旧行为,已被本次修复取消;要区分请用前端自己的请求状态 |
|
||||
| ❌ 依赖「重放时该字段为 null」来区分首调与重放 | 旧行为,已被本次修复取消;要区分请用前端自己的请求状态 |
|
||||
|
||||
---
|
||||
|
||||
@@ -187,7 +214,7 @@ base: "dev-v3"
|
||||
|
||||
**无表变更、无 Flyway 迁移、无索引变更。**
|
||||
|
||||
- 唯一变化是改派回执表 `fleet_assignment_change_receipt` 的 `outcome_json` 列**新写入的内容**:`assignmentSlotId` 从「不写(NON_NULL 省略)」变为「照抄首调的值」。列本身不变,`outcome_schema_version` 仍是 2(字段集没变,只是这个键从恒缺失变为有值;旧回执缺该键仍反序列化为 null,向后兼容)。
|
||||
- 唯一变化是改派回执表 `fleet_assignment_change_receipt` 的 `outcome_json` 列**新写入的内容**:`assignmentSlotId` 从「不写」变为「照抄首调的值」。该列由回执专用的私有序列化器写入,它配了 `NON_NULL`,所以值为 null 时键整个不落库——这也是改前存量行里查不到这个键的原因。列本身不变,`outcome_schema_version` 仍是 2(字段集没变,只是这个键从恒缺失变为有值;旧回执缺该键仍反序列化为 null,向后兼容)。
|
||||
- **存量回执不订正**(详见「六、边界行为」):2026-09-06 ~ 2026-09-20 修复部署前落库的回执行,`outcome_json` 里没有这个键,重放它们仍返回 null。
|
||||
|
||||
---
|
||||
@@ -195,7 +222,7 @@ base: "dev-v3"
|
||||
## 六、边界行为
|
||||
|
||||
- **同 `requestId` 重放** → `assignmentSlotId` 与首调同值(本次修复点)。
|
||||
- **该字段本身为 null**(无历史槽位号且取不到锚点组身份)→ 首调与重放同为 null,两次响应体里该键都不出现,不变量仍成立。
|
||||
- **该字段本身为 null**(无历史槽位号且取不到锚点组身份)→ 首调与重放同为 null,不变量仍成立。
|
||||
- **同 `requestId` + 不同载荷** → 605059,不产生改派副作用(不变)。
|
||||
- **不传 `requestId`** → 无重放语义,每次都是真实改派(不变)。
|
||||
- **存量回执(修复部署前落库的)** → 重放仍返回 null,**本次不订正**。理由:回执的语义是「冻结首调那一刻的答案」,事后回填等于用推导值覆盖冻结结果;而 `requestId` 的实际重放窗口是「调用方当场重试」的秒级,存量行早已过了这个窗口,订正的收益接近零、破坏冻结语义的代价是实在的。存量读数与可反推结论见工单 #7965 AC-5。
|
||||
@@ -210,7 +237,7 @@ base: "dev-v3"
|
||||
| 字段 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| `assignmentSlotId`(**首次调用**) | 真实值 | 真实值(**不变**) |
|
||||
| `assignmentSlotId`(**同 requestId 重放**) | 恒 `null`,`NON_NULL` 省略后该键在响应体里**不出现** | 与首调**同值**(首调为 null 时同为 null) |
|
||||
| `assignmentSlotId`(**同 requestId 重放**) | 恒 `null` | 与首调**同值**(首调为 null 时同为 null) |
|
||||
| 其余出参字段 | — | 不变(无新增、无删除、无改名、无类型变化) |
|
||||
| 入参 / 路径 / 方法 / 错误码 / 判权 | — | 不变 |
|
||||
|
||||
@@ -218,16 +245,22 @@ base: "dev-v3"
|
||||
|
||||
| 场景 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| 前端提交改派后超时重试(同 `requestId`) | 第二次响应里少一个字段,页面上槽位号「重试一下就没了」 | 两次响应体一致 |
|
||||
| 前端提交改派后超时重试(同 `requestId`) | 第二次响应里该字段变 `null`,页面上槽位号「重试一下就没了」 | 两次响应体逐字节一致 |
|
||||
| 调用方比对两次响应做一致性校验 | 必然不一致,且不报错(静默) | 一致 |
|
||||
| 修复部署前落库的旧 `requestId` 重放 | 返回 null | **仍返回 null**(存量不订正) |
|
||||
|
||||
## 六.7、影响评估
|
||||
|
||||
- **是否破坏向后兼容**: 否。首次调用的行为完全不变;重放路径是从「少一个字段」变成「字段齐全」,是补齐不是削减。
|
||||
- **前端是否必须同步上线**: 否。此前若为重放路径写过「该字段可能缺失」的兜底,**那段兜底可以保留**(字段为 null 时仍会缺失),不需要改。
|
||||
- **回滚**: 回滚 PR #7997 即可。回滚后新写入的回执重新变回不带该字段;已写入的带值回执不受影响(读侧照常反序列化)。
|
||||
- **前端是否必须同步上线**: 否。此前若为重放路径写过「该字段可能为 null」的兜底,**那段兜底可以保留**(该字段本来就允许为 null),不需要改。
|
||||
- **回滚**: 回滚 PR #7997 即可,无表结构变更。回滚后新写入的回执重新变回不带该键;已写入的带值回执不受影响(读侧照常反序列化)。
|
||||
- **风险**: 低。改动是回执写侧一行取值,不触及改派本身的任何判定、状态机与资源占用。
|
||||
- **前端现状(供 mmg 核对,不是结论)**: 本机 hl-ui 检出 `845d8827`(2026-08-07,已较旧,**仅供参考**)里,
|
||||
`changeAssignment()` 的返回值只被读了 `id`/`assignmentId`(`useAssignFlow.js:853`)与 `warningCode`/`otherVehicles`
|
||||
(`showOtherVehiclesWarning`),**未见读 `assignmentSlotId`**;派车看板与矩阵里读的 `assignmentSlotId` 来自列表数据,不是改派响应。
|
||||
若属实,本次修复对现有页面是**零感知**的补齐。
|
||||
- **重放路径确实会走到**: 同一份 hl-ui 里 `useAssignFlow.js:757` 明写「失败后的未变化草稿复用 requestId,任一草稿字段变化才换新键」,
|
||||
即网络重试会用同一个 `requestId` 再提一次——这正是本次修复的那条路径。
|
||||
|
||||
---
|
||||
|
||||
@@ -241,7 +274,66 @@ base: "dev-v3"
|
||||
|
||||
## 八、测试环境已验证
|
||||
|
||||
<!-- AC-1 实测读数待填 -->
|
||||
被测版本:hl-fleet-service `dev-v3 @ adfc5b53b`(2026-09-20 14:02:06 部署,state ok;该提交在修复提交 `d31bb3209` 之后,含本次契约变更)。网关 hl-gateway `dev-v3 @ 4cbccc26b`——本次不涉路由与鉴权改动。
|
||||
|
||||
**网关双调实测(核心:同一 `requestId` 首调与重放同值)**
|
||||
|
||||
```
|
||||
POST /admin/fleet/assignments/359407200621432832/change 2026-09-20 15:26
|
||||
请求体两次逐字相同(同一个 body.json,--data-binary 发送)
|
||||
requestId = ac1-7965-20260920-002
|
||||
|
||||
第 1 次 HTTP 200 assignmentSlotId = "359407200600461312" ✓ 真实值
|
||||
第 2 次 HTTP 200 assignmentSlotId = "359407200600461312" ✓ 与首调同值
|
||||
两次响应体 MD5 相同:83a1b4f2708d18ce66a981f26600ecf6 ✓
|
||||
逐字段比对 30/30 全等,无任何差异 ✓
|
||||
assignmentSlotId == previousAssignmentGroupId ✓ 与 effectiveSlotId(anchor) 语义一致
|
||||
|
||||
重放佐证(确认第 2 次没有再改一次派单)
|
||||
回执表该 request_id 行数 = 1,且 create_time == update_time == completed_at == 15:26:34 ✓
|
||||
该订单下派单行总数 3 行,15:26:34 之后新增派单 0 行、新增操作日志 0 行 ✓
|
||||
回执 outcome_json:assignmentSlotId = previousAssignmentGroupId = 359407200600461312 ✓
|
||||
```
|
||||
|
||||
**存量回执读数(对应「六、边界行为」里「存量不订正」那条)**
|
||||
|
||||
```sql
|
||||
SELECT COUNT(*) FROM hl_fleet_service.fleet_assignment_change_receipt
|
||||
WHERE create_time >= '2026-09-06'
|
||||
AND JSON_EXTRACT(outcome_json,'$.assignmentSlotId') IS NULL;
|
||||
```
|
||||
|
||||
```
|
||||
读数(2026-09-20 15:31,TEST 库):19
|
||||
全表 96 行 = 2026-09-06 前 71 行(全部有值,#7067 之前的正常行为)
|
||||
+ 2026-09-06 后 25 行(19 行缺该键 + 6 行有值)
|
||||
缺值→有值的分界落在 2026-09-20 11:01 与 12:15 之间,即修复部署上 TEST 的时刻
|
||||
```
|
||||
|
||||
这 19 行的真值可精确反推(`assignmentSlotId ≡ previousAssignmentGroupId`),两条前提均已核验为 0:没有任何一行的 anchor 派单带存量槽位号、没有任何一行缺 `previousAssignmentGroupId`;另有 2026-09-20 09:37:57 那一行的网关响应原文可作对照点。即便如此本次**仍不订正**,理由见「六、边界行为」。
|
||||
|
||||
**本地测试**(在 `dev-v3 @ 8055b3988` 上实跑)
|
||||
|
||||
```
|
||||
AssignmentChangeReceiptMysqlTest#execute_realAopRedisAndMysql_replaysDurablyAcrossTtl
|
||||
(须带 -Dfleet.mysql.provider=testcontainers,否则整类 assumption-skip)
|
||||
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 ✓
|
||||
阳性对照:把写侧改回 null → Tests run: 1, Failures: 1
|
||||
失败断言 actual: null / expected: 8000L ✓ 该用例确实抓得住
|
||||
还原后复绿,两轮日志均含 Changes detected - recompiling(非 mtime 假绿)
|
||||
|
||||
AssignmentChangeReceiptServiceIntegrationTest Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
|
||||
AssignmentChangeReceiptServiceTest Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
|
||||
|
||||
fleet 全量 verify(严格,未加 -Dmaven.test.failure.ignore)
|
||||
surefire Tests run: 4451, Failures: 0, Errors: 0, Skipped: 5
|
||||
failsafe Tests run: 2, Failures: 0, Errors: 0, Skipped: 2
|
||||
BUILD SUCCESS;spotless:check 882 files / 0 needs changes
|
||||
完整性:源码侧 346 个测试类 vs 执行侧 346 个,两向差集为空
|
||||
Skipped 7 条全部是类级系统属性门控(非失败),逐条点名见工单 #7965 AC-8
|
||||
```
|
||||
|
||||
> 说明:`AssignmentChangeReceiptMysqlTest` 本身就在那 7 条门控跳过里——**默认 `mvn verify` 的「4451 用例 0 失败」并不包含本次缺陷的防线**,所以 AC-2 的单独带 `-D` 运行不可省。已另提 PR [#8034](https://git.1814.love:8443/wx/HL/pulls/8034) 在不带门控的 `AssignmentChangeReceiptServiceTest` 里补了两条 `OutcomeV1.from()` 字段映射用例(并做了变异对照:把写侧改回 null,仅跑该类即变红),把这条路补进合并门禁。该 PR 只动测试与注释,**不改任何运行时行为,与本 changelog 描述的契约无关**。
|
||||
|
||||
---
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户