--- schema: "hl-changelog/v2" ticket: "7965" title: "改派幂等重放:assignmentSlotId 不再被抹成 null,重放与首调同值" consumer: "admin" author: "jw(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "pending" 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。" updated_at: "2026-09-20" base: "dev-v3" --- # fleet: 改派幂等重放不再抹掉 assignmentSlotId > **存放目录**: `changelogs-v2/{YYYY-MM}/`(管理后台) > > **服务**: hl-fleet-service (端口 8087) > **PR**: [#7997](https://git.1814.love:8443/wx/HL/pulls/7997) > **Issue**: [#7965](https://git.1814.love:8443/wx/HL/issues/7965) > **日期**: 2026-09-20 > **影响范围**: 派车「改派」提交的**重试/重放**路径 --- ## ⚠️ 关键变化 1. **只有「同一 `requestId` 重放」这条路径的返回值变了**:`assignmentSlotId` 从恒 `null`(响应体里该字段直接消失)改为**与首次调用同值**。 2. **首次调用的行为一个字都没变**:它本来就返回真实值。 3. **不是新增字段、不是删字段**:`assignmentSlotId` 一直在响应 VO 里;变的是它在重放时的取值。 4. **入参、路径、方法、其余出参、错误码、判权全部不变。** --- ## 一、背景 改派带 `requestId` 是幂等键:网络重试、前端重复提交时,第二次调用不会再改一次派单,而是把首次冻结的结果原样重放回来。 `#7067`「派单去槽位化」把回执写侧的 `assignmentSlotId` 无条件冻结成了 `null`,而产出侧(`AssignmentService` → `AssignmentConverter` → `ChangeAssignmentRespVO`)仍在计算并返回真实值。于是**同一个 `requestId` 调两次,拿到两份不一样的「同一个结果」**:首调有这个字段,重放没有(响应用 `NON_NULL` 省略 null,字段整个不出现)。 这不是「哪个值才对」的问题——`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` #### 使用场景 派车管理后台改派:按车辆槽位和生效日换车、换司机或同时替换。前端提交时带上本次操作的 `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`(字段在响应体里消失),现在与首调同值 | | previousAssignmentGroupId | Long | 改派前的派车组身份(不变) | | newAssignmentGroupId | Long | 改派后的新派车组身份(不变) | | assignmentStatus | String | 改派后派单状态(不变) | #### 请求示例 ```json { "effectiveDate": "2027-08-28", "newDriverId": 2089686360926380033, "reason": "#7965 AC-1 取证", "requestId": "ac1-7965-20260920" } ``` #### 响应示例 (同一个 `requestId` 连调两次,两次响应体一致) ```json { "code": 200, "message": "成功", "data": { "assignmentId": 359948910304825344, "assignmentSlotId": 359947685400285184, "previousAssignmentGroupId": 359947685400285184, "newAssignmentGroupId": 359948910271270912, "assignmentStatus": "assigned", "effectiveDate": "2027-08-28", "affectedDays": 1 }, "success": true } ``` #### 空数据 / 降级响应 本接口不返回空数据形态:要么改派成功返回上述结构,要么返回错误码。 `assignmentSlotId` 本身**可以是 `null`**(该派单没有历史槽位号、也取不到锚点组身份时),此时首调与重放**同为 `null`**——不变量是「两次同值」,不是「一定非空」。 ```json { "code": 200, "message": "成功", "data": { "assignmentId": 359948910304825344, "previousAssignmentGroupId": null, "newAssignmentGroupId": 359948910271270912, "assignmentStatus": "assigned" }, "success": true } ``` > 注意:响应用 `NON_NULL` 省略 null 字段,所以 `assignmentSlotId` 为 null 时**该键不出现**,不是出现一个 `null`。前端取值要按「键可能不存在」写。 #### 错误响应 ```json { "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 时整个键被省略) | | ❌ 把 `assignmentSlotId` 当业务主键 / 幂等键 / 筛选键 | 它是展示用历史槽位号,不参与判定;身份走 `newAssignmentGroupId` | | ❌ 重试时换一个新 `requestId` | 会被当成一次新的改派,真的再改一次 | | ❌ 依赖「重放时该字段缺失」来区分首调与重放 | 旧行为,已被本次修复取消;要区分请用前端自己的请求状态 | --- ## 五、数据库行为 **无表变更、无 Flyway 迁移、无索引变更。** - 唯一变化是改派回执表 `fleet_assignment_change_receipt` 的 `outcome_json` 列**新写入的内容**:`assignmentSlotId` 从「不写(NON_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`,`NON_NULL` 省略后该键在响应体里**不出现** | 与首调**同值**(首调为 null 时同为 null) | | 其余出参字段 | — | 不变(无新增、无删除、无改名、无类型变化) | | 入参 / 路径 / 方法 / 错误码 / 判权 | — | 不变 | ### 行为级对比 | 场景 | 改前 | 改后 | |------|------|------| | 前端提交改派后超时重试(同 `requestId`) | 第二次响应里少一个字段,页面上槽位号「重试一下就没了」 | 两次响应体一致 | | 调用方比对两次响应做一致性校验 | 必然不一致,且不报错(静默) | 一致 | | 修复部署前落库的旧 `requestId` 重放 | 返回 null | **仍返回 null**(存量不订正) | ## 六.7、影响评估 - **是否破坏向后兼容**: 否。首次调用的行为完全不变;重放路径是从「少一个字段」变成「字段齐全」,是补齐不是削减。 - **前端是否必须同步上线**: 否。此前若为重放路径写过「该字段可能缺失」的兜底,**那段兜底可以保留**(字段为 null 时仍会缺失),不需要改。 - **回滚**: 回滚 PR #7997 即可。回滚后新写入的回执重新变回不带该字段;已写入的带值回执不受影响(读侧照常反序列化)。 - **风险**: 低。改动是回执写侧一行取值,不触及改派本身的任何判定、状态机与资源占用。 --- ## 七、不影响范围 - **仅影响**: 本接口在「同 `requestId` 幂等重放」路径上 `assignmentSlotId` 的取值。 - **零影响**: 首次改派的全部行为与返回值;派单创建 / 取消 / 拒接 / 确认;车费计算与价格日历;行程短信;资源占用与 CAS;判权(`/admin/fleet/**` 仍要求 `VEHICLE_MANAGER` 或 `SUPER_ADMIN`);网关路由;数据库表结构。 - `#7067`「槽位身份退役」的其余部分不在本次范围——该字段目前仍是**半退役**状态(`src/main` 里仍有多处写口),本次不推进也不回退它。 --- ## 八、测试环境已验证 --- ## 十、相关文档 - 关联 Issue: [wx/HL#7965](https://git.1814.love:8443/wx/HL/issues/7965) - 关联 PR: [wx/HL#7997](https://git.1814.love:8443/wx/HL/pulls/7997) - 缺陷引入来源 `#7067` 派单去槽位化: [wx/HL#7067](https://git.1814.love:8443/wx/HL/issues/7067) - 门控测试类结构性盲区的处理见 [wx/HL#7444](https://git.1814.love:8443/wx/HL/issues/7444) ## 关联 / 联系人 ### 链接 - **Issue**: [#7965](https://git.1814.love:8443/wx/HL/issues/7965) - **PR**: [#7997](https://git.1814.love:8443/wx/HL/pulls/7997) - **Merge commit**: [d31bb3209](https://git.1814.love:8443/wx/HL/commit/d31bb3209) ### 联系人 - **后端负责人**: @jw - **前端负责人**: @mmg