文件
hl-api-changelog/changelogs-v2/2026-09/20_7965_改派回执幂等重放不再抹掉assignmentSlotId-修改接口-管理后台.md
T
jw 4e9e0c50bf
changelog-filename-gate / validate (push) Failing after 2s
docs(changelog): #7965 改派幂等重放 assignmentSlotId 口径订正 + 补实测读数
该文件的初稿被 #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
2026-09-20 15:56:36 +08:00

20 KiB
原始文件 Blame 文件历史

schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
schema ticket title consumer author change_type backend_status gateway_status frontend_status frontend_owner frontend_ref target_release verified_at status_note updated_at base
hl-changelog/v2 7965 改派幂等重放:assignmentSlotId 不再被抹成 null,重放与首调同值 admin jw(GIT) 修改接口 deployed verified pending mmg 改派 POST /admin/fleet/assignments/{assignmentId}/change 带 requestId 时,同一 requestId 的幂等重放此前恒返回 assignmentSlotId=null,而首次调用返回真实值——同一个 requestId 拿到两份不一样的「同一个结果」。本次把回执冻结口径改回照抄首调的值,重放与首调返回同一个 assignmentSlotId。入参、路径、其余出参字段、错误码、判权一律不变;首次调用的行为也完全不变,只有「同 requestId 重放」这一条路径的返回值变了。后端已合并 dev-v3(d31bb3209)并部署 TEST,网关双调实测两次响应体逐字节一致(工单 #7965 AC-1)。 2026-09-20 dev-v3

fleet: 改派幂等重放不再抹掉 assignmentSlotId

存放目录: changelogs-v2/{YYYY-MM}/(管理后台)

服务: hl-fleet-service (端口 8087) PR: #7997 Issue: #7965 日期: 2026-09-20 影响范围: 派车「改派」提交的重试/重放路径


⚠️ 关键变化

  1. 只有「同一 requestId 重放」这条路径的返回值变了:assignmentSlotId 从恒 null 改为与首次调用同值。
  2. 首次调用的行为一个字都没变:它本来就返回真实值。
  3. 不是新增字段、不是删字段:assignmentSlotId 一直在响应 VO 里;变的是它在重放时的取值。
  4. 入参、路径、方法、其余出参、错误码、判权全部不变。

一、背景

改派带 requestId 是幂等键:网络重试、前端重复提交时,第二次调用不会再改一次派单,而是把首次冻结的结果原样重放回来。

#7067「派单去槽位化」把回执写侧的 assignmentSlotId 无条件冻结成了 null,而产出侧(AssignmentService → AssignmentConverter → ChangeAssignmentRespVO)仍在计算并返回真实值。于是同一个 requestId 调两次,拿到两份不一样的「同一个结果」:首调是真实值,重放是 null。

口径订正:响应体不省略 null 字段(NON_NULL 只挂在回执写 outcome_json 的私有序列化器上,响应 VO 没有 @JsonInclude,实测响应里 "warningCode": null 原样带出)。所以改前前端看到的是 "assignmentSlotId": null——键在、值为 null,不是键消失。工单与 PR 正文里「重放的响应体里这个字段消失了」的说法只对数据库里的 outcome_json 成立,对响应体不成立。

这不是「哪个值才对」的问题——assignmentSlotId 该不该退役是另一件事;错的是两条路径不同值,这是幂等回执的定义性失败。本次只把两侧拉齐:回执照抄首调的值。将来若真要退役这个字段,写口与回执一起退,两侧仍然同值。

缺陷自 2026-09-06(913062466)起带病运行 13 天没被发现,因为唯一能抓住它的用例 AssignmentChangeReceiptMysqlTest 被系统属性 fleet.mysql.provider 门控,平时整类跳过、PR 全绿。


二、变更接口清单

# 接口 方法 路径 变更类型 说明
1 修改派单(改派) POST /admin/fleet/assignments/{assignmentId}/change 修改 同 requestId 幂等重放的 assignmentSlotId 由恒 null 改为与首调同值

三、接口详情

1. 修改派单(改派) POST /admin/fleet/assignments/{assignmentId}/change

VO: Result<ChangeAssignmentRespVO>

使用场景

派车管理后台改派:按车辆槽位和生效日换车、换司机或同时替换。前端提交时带上本次操作的 requestId;网络超时重试或用户重复点击时,用同一个 requestId 再调一次,后端不会重复改派,而是把首次的结果重放回来。

入参

本次入参一个没动,下表只列与本次相关的字段。

字段 位置 类型 必填 约束 说明
assignmentId Path Long ✅ 当前车辆槽位任一派单 ID 不存在返回 605009(不变)
effectiveDate Body LocalDate ✅ 须落在该派单服务日期区间内 不在区间返回 605028(不变)
requestId Body String ❌ 幂等键,由前端为「本次操作」生成一次 传了才有重放语义;同键不同业务载荷返回 605059(不变)

出参

仅列与本次相关的字段,其余出参(assignmentId、assignmentStatus、effectiveDate、affectedDays、protocolPrice、vehicleFeeTotal、dailyVehicleFees、otherVehicles、warningCode、sendItinerarySms 等)本次一个没动。

字段 类型 说明
assignmentSlotId Long 展示用历史槽位号,不参与任何判定。本次变更点:首调一直是真实值;同 requestId 重放此前恒 null,现在与首调同值。序列化为字符串(防 JS 精度丢失)
previousAssignmentGroupId Long 改派前的派车组身份(不变)
newAssignmentGroupId Long 改派后的新派车组身份(不变)
assignmentStatus String 改派后派单状态(不变)

请求示例

(测试服实测请求体,2026-09-20 15:26,改派 assignmentId=359407200621432832 换司机)

{
  "effectiveDate": "2026-11-06",
  "newDriverId": 2065272146627641345,
  "sendItinerarySms": false,
  "reason": "#7965 AC-1 取证",
  "requestId": "ac1-7965-20260920-002"
}

响应示例

(测试服实测:同一个 requestId 连调两次,两次响应体 MD5 相同)

{
  "code": 200,
  "message": "成功",
  "data": {
    "assignmentId": "359963532525178880",
    "assignmentSlotId": "359407200600461312",
    "previousAssignmentGroupId": "359407200600461312",
    "newAssignmentGroupId": "359963532273520640",
    "assignmentStatus": "assigned",
    "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——不变量是「两次同值」,不是「一定非空」:

{
  "code": 200,
  "message": "成功",
  "data": {
    "assignmentId": "359963532525178880",
    "assignmentSlotId": null,
    "previousAssignmentGroupId": null,
    "newAssignmentGroupId": "359963532273520640",
    "assignmentStatus": "assigned"
  },
  "traceId": null,
  "success": true
}

该字段为 null 时键仍然在,值是 null,不会消失。前端按「值可能为 null」判空即可,不必判键是否存在。

错误响应

{ "code": 605059, "message": "同一请求标识的业务载荷不一致", "data": null, "success": false }
code 触发条件
605059 同 requestId 但业务载荷不同(本次不变,且不产生改派副作用)
605009 派单不存在(本次不变)
605020 当前状态不允许改派(本次不变)
605028 生效日不在派单服务日期范围内(本次不变)
605041 最终基线复核不一致,data.dailyDifferences 给差异(本次不变)
401 未登录(网关拦截)

本次不新增、不修改任何错误码。

业务边界

  • 不变量是「同 requestId 的首调与重放返回同一个 assignmentSlotId」,不是「该字段一定非空」。
  • 该字段是展示用历史槽位号,#7067 之后不参与任何判定;不要拿它当主键、当筛选键、当幂等键。
  • 不同 requestId 的两次改派是两次独立操作,assignmentSlotId 本来就可能不同,不在本不变量范围内。
  • 同 requestId + 不同业务载荷仍返回 605059,且不产生任何改派副作用(不变)。
  • 不传 requestId 时没有重放语义,每次调用都是一次真实改派。

四、契约约束与正确调用方式

场景 做法
✅ 改派提交 为「本次操作」生成一个 requestId,重试时原样复用,不要每次重新生成
✅ 重试后读 assignmentSlotId 直接用,现在与首调同值;不需要为重放路径写兜底
✅ 渲染 assignmentSlotId 按「值可能为 null」判空;键恒存在,且是字符串形态的雪花 ID,别按 number 解析
❌ 把 assignmentSlotId 当业务主键 / 幂等键 / 筛选键 它是展示用历史槽位号,不参与判定;身份走 newAssignmentGroupId
❌ 重试时换一个新 requestId 会被当成一次新的改派,真的再改一次
❌ 依赖「重放时该字段为 null」来区分首调与重放 旧行为,已被本次修复取消;要区分请用前端自己的请求状态

五、数据库行为

无表变更、无 Flyway 迁移、无索引变更。

  • 唯一变化是改派回执表 fleet_assignment_change_receipt 的 outcome_json 列新写入的内容:assignmentSlotId 从「不写」变为「照抄首调的值」。该列由回执专用的私有序列化器写入,它配了 NON_NULL,所以值为 null 时键整个不落库——这也是改前存量行里查不到这个键的原因。列本身不变,outcome_schema_version 仍是 2(字段集没变,只是这个键从恒缺失变为有值;旧回执缺该键仍反序列化为 null,向后兼容)。
  • 存量回执不订正(详见「六、边界行为」):2026-09-06 ~ 2026-09-20 修复部署前落库的回执行,outcome_json 里没有这个键,重放它们仍返回 null。

六、边界行为

  • 同 requestId 重放 → assignmentSlotId 与首调同值(本次修复点)。
  • 该字段本身为 null(无历史槽位号且取不到锚点组身份)→ 首调与重放同为 null,不变量仍成立。
  • 同 requestId + 不同载荷 → 605059,不产生改派副作用(不变)。
  • 不传 requestId → 无重放语义,每次都是真实改派(不变)。
  • 存量回执(修复部署前落库的) → 重放仍返回 null,本次不订正。理由:回执的语义是「冻结首调那一刻的答案」,事后回填等于用推导值覆盖冻结结果;而 requestId 的实际重放窗口是「调用方当场重试」的秒级,存量行早已过了这个窗口,订正的收益接近零、破坏冻结语义的代价是实在的。存量读数与可反推结论见工单 #7965 AC-5。
  • 改派本身的所有其他行为(基线复核 605041、旧 HOLD 605042、车费校验 605045/605049、全程槽 605064、行程短信 sendItinerarySms、ORDER_HAS_OTHER_VEHICLES 提示)一律不变。

六.6、修改前后对比

字段级对比

字段 改前 改后
assignmentSlotId(首次调用) 真实值 真实值(不变)
assignmentSlotId(同 requestId 重放) 恒 null 与首调同值(首调为 null 时同为 null)
其余出参字段 — 不变(无新增、无删除、无改名、无类型变化)
入参 / 路径 / 方法 / 错误码 / 判权 — 不变

行为级对比

场景 改前 改后
前端提交改派后超时重试(同 requestId) 第二次响应里该字段变 null,页面上槽位号「重试一下就没了」 两次响应体逐字节一致
调用方比对两次响应做一致性校验 必然不一致,且不报错(静默) 一致
修复部署前落库的旧 requestId 重放 返回 null 仍返回 null(存量不订正)

六.7、影响评估

  • 是否破坏向后兼容: 否。首次调用的行为完全不变;重放路径是从「少一个字段」变成「字段齐全」,是补齐不是削减。
  • 前端是否必须同步上线: 否。此前若为重放路径写过「该字段可能为 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 再提一次——这正是本次修复的那条路径。

七、不影响范围

  • 仅影响: 本接口在「同 requestId 幂等重放」路径上 assignmentSlotId 的取值。
  • 零影响: 首次改派的全部行为与返回值;派单创建 / 取消 / 拒接 / 确认;车费计算与价格日历;行程短信;资源占用与 CAS;判权(/admin/fleet/** 仍要求 VEHICLE_MANAGER 或 SUPER_ADMIN);网关路由;数据库表结构。
  • #7067「槽位身份退役」的其余部分不在本次范围——该字段目前仍是半退役状态(src/main 里仍有多处写口),本次不推进也不回退它。

八、测试环境已验证

被测版本: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       ✓

存量回执读数(对应「六、边界行为」里「存量不订正」那条)

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 在不带门控的 AssignmentChangeReceiptServiceTest 里补了两条 OutcomeV1.from() 字段映射用例(并做了变异对照:把写侧改回 null,仅跑该类即变红),把这条路补进合并门禁。该 PR 只动测试与注释,不改任何运行时行为,与本 changelog 描述的契约无关。


十、相关文档

关联 / 联系人

链接

联系人

  • 后端负责人: @jw
  • 前端负责人: @mmg