--- schema: "hl-changelog/v2" ticket: "8064" title: "fleet: 共用关系成员失去占用后,关系现在会自动收缩(行为变更,无接口契约变化)" consumer: "admin" author: "wx(GIT)" change_type: "修复" backend_status: "deployed" gateway_status: "verified" frontend_status: "not_required" frontend_owner: "" frontend_ref: "" target_release: "" verified_at: "" status_note: "本条无任何接口出入参或错误码变化,变的是同一请求的副作用:共用关系现在会在成员失去占用时自动收缩、剩余不足 2 人时自动解除。gateway_status=verified 的依据是 2026-09-21 经网关 https://api.test.1814.love:9443 的真实调用(建关系 + 两次 soft-clear-assignment),调用流水与库内终态逐条对照一致,不是只看部署登记。本条会让共用关系『自己消失』,前端是否需要改代码取决于两点:有没有缓存 activeShareGroupId、有没有「关系只能人工解除」的假设。这两点后端查证不了,已由前端侧自查确认(见下文 mmg 的实证)。⚠️ 存量收敛已于 2026-09-21 执行完毕(AC-5):经应用路径点名收敛,摘除成员行 10 条、整体释放关系 4 条;另有 2 条关系在摘除成员后仍保持 ACTIVE——那是正确终态,收缩规则是「剩余成员 < 2 时才整体释放」,这 2 条原各 3 名成员、摘 1 名后仍剩 2 名。本轮为点名执行而非全库扫,名单外可能仍存在同类历史数据,前端展示逻辑该兜的仍要兜。前端实证 not_required(mmg 2026-09-21):按「四、前端要做什么」逐项自查——activeShareGroupId/shareGroup/share-groups/shareEligible 前端 src 全仓零命中(共用关系属 #7444 未接入挂起域,同 #8051/#8061 实证口径),前端无任何共用关系状态缓存、无「关系只能人工解除」的假设;未来接入时均为操作后实时拉取,自动收缩不产生前端脏读。" updated_at: "2026-09-22" base: "dev-v3" --- # fleet: 共用关系成员失去占用后,关系现在会自动收缩(行为变更,无接口契约变化) > **存放目录**: 二期(fleet 车务)→ `changelogs-v2/2026-09/` ## ⚠️ 关键变化 **接口契约零变化**——没有新增/修改/删除任何端点,没有新增/修改任何请求或响应字段,没有新增任何错误码。 **变的是同一个请求的副作用。** 从本次修复上线起: 1. 共用关系的某个成员因任何一条**真实的占用释放路径**失去对该车/该司机的占用时,该成员会被**自动移出**关系(成员行落 `left_at`,`leave_reason = AUTO_OCCUPANCY_RELEASE`); 2. 移出后**剩余成员不足 2 个**时,整条关系**自动解除**(`status = RELEASED`,`release_reason = AUTO_SINGLE_MEMBER`,`active_share_group_id` 置空)。 🔴 **这两件事在本次修复之前,在当前部署形态下一次都没有发生过**(不是"偶尔没触发",是整条代码路径执行不到,见"一、背景")。所以对前端而言,这是一个**从未出现过的新现象**,不是一个已有现象的修正。 ## 一、背景 ### 缺陷形态 自动收缩的唯一入口是 `GroupDispatchShareOccupancyRefService.onClaimReleased(...)`,全仓唯一调用点在 `OccupancyDualWriteCoordinator.notifyShareGroupOfRelease(...)`;后者只在 `releaseCurrent(...)` 内被调,`releaseCurrent` 只在 `synchronizeOne(...)` 内被调,而 `synchronize()` 的**第一条语句**是: ```java if (!context.dualWrite()) { return; } ``` 而占用账本的灰度形态(`fleet_occupancy_rollout_fence`,64 条分片)在测试环境与生产环境**全部为 `LEGACY`**,并已于工单 #7972 定案**维持不切**(理由:回填完成前切换会让新账本少记占用,属于"用一个偏松的判据替掉现状")。 ⇒ **在 `LEGACY` 形态下,`synchronizeOne` / `releaseCurrent` / `notifyShareGroupOfRelease` / `onClaimReleased` 整条链路一行都执行不到。** ### 这个判断是怎么坐实的 不是靠读代码推断,是靠一个**在"修好了"和"没修"两个世界里取值不同**的读数: | 读数 | 修复前 | 修复后 | |---|---|---| | 全库 `fleet_group_dispatch_share_group.release_reason = 'AUTO_SINGLE_MEMBER'` 计数 | **0** | **1** | | 全库 `fleet_group_dispatch_share_log` 中 `reason = 'AUTO_SINGLE_MEMBER'` | **无** | 有 | ⚠️ **一个容易读错的邻近读数**(写在这里免得下一个人重复踩):成员行的 `leave_reason = 'AUTO_OCCUPANCY_RELEASE'` 修复前**并不是 0,而是 28**——但那 28 条**全部来自手工解除路径**(24 条属 `MANUAL`、4 条属 `CLEAR_ALL`),因为手工解除与自动收缩**复用了同一个成员级原因值**。⇒ **按成员行的 `leave_reason` 计数衡量自动收缩活跃度会错 86%**,必须 join 到所属关系的 `release_reason` 才能分辨。 ### 修复 - PR **#8073**(`c07942035`):把 LEGACY 形态下的收缩动作挂到 `dualWrite` 闸门**之前**,使其在不切换账本形态的前提下真正执行。 - PR **#8077**(`3a92cafbf`):整团解除时,审计用的关系解除原因**不被自动收缩夺走**(走豁免,而不是调整执行顺序)——即"整团解除"仍记为整团解除,不会被改写成 `AUTO_SINGLE_MEMBER`。 ## 二、变更接口清单(无) 本条**不新增、不修改、不删除**任何 HTTP 接口,也不改动任何请求/响应字段与错误码。 下列既有端点的**行为副作用**受影响(契约不变): | METHOD | Path | 变化 | |---|---|---| | POST | `/admin/fleet/assignments/{assignmentId}/soft-clear-assignment` | 若该派单是某 active 共用关系的成员,调用后该成员被自动移出;剩余 < 2 人时关系自动解除 | | POST | `/admin/fleet/assignments/{assignmentId}/driver-reject` | 同上 | | POST | `/admin/fleet/assignments/{assignmentId}/change`(改派替换) | 同上 | | POST | `/admin/fleet/assignments/{assignmentId}/complete`(完结) | 同上 | | DELETE | `/admin/fleet/assignments/{assignmentId}`(取消) | **仅**当被取消的行在取消前处于 `unassigned` 时才进入释放集,见下方"三、边界行为" | ## 三、边界行为(这一节比上面的清单更重要) ### ⛔ 取消派单**通常不会**触发收缩 `DELETE /admin/fleet/assignments/{assignmentId}` 对**在途**的派车行落的是 `exception` 而**不是** `canceled`,而按工单 **#6152** 的定案,`exception` **保留占用**。只有"取消前已是 `unassigned`"的那部分行会落 `canceled` 并进入释放集。 ⇒ **前端不要把"取消派单"当成"会解除共用关系"的动作。** 车务若想解除关系,正确动作是显式调解除接口,或使用软清。 ### 会触发收缩的五条路径 软清 / 司机拒接 / 改派替换 / 完结 / 取消中落 `canceled` 的那一子集。它们在后端收敛到同一个写口,因此行为一致。 ### 软清清的是整行,不是单一维度 软清会把该派车行的**车与司机一起清空**(车辆 id / 车牌 / 车型 / 司机 id / 姓名 / 电话全部置空,并把当日车辆用量归零)。⇒ 若该派单同时参与了 VEHICLE 维度与 DRIVER 维度的两条共用关系,**两条都会受影响**,不是只动一条。 **2026-09-22 双维度活体实测坐实**(此前这一段是源码推演,现补实测):自建班期下造 2 条派单共用**同一辆车 + 同一个司机**,于是 VEHICLE 与 DRIVER 各成一条 2 人共用关系;软清其中一条派单后,两条关系**各自独立**收缩,**同时**转 `RELEASED`: | 维度 | 关系状态 | `release_reason` | `released_at` | |---|---|---|---| | VEHICLE | `RELEASED` | `AUTO_SINGLE_MEMBER` | `2026-09-21 16:32:26` | | DRIVER | `RELEASED` | `AUTO_SINGLE_MEMBER` | `2026-09-21 16:32:26` | 两条关系下的**全部 4 条成员行**(含**未被软清**的那一户在两条关系里的成员行)都落 `left_at = 2026-09-21 16:32:26`、`leave_reason = AUTO_OCCUPANCY_RELEASE`;未被软清那条派单的派车行本身**完全未动**(车、司机、状态原样)。 ⇒ **前端含义**:一次软清会让该派单参与的**每一个**维度的关系消失。若界面按维度分别渲染共用关系,操作后**两块都要重新拉取**,不能只刷新其中一块。 ### 机制是「每个维度各自收缩」,不是「一个维度带动另一个」 自动收缩的触发口是**占用被释放**:`GroupDispatchShareOccupancyRefService#onClaimReleased` 按 `(服务日, 资源维度, 资源 ID, 来源)` 判定,只接**单个**资源维度,方法体内没有任何跨维度查询;上游 `OccupancyDualWriteCoordinator#notifyShareGroupOfRelease` 按**单条**占用行取它自己的维度调一次。⇒ 上一节看到的「两条同时 RELEASED」,成因是**同一个动作释放了两个维度的占用**(软清清整行),钩子因此被各调了一次,**不是**维度之间有传导。 这个区别对前端有实际后果,体现在**手工解除关系**上: - 调解除接口解除 VEHICLE 维度的关系时,后端按释放集算法决定哪些成员的占用真被释放(被释放的那些会走软清 ⇒ 它们在 DRIVER 维度的关系也随之收缩),**留任的成员占用不变**⇒ 它们在 DRIVER 维度的关系**原样保留**。 - ⇒ **别预测**「解除一个维度会不会连带另一个」——它取决于这次解除把谁判进了释放集。**操作后两个维度都重新拉取**是唯一可靠的做法。 ### 整团解除不会被改写成自动收缩 整团解除时,关系的 `release_reason` 仍记录整团解除的原因,不会被自动收缩改写成 `AUTO_SINGLE_MEMBER`(PR #8077)。审计与对账按 `release_reason` 区分来源时,这一点是可靠的。 ## 四、前端要做什么 **需要确认的一件事**:贵侧是否在任何地方**缓存**了共用关系状态(`activeShareGroupId`、关系成员列表、"该派单属于某共用关系"这一标志),或是否在代码/交互里假设了「**共用关系只能由人工显式解除**」。 - 若有 ⇒ 该假设**已不再成立**,关系会在上述释放类操作之后"自己消失",需要改为操作后重新拉取。 - 若无(每次都实时拉取)⇒ 前端零改动,请把 `frontend_status` 改为 `not_required` 并说明依据。 ⚠️ 我**没有**替贵侧填 `not_required`:那是一个关于前端代码的事实,后端查证不了。**这个位由看得见前端代码的人来定。** ## 五、测试环境已验证 **部署**:fleet 测试服部署点 `51571c58a`;`git merge-base --is-ancestor c07942035 51571c58a` 与 `3a92cafbf` **均为真**;Nacos 两实例 `healthy = true`。 **形态**:`fleet_occupancy_rollout_fence` 维持 `LEGACY`(**未改任何配置、未翻闸门**——在 DUAL_WRITE 下绿不能证明本缺陷修好了,那正是它的藏身处)。 **网关**:全部调用经 `https://api.test.1814.love:9443`,调用流水逐条留档。 **夹具**:服务日 **2026-12-20**(全新,不复用存量),团车 + 司机 + 甲乙两户,VEHICLE 维度共用关系,3 个 alive 成员。 **三次查询逐条对**: 1. **软清前**:3 个成员全部 alive,关系 `status = ACTIVE`,关系日志仅一条 `CREATE`; 2. **软清甲之后**:关系仍 `ACTIVE`、`release_reason` 为 null、`active_share_group_id` 仍指向本关系;**甲的成员行** `left_at = 2026-09-21 01:14:08`、`leave_reason = AUTO_OCCUPANCY_RELEASE`,而**团车行与乙的 `left_at` 仍为 null** ⇒ 剩 2 行 alive。(团车行与乙没动,是排除"整条关系被一把清掉"的阳性对照。) 3. **软清乙(最后一人)之后**:`status = RELEASED`、`release_reason = AUTO_SINGLE_MEMBER`、`active_share_group_id = NULL`、`released_at = 2026-09-21 01:14:08`;关系日志出现 `{action: RELEASED, reason: AUTO_SINGLE_MEMBER}`。 ## 六、不影响范围 - 任何接口的**请求参数、响应字段、错误码**——一个都没动。 - **取消派单**对在途行的行为(仍落 `exception`、仍保留占用,#6152 定案不变)。 - **整团解除**的审计原因(PR #8077 专门保证它不被夺走)。 - 占用账本(`fleet_resource_occupancy_*`)的灰度形态——**仍维持 `LEGACY`,本次修复没有切换它**,这正是本次修复的设计目标:不靠切形态也能让收缩跑起来。 - **存量数据**:已通过 internal 端点执行收敛(2026-09-21 22:27:47 UTC,不是 SQL 直改)。结果: - 摘除成员 **10 行**:`left_at = 2026-09-21 22:27:47`、`leave_reason = AUTO_OCCUPANCY_RELEASE` - 整体释放关系 **4 条**:`status = RELEASED`、`release_reason = AUTO_SINGLE_MEMBER`、`released_at = 2026-09-21 22:27:47` - 摘除成员后仍保持 ACTIVE 的关系 **2 条**(**非残留**:它们各有 3 个成员,摘掉 1 个后剩 2 个,按收缩规则"剩余 < 2 时解除"不触发) ⚠️ 本轮收敛是**点名执行**(传入显式 ID 名单),不是全库扫。若名单外仍有同类历史数据,前端在界面上仍可能遇到"有成员、但该成员已不占用该资源"的关系——**展示逻辑该兜的还得兜**,别假设这类数据在库里已绝迹。 ## 七、相关历史 PR - PR #8073(`c07942035`)—— 让自动收缩在 LEGACY 下真正执行。 - PR #8077(`3a92cafbf`)—— 整团解除的审计原因不被自动收缩夺走。 - 工单 #7972 —— 定案占用账本形态维持 `LEGACY` 不切(本缺陷得以长期存在的环境前提)。 - 工单 #6152 —— 取消派单对在途行落 `exception` 且保留占用。 - 工单 #8061 —— 解除共用关系只清成员占用、同槽非成员不动。 ## 关联 / 联系人 ### 链接 - 后端工单:https://git.1814.love:8443/wx/HL/issues/8064 - PR:https://git.1814.love:8443/wx/HL/pulls/8073 、 https://git.1814.love:8443/wx/HL/pulls/8077 ### 联系人 - 后端:wx - 前端:mmg(本条需前端确认是否缓存了共用关系状态,见"四、前端要做什么")