--- schema: "hl-changelog/v2" ticket: "8159" title: "团级配车成员共用候选查询" consumer: "admin" author: "wx(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "bc4024952daa784232abe6fbf56b658b57361bb3" target_release: "v2.1" verified_at: "2026-09-22" status_note: "2026-09-22 mmg 交付:ShareGroupEditModal 新建/编辑选举(浏览态三态 selectable 不误禁、shareGroupId 复算恢复本关系成员并预勾、members 全集提交、605036 确认重发、602106 刷新重选),ShareGroupPanel 加入口,抽屉透传 serviceDates;checkpoint 全绿 26 测试" updated_at: "2026-09-22" base: "dev-v3" --- # 团级配车成员共用候选查询(#8159) > **服务**: hl-fleet-service(经网关调用,无需关心服务端口) > **PR**: #8188 > **Issue**: #8159 > **日期**: 2026-09-22 > **影响范围**: 管理后台团级配车页面成员共用选举 --- ## ⚠️ 关键变化 新增查询端点,返回「成员共用」的候选行清单,喂给已有的「确认共用关系」写口。 **三条会直接影响你怎么写代码的点,按严重度排**: 1. 🔴 **`shareGroupId` 必须由前端比对,本接口不会替你做**(业务边界第 2 条)。编辑一个**已存在**的共用关系时,它自己的既有成员会被本接口置灰成 `ALREADY_IN_ANOTHER_GROUP`——前端要把 `shareGroupId` 等于「正在编辑的关系 ID」的行**恢复成可勾选**。并且**提交时 `members` 是全集不是增量**:漏掉的既有成员会被写口按「移出本关系」处理,**不报错、静默生效**。 2. 🔴 **`occupying` / `selectable` 是三态,`null` 是「未判定」不是「否」**。不传 `resourceId` 时 `occupying` 恒 `null`、`selectable` **永不为 `true`**——但它**可能是 `false`**(已属别的关系、或团期身份缺失这两条浏览态就判得出来),所以置灰要照常渲染。 3. **成功码是 `code: 200`,不是 `0`**;业务失败也返 HTTP 200,**一律按 `body.code` 判,不要按 HTTP 状态行判**。 --- ## 一、背景(选填) 团级配车支持多个成员(派车行、团级配车行)共享同一车或司机资源的场景。选举新成员进关系时,需先查询该服务日下哪些成员行可选。本接口支持按资源维度(车/司机)和具体资源 ID 筛选候选。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 查询成员共用候选 | GET | `/admin/fleet/group-dispatch/batches/{groupBatchId}/share-member-candidates` | 新增接口 | 返回该团服务日下可选成员清单 | --- ## 三、接口详情 ### 1. 查询成员共用候选 `GET /admin/fleet/group-dispatch/batches/{groupBatchId}/share-member-candidates` **VO**: `ShareMemberCandidateRespVO` #### 使用场景 管理后台团级配车页面「成员共用」选举弹窗打开时,调用本接口获取候选成员清单。前端先让用户选择资源维度(车 / 司机),再传 `resourceId` 重新查询,获得勾选框可选状态。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | `groupBatchId` | Path | Long | ✅ | — | 团期 ID(`group_batch_id`)。🔴 **这不是 `product_batch_id`**;维度错误不报参数校验错,只返 `data: []` | | `serviceDate` | Query | String(`yyyy-MM-dd`) | ✅ | 必须在团期服务日窗内 | 服务日。窗外日期返 `code: 602104` | | `resourceType` | Query | String | ✅ | `VEHICLE` / `DRIVER` | 资源维度。缺失或非法值返 `code: 100001` | | `resourceId` | Query | Long | ❌ | — | 具体资源 ID(车 ID / 司机 ID)。**不传时 `occupying` / `selectable` 返 `null`**(未判定状态,见业务边界第 1 条) | #### 出参 `Result>` | 字段 | 类型 | 说明 | |------|------|------| | `sourceType` | String | `ASSIGNMENT`(派车行)/ `GROUP_DISPATCH`(团级配车行) | | `sourceId` | String | 雪花 ID,**字符串形态**(19 位,前端一律当字符串处理,禁转 Number) | | `requirementId` | String | 需求 ID(派车行有值,团级配车行可能 null) | | `orderId` | String / null | 订单 ID。`GROUP_DISPATCH` 行可能为 null | | `orderNo` | String | 订单号 | | `customerName` | String | 客户名称 | | `headcount` | Integer | 人数 | | `pickupAt` | String | 上车地点 | | `dropoffAt` | String / null | 下车地点,可能为 null | | `pickupParticipant` | Integer | 上车人数 | | `dropoffParticipant` | Integer | 下车人数 | | `groupCode` | String / null | 团号,可能为 null | | `vehicleModel` | String / null | 车型 | | `occupiedVehicleId` | String / null | 该行实际占用的车 ID;未占用时 null | | `occupiedVehiclePlate` | String / null | 该行实际占用的车牌号 | | `occupiedDriverId` | String / null | 该行实际占用的司机 ID;未占用时 null | | `occupiedDriverName` | String / null | 该行实际占用的司机名称 | | `occupying` | Boolean / **null** | 本行在 `(serviceDate, resourceType, resourceId)` 上是否活跃占用。**`resourceId` 未传时为 `null`**(未判定) | | `shareGroupId` | String / null | 本行所属的 ACTIVE 共用关系 ID;不属任何关系时 null。🔴 **调用方必须拿它和「正在编辑的关系 ID」比对**,见业务边界第 2 条 | | `selectable` | Boolean / **null** | 是否可勾选。`true` = 可选;`false` = 不可选(此时 `unselectableReason` 非空);**`null` = 未判定**(`resourceId` 未传、且本行未被 `ALREADY_IN_ANOTHER_GROUP` / `CROSS_BATCH` 判死时出现)。🔴 **`resourceId` 未传时本字段永不为 `true`** | | `unselectableReason` | String / null | 不可选原因枚举值(见六.5 章节);`selectable=true` 时为 null | | `unselectableDetail` | String / null | 人类可读的补充说明,如「订单 HLxxxx 已在车 A 关系中」 | #### 请求示例 ```json GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candidates?serviceDate=2026-12-25&resourceType=VEHICLE&resourceId=2085539421276286978 ``` 无请求体。Authorization 头由网关透传(管理后台 JWT)。 #### 响应示例 成功响应(带具体资源 ID 的正常清单): ```json { "code": 200, "message": "成功", "data": [ { "sourceType": "ASSIGNMENT", "sourceId": "2102125764705181697", "requirementId": "2102125585692295169", "orderId": "2102125467924684801", "orderNo": "HL20260922035901739", "customerName": "#8114AC8-X", "headcount": 1, "pickupAt": "满洲里口岸", "dropoffAt": null, "pickupParticipant": 0, "dropoffParticipant": 0, "groupCode": null, "vehicleModel": "丰田普拉多", "occupiedVehicleId": "2085539421276286978", "occupiedVehiclePlate": "蒙A-E2E99", "occupiedDriverId": "2065272150012444674", "occupiedDriverName": "道尔吉", "occupying": true, "shareGroupId": null, "selectable": true, "unselectableReason": null, "unselectableDetail": null } ], "success": true } ``` **示例说明**:示例值取自测试环境某次真实调用,不构成可复现夹具。 #### 空数据 / 降级响应 该团该服务日无任何在飞派单与活跃团级配车行时返 `code: 200, data: []`(**不是 null、不抛错**)。 ```json { "code": 200, "message": "成功", "data": [], "success": true } ``` 🔴 **区分「空」与「失败关闭」**:拿不到团期权威基线时,本接口**不降级返空**,而是**失败关闭**抛 `GROUP_BATCH_BASELINE_UNAVAILABLE`。⇒ `data: []` 只意味着「确实没有候选」,前端可以放心按「无可选成员」渲染,不必担心它其实是一次被吞掉的下游故障。 #### 错误响应 **服务日不在团期窗内**: ```json { "code": 602104, "message": "服务日不在团期服务日窗内: 2026-12-28", "success": false, "data": null } ``` **参数非法**(缺 `resourceType` 或传非法值): ```json { "code": 100001, "message": "参数非法: 资源维度非法: HOUSE", "success": false, "data": null } ``` #### 业务边界 **0. 本清单直接喂给写口**:返回的 `sourceType` + `sourceId` 就是「确认共用关系」写口 `members[]` 要的两个字段,**原样透传即可**,不需要再做映射。 **1. `resourceId` 未传时的 `null` 三态语义** | 字段 | 传了 `resourceId` | 未传 `resourceId`(浏览态) | |---|---|---| | `occupying` | `true` / `false` | 恒 `null`(未判定) | | `selectable` | `true` / `false` | `false` **或** `null`,**永不为 `true`** | | `unselectableReason` | `selectable=false` 时非空 | 同左 | `null` 的含义是**未判定**,不是「否」。不传 `resourceId` 时「当日占没占着这个资源」根本算不出来,所以 `occupying` 一律 `null`。 🔴 **但浏览态下 `selectable` 并不总是 `null`**:有两条判据不依赖 `resourceId`,浏览态照样能判死—— - **已属别的 ACTIVE 共用关系** → `selectable=false`,`unselectableReason=ALREADY_IN_ANOTHER_GROUP` - **团期身份缺失**(`requirement_id` 为空,或团期与本团不符)→ `selectable=false`,`unselectableReason=CROSS_BATCH` ⚠️ **订正 2026-09-22**:本行原写「`group_batch_id` / `requirement_id` 为空」,**`group_batch_id` 那一半不成立**——取数条件是 `eq(group_batch_id, ?)`,`group_batch_id` 为空的派单行**结构上查不出来**、压根不在清单里(见第六节「4. 覆盖范围」)。所以前端**不必**为「`group_batch_id` 为空 + `CROSS_BATCH`」写分支,那个组合不可能出现;会以 `CROSS_BATCH` 出现在清单里的,只有 `requirement_id` 为空或团期不符这两种。 ⇒ 前端在浏览态**不能**把 `selectable == null` 当成 `false` 禁用勾选,也**不能**当成 `true` 放开;但**要照常渲染 `selectable === false` 的置灰与原因**——那些行在浏览态就已经定死了,选了具体资源也不会变回可选。 **2. 🔴 `shareGroupId` 必须由调用方比对——这是契约的一部分,不是可选优化** 本读口**不知道你正在编辑哪一个共用关系**,所以凡已属任一 ACTIVE 关系的行,一律置灰为 `ALREADY_IN_ANOTHER_GROUP`。而写口的 602106 拒的是「属于**另一个**关系」——**同一关系的既有成员,写口是收的**。 ⇒ **给同一个 `shareGroupId` 追加第 3 个成员这个动作,本清单的置灰结构上覆盖不到。** 前端必须自己做两件事: 1. **把 `shareGroupId` 等于「正在编辑的关系 ID」的行视为可勾选**(忽略本接口给它的 `ALREADY_IN_ANOTHER_GROUP` 置灰)。不做这一步的表现是:编辑一个已有关系时,它自己的现有成员全部不可选,功能做不出来。 2. 🔴 **提交时 `members` 是全集不是增量**——要把该关系的**既有成员连同新成员一起**放进 `members`。**漏掉的既有成员会被写口按「移出本关系」处理。** 只提交新成员的写法不会报错,但会静默把原有成员全部踢出关系。 **3. `selectable=true` 不是「提交必成功」的承诺** 本清单是**时点快照、不加锁**。返回之后别的车务仍可能把某成员纳入另一个关系,提交时照样可能撞 `602106`。 ⇒ 这种 602106 请渲染成「**有人先一步占了,请刷新候选清单重选**」,🔴 **不要当系统故障自动重试——重试必然同样失败**。这条竞态读口消除不了(加锁也不行,锁在响应返回那一刻就已放开),最终由写口的锁定读与唯一键兜底。 **4. 覆盖范围:未关联团期的派单行结构上不在本清单内** 本清单只列 `group_batch_id` 指向本团的派单行。`group_batch_id` 为空的派单行查不出来——但**这不是缺口**:这类行在写口同样恒被 602103 拒,「看不见」与「看得见也提交不了」**等价**。要让这类行可共用,得先补上团期身份(数据侧动作,不是前端能处理的)。 --- ## 四、契约约束与正确调用方式 ### ✅ 正确 / ❌ 错误请求对照 🔴 **先说判据**:本服务的业务失败与入参校验**一律返 HTTP 200**(`GlobalExceptionHandler` 带 `@ResponseStatus(HttpStatus.OK)`),**`body.code` 才是真相**。前端判错请一律读 `body.code`,**不要读 HTTP 状态行**——按状态行判会把所有业务错误当成成功。 | 场景 | 请求 | 预期(HTTP 恒 200) | |------|------|------| | ✅ 浏览态,看全清单 | `?serviceDate=2026-12-25&resourceType=VEHICLE`(无 resourceId) | `code: 200`;各行 `occupying=null`;`selectable` 为 `null` 或 `false`(被判死的行),**不会是 `true`** | | ✅ 选定车后判可选 | `?serviceDate=2026-12-25&resourceType=VEHICLE&resourceId=2085539421276286978` | `code: 200`;各行 `occupying=true/false`,`selectable=true/false` | | ✅ 同团其他服务日 | `?serviceDate=2026-12-26&resourceType=VEHICLE&resourceId=xxx` | 在该团服务日窗内则照常返回候选,否则 `code: 602104` | | ❌ 维度错填 | `?serviceDate=2026-12-25&resourceType=HOUSE&resourceId=xxx` | `code: 100001`「参数非法: 资源维度非法: HOUSE」 | | ❌ 缺维度参数 | `?serviceDate=2026-12-25&resourceId=xxx`(无 resourceType) | `code: 100001`「参数非法: 资源维度不能为空」 | | ❌ 路径参数填成产品班期 ID | `/batches/{product_batch_id}/share-member-candidates?...` | 🔴 **不报参数错**,返 `code: 200` + `data: []`。失败形态是静默的,看起来像「接口没数据」 | --- ## 五、数据库行为 本接口**仅读**,无任何 DB 写入,不新增表、不改表结构、无 Flyway 脚本。 --- ## 六、边界行为 **鉴权**(🔴 本单专门订正过一处长期误解,前端排查 403 时会用到): - **未登录 / token 失效** → 网关 `JwtAuthFilter` 拦下 - **已登录但角色不符** → `FleetAdminRoleGuardInterceptor` 对 `/admin/fleet/**` **只放行 `VEHICLE_MANAGER` 与 `SUPER_ADMIN` 两个角色** - 🔴 **鉴权的真相是角色,不是权限点**:`fleet:group-dispatch:view` / `fleet:group-dispatch:write` 这两个字符串在整个 Java 侧**零引用**,它们只是权限字典里的声明,不是运行时判据。⇒ **「给某个非车务角色开个只读权限点,让他看候选清单」这件事配置不出来**——那类角色连 `/admin/fleet/**` 都进不来。要改授权粒度得动拦截器,是另一件事。 **其余边界**(一律 HTTP 200,按 `body.code` 判): - **资源维度缺失 / 非法** → `code: 100001` - **服务日超出团期服务日窗** → `code: 602104` - **团期权威基线拿不到** → **失败关闭**(不降级返空),见「空数据 / 降级响应」 - **本团本服务日无候选** → `code: 200` + `data: []` **网关路由**:复用既有 `- Path=/admin/fleet/**` → `lb://hl-fleet-service`,本单**不新增路由**。 --- ## 六.5 枚举 / 数据字典 ### sourceType(行源类型) **所属字段**: `sourceType` | **类型**: `String` | 值 | 中文 | 说明 | |---|---|---| | `ASSIGNMENT` | 派车行 | 订单需求对应的派车行 | | `GROUP_DISPATCH` | 团级配车行 | 团级配车单位产生的配车行 | ### resourceType(资源维度) **所属字段**: `resourceType`(查询参数) | **类型**: `String` | 值 | 中文 | 说明 | |---|---|---| | `VEHICLE` | 车辆 | 按车 ID 筛选候选 | | `DRIVER` | 司机 | 按司机 ID 筛选候选 | ### unselectableReason(不可选原因) **所属字段**: `unselectableReason` | **类型**: `String` | 值 | 触发条件 | 浏览态(未传 `resourceId`)能否判出 | 对应写口错误码 | |---|---|---|---| | `CROSS_BATCH` | `requirement_id` **为空**,或团期与本团不符。⚠️ 订正 2026-09-22:**不含 `group_batch_id` 为空**,那类行结构上查不出来、不在清单里(见第六节「4. 覆盖范围」) | ✅ 能 | 602103 | | `NOT_OCCUPYING` | 派单不在**可改派的在飞态**,或当日**标记不用车** | ❌ 不能(需 `resourceId`) | 602102 | | `ALREADY_IN_ANOTHER_GROUP` | 本行已属某个 ACTIVE 共用关系(此时 `shareGroupId` **非空**) | ✅ 能 | 602106 | | `GROUP_DISPATCH_INVALID` | 团级配车行**当日不在此资源上** | ❌ 不能(需 `resourceId`) | 602105 | **说明**:这一列值**不是**接口返回的 `code`,而是 `unselectableReason` 字段的取值。本接口只要能返回清单,`code` 恒为 `200`;上面那些 6021xx 是**提交共用关系**(写口)失败时才会出现在 `code` 里的。 🔴 **`ALREADY_IN_ANOTHER_GROUP` 这一行前端必须自己复算**:本读口不知道你在编辑哪个关系,会把正在编辑的那个关系的既有成员也置灰成它。判据是 `shareGroupId` 是否等于正在编辑的关系 ID,详见业务边界第 2 条。 --- ## 六.6 修改前后对比 无,本接口为新增。 --- ## 六.7 影响评估 - **破坏兼容**:否,新增接口 - **前端同步上线要求**:否,新接口不影响现有流程 - **workaround 清理**:无 --- ## 七、不影响范围 - **仅影响**:管理后台团级配车页面成员共用选举功能 - **零影响**: - 派车行的查询 / 新增 / 编辑 - 订单创建 / 支付流程 - 团期成团 / 分配等其他流程 --- ## 八、测试环境已验证 真实接口调用结果(hl-fleet-service 已部署 `dev-v3 @ cf9dc85a4`,merge commit `a584bd087`): ``` GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candidates ?serviceDate=2026-12-25&resourceType=VEHICLE&resourceId=2085539421276286978 → 200 + 候选清单(occupying=true/false, selectable=true/false) ✓ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candidates ?serviceDate=2026-12-25&resourceType=VEHICLE → 200 + 候选清单(occupying=null, selectable=null) ✓ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candidates ?serviceDate=2026-12-28&resourceType=VEHICLE&resourceId=xxx → 602104(服务日不在团期服务日窗内) ✓ GET /admin/fleet/group-dispatch/batches/2102125467954044929/share-member-candidates ?serviceDate=2026-12-25&resourceType=HOUSE → 100001(参数非法: 资源维度非法: HOUSE) ✓ ``` --- ## 九、相关历史 PR 无,本接口为新增。 --- ## 十、相关文档 - 关联 Issue: [wx/HL#8159](https://git.1814.love:8443/wx/HL/issues/8159) - 关联 PR: [wx/HL#8188](https://git.1814.love:8443/wx/HL/pulls/8188) --- ## 关联 / 联系人 ### 链接 - **Issue**: [#8159](https://git.1814.love:8443/wx/HL/issues/8159) - **PR**: [#8188](https://git.1814.love:8443/wx/HL/pulls/8188) - **Merge commit**: [a584bd087](https://git.1814.love:8443/wx/HL/commit/a584bd087) ### 联系人 - **后端负责人**: @wx