--- schema: "hl-changelog/v2" ticket: "7608" title: "团期取消成团接入 group-batch:manage 判权(589507),带 nacos 灰度开关" consumer: "admin" author: "jw(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "not_required" frontend_owner: "mmg" frontend_ref: "" target_release: "" verified_at: "2026-09-13" status_note: "AC-2/AC-3/AC-6 与批 0 探针四项均在 TEST 网关实打通过(构建身份逐批探针确认)。对前端有一处硬影响:CUSTOMIZER / FINANCE / ROOM_MANAGER / VEHICLE_MANAGER 改前可正常取消成团,改后一律 589507,需按 group-batch:manage 隐藏「取消成团」按钮(与「成团」按钮同一个权限码),否则低权限角色会点到必然失败的按钮。响应结构与业务错误码不变,无需改解析逻辑。回滚不需重发服务:改 nacos group-batch.acl.enforce.cancel-group=false,实测约 7s 生效。 前端实证 not_required(2026-09-13, mmg):cancel-group 端点前端尚未接入(cancelGroup/取消成团 全仓零命中),无「取消成团」按钮可隐藏;本域既定口径为阶段门写操作按 batchStatus 白名单显隐+可见即可点+589507 兜底(batch/detail/index.vue:40-42,group-batch 按钮权限本就不接线),589507 由 request.js 拦截器统一透后端 message 天然覆盖。将来若接 cancel-group 按同口径即可,响应结构与业务错误码不变零额外改动。另三端点仅旁路观察对外零变化。" updated_at: "2026-09-13" base: "dev-v3" --- # order-v3: 团期取消成团接入 `group-batch:manage` 判权 **服务**: hl-order-service-v3 **PR**: #7616 **Issue**: #7608 --- ## ⚠️ 关键变化 🔴 **`cancel-group` 从「任何后台角色都能调」变为「必须持 `group-batch:manage`」,无权限返回 `589507`。** 改前**零判权**:定制师 / 房务 / 车务 / 财务任一持有效后台 token 的角色都能把一个已成团的团期打回招募中。 > **谁会受影响**:目前 `group-batch:manage` 只授予 `ADMIN` 与 `SUPER_ADMIN`(`V20260906_002` 种子)。`CUSTOMIZER` / `FINANCE` / `ROOM_MANAGER` / `VEHICLE_MANAGER` 在团期 9 个权限码上**零授权**,这些角色调用本端点将从改前的正常执行变为 `589507`。**若前端把「取消成团」按钮展示给了这些角色,需要按权限隐藏,否则用户会点到一个必然失败的按钮。** 🟡 **这是权限不对称的修复,不是新增限制。** 同一对按钮的另一半——**成团**(`POST .../group`)自 `#7158` 起就要求 `group-batch:manage`。改前的状态是:**低权限账号可以撤销高权限账号的动作**。 🟢 **响应结构、成功码、业务错误码全部不变。** 有权限的调用方(ADMIN / SUPER_ADMIN)行为与改前逐字节一致。 --- ## 一、背景 `#7455`(P0,已完成)自称做了「全端点扫描」并收口了 15 个零判权端点,但**漏掉了 `GroupBatchActionController` 里的 4 个**。 根因是审计方法本身的缺陷:该方法「逐个 Controller 数 `GroupBatchPermissionGuard` 的命中数,命中 0 即无强制」——这是**按类计数**。`GroupBatchActionController` 因为 `advance` 与 `recheck-material-gate` 两处有 guard,**整个类被归入「已判权」**,于是同类里裸着的 4 个端点全部漏检。 > **推论**:任何「按类计数」的判权审计,都会在**混合类**(部分方法有守卫、部分没有)上系统性漏检。 本单是这 4 个端点收口的**第一批**。`cancel-group` 之所以能不等观察窗直接收口:同一个团期**必须先由持 `manage` 的人成团**才可能被取消,调用方集合可被静态证明是成团调用方的子集,不存在「收口后把真实调用方挡在外面」的风险。另外三个端点本批**只加旁路观察日志、不拦截**,对外行为零变化。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 取消成团 | POST | `/v3/admin/order/group-batch/:groupBatchId/cancel-group` | 修改 | **新增判权**:需 `group-batch:manage`,无权限 `589507`;响应结构与业务错误码不变 | --- ## 三、接口详情 ### 1. 取消成团 `POST /v3/admin/order/group-batch/:groupBatchId/cancel-group` **VO**: `无请求体(Result)` #### 使用场景 hl-ui 管理后台团期详情页「取消成团」按钮:把已成团(`RESOURCE_PREPARING`)的团期打回招募中(`RECRUITING`),并重置四项资源就绪位与物资确认位。**本单起需要 `group-batch:manage` 权限。** #### 入参字段表 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long | ✅ | 运营团期主键 | **不是**产品侧排期 ID `productBatchId`(**行为不变**) | 无请求体。 #### 出参字段表 | 字段 | 类型 | 说明 | |------|------|------| | data | null | **结构完全不变**,`Result` | #### 请求示例 ```http POST /v3/admin/order/group-batch/2098606570236481538/cancel-group HTTP/1.1 X-Admin-Id: 1001 X-Admin-Role: ADMIN ``` 无请求体。 #### 响应示例 ```json { "code": 200, "message": "成功", "data": null, "success": true } ``` #### 空数据 / 降级响应 无分页与列表语义,不存在空数据形态。 **降级**:灰度开关 `group-batch.acl.enforce.cancel-group` 置 `false` 时,判权**退化为旁路观察日志**(`GB_ACL_PROBE`)而非静默放行——此时所有角色恢复放行,行为与改前一致,但每次调用仍在服务端留痕。开关默认 `true`。 #### 错误响应 | 码 | 符号 | 触发 | 本单 | |----|------|------|------| | **589507** | `GROUP_BATCH_PERMISSION_DENIED` | 调用方角色不持有 `group-batch:manage` | 🆕 **本端点新增**(码本身早已存在) | | 589500 | — | 团期不存在 | 不变 | | 589501 | — | 团期状态不允许(非 `RESOURCE_PREPARING`) | 不变 | | 589509 | — | 已有已确认子订单或已派单资源,禁止取消成团 | 不变 | ```json { "code": 589507, "message": "无操作权限(非团期管理员 / 非本定制师名下)", "data": null, "traceId": null, "success": false } ``` #### 业务边界 - **拒绝时零写入**:判权在 Controller 入口,排在 Service 的任何库读写之前。被拒的请求不改 `order_group_batch` 任何列(含 `update_time`)、不写状态流水、不登记 Fleet 释放命令。 - **判权落点显式定在 Controller**:本类的 `advance`、`recheck-material-gate` 已是这个形状。统一判在入口,让「读 Controller 就能判有没有守卫」重新成立——这正是 `#7455` 漏检的方法学根因。 - **超管短路放行**:user-service 对 `SUPER_ADMIN` 短路放行,因此**用超管自测无法发现种子缺失**。本单的验收用 `ADMIN` + 低权限角色实打(见第八节)。 - **本批不收口的三个端点**:`withdraw` 提交、`transfer-in`、`transfer-candidates` 本批**只观察不拦截**,对外行为零变化。它们的收口分别在批 2 / 批 3,需先拿到观察窗数据、且 `withdraw` 需要 user-service 新增权限码种子。 --- ## 四、契约约束与正确调用方式 - 前端应按 `group-batch:manage` 控制「取消成团」按钮的可见性/可用性,与「成团」按钮**用同一个权限码**(两者本就是同一对按钮)。 - `589507` 建议直接透出 `message` 文案,无需前端自造提示。 - 权限码与角色绑定**没有运行期写入口**(只能走 Flyway 种子),前端不要尝试动态查询角色权限表来做按钮控制,应使用现有的权限码下发通道。 --- ## 五、数据库行为 **无 DDL、无数据迁移。** 本单不含任何 Flyway 脚本。 权限码 `group-batch:manage` 与其角色授予(`ADMIN` / `SUPER_ADMIN`)由既有种子 `V20260906_002` 提供,**user-service 零改动**。 --- ## 六、边界行为 - 有权限的调用方:与改前逐字节一致。 - 无权限的调用方:改前执行成功,改后 `589507` 且零写入。 - 角色头缺失:`GroupBatchPermissionGuard` 是 **fail-closed**,`roleKey` 为空一律 `589507`(**行为不变**,该守卫既有口径)。 - user-service 不可达:权限查询 `catch (RuntimeException) → false`,即 **fail-closed**,返回 `589507`。 ## 六.6、修改前后对比 | 角色 | 改前 | 改后 | |------|------|------| | `SUPER_ADMIN` | 正常执行 | 正常执行(不变) | | `ADMIN` | 正常执行 | 正常执行(不变) | | `CUSTOMIZER` | **正常执行** | **589507** | | `FINANCE` | **正常执行** | **589507** | | `ROOM_MANAGER` / `VEHICLE_MANAGER` | **正常执行** | **589507** | ## 六.7、影响评估 - **前端**:需按权限隐藏按钮,否则低权限角色会点到必然失败的按钮。响应结构无变化,无需改解析逻辑。 - **后端**:无下游契约变化。 - **回滚**:改 nacos `group-batch.acl.enforce.cancel-group: false`,约 30s 生效,**不需要重发服务**。 --- ## 七、不影响范围 - 成团 / 流团 / 名额调整 / 预支 / 物料门复判五个端点:判权口径不变。 - `withdraw` 提交 / `transfer-in` / `transfer-candidates`:本批只加观察日志,**对外行为零变化**。 - 小程序端(`consumer: mp`):本单端点均为管理后台端点,小程序无影响。 --- ## 八、测试环境已验证 > **构建归属**:TEST order-v3 部署 `fix/7608-groupbatch-action-acl` @ `ac4fbf0f7`(面板 `git-backend` 确认)。每批取证前后各打一次构建身份探针:`CUSTOMIZER` 调 `cancel-group` 返回 `589507` —— **这个码在 dev-v3 上根本产不出来,它本身就是构建指纹**,可排除「部署假成功、跑的是陈旧产物」。 ### 判权收口生效(角色矩阵实打) 对**不存在**的 `groupBatchId` 发请求(不产生任何写入),看返回码即可判定: | 角色 | 改前(dev-v3) | 改后 | 判定 | |---|---|---|---| | `SUPER_ADMIN` | 589500 | **589500** | 过守卫,进业务校验 | | `ADMIN` | 589500 | **589500** | 过守卫,进业务校验 | | `CUSTOMIZER` | 589500 | **589507** | 被拦 | | `FINANCE` | 589500 | **589507** | 被拦 | **没有只用超管自测**:user-service 对 `SUPER_ADMIN` 短路放行,只用超管验会掩盖种子缺失。上表用 `ADMIN` + 两个低权限角色实打。另查 SQL 确认 `group-batch:manage` 状态 `ACTIVE` 且只绑 `ADMIN` / `SUPER_ADMIN`,无种子缺失。 ### 零写入(真实团期,改前/改后逐列对拍) 用自建团期 `2098979230774657025`(`RESOURCE_PREPARING`,四个 ready 位全 0,零已确认子订单 —— `cancelGroup` 三条硬前置全满足,**即「守卫若不拦,这次调用本会真的写进去」**): | 项 | 结果 | |---|---| | `CUSTOMIZER` 返回码 | **589507** | | `order_group_batch` 改前/改后 | **41 列逐列完全一致**,无差异列 | | `update_time` | **未变**(`11:37:19` → `11:37:19`) | | `group_batch_status_log` | **新增 0 行**(2 → 2) | > 这条必须用**真能被取消**的团期。拿一个取消不了的团期做,守卫不拦它也写不进去,零写入的证明是空的。 ### 有权限仍可用(非超管 ADMIN) 同一团期、同一端点,`ADMIN` token → **200**,`RESOURCE_PREPARING` → `RECRUITING`,`update_time` `11:37:19` → `11:37:52`,状态流水 +1 行(`BATCH_CANCEL_GROUP` / operator `1001`)。 > 四个 ready 位的「重置」在本路径上**结构性不可观测**:R10 硬前置要求它们进来时就全 0,重置是 0→0,不可能看到 delta。这是接口语义决定的,不是没验。 ### 灰度开关开/关两态(回滚演练) nacos `hl-order-service-v3-test.yml`(`tenant=test`): | 时刻 | 动作 | `CUSTOMIZER` 调用结果 | |---|---|---| | 11:51:40 | 写入 `group-batch.acl.enforce.cancel-group: false` | — | | 11:51:47 | 写入后 **7s** | **589500**(恢复放行) | | 11:51:55 | 写回原文 | — | | 11:52:02 | 还原后 **7s** | **589507**(收口恢复) | 配置已逐字节还原(md5 与原文相同,`group-batch` key 已消失),**TEST 未被留在关闭态**。实测热刷新 **≈7s**,代码注释里的「30s」是保守上界。 ### 三个旁路观察端点:行为零变化(前后对照) `withdraw` / `transfer-in` / `transfer-candidates` 各用 `CUSTOMIZER`、`FINANCE` 打一次,共六发: | | 改前(dev-v3,无探针) | 改后(有探针) | |---|---|---| | 六发结果 | 全部 `589500` | 全部 `589500` | | 400 / 500 / 589507 | 无 | 无 | **逐个对上,零差异** —— 证明新增的观察探针没有把正常请求打挂(探针内吞 `RuntimeException`,且 `has()` 的 Feign 跳是 fail-closed 不外抛)。 > `transfer-in` 带齐了必填字段(含 `fromGroupBatchId`),返回 589500 说明**已进到 Controller 之后**,不是被 `@Valid` 的 400 挡在门外 —— 黑盒探测判权时这一步必须做,否则 400 会掩盖守卫、把有守卫的端点误判成裸奔。 ### 观察探针端到端(`GB_ACL_PROBE`,合并进 dev-v3 后补采) 三个旁路端点实际产出的观察日志(`enforced=false` 表示只观察不拦截): | 端点 | roleKey | `intendedPermission` | `hasPermission` | |---|---|---|---| | `GB-ADM-070 withdraw-submit` | `CUSTOMIZER` / `FINANCE` | `group-batch:manage` | **false** | | `GB-ADM-070 withdraw-submit` | `SUPER_ADMIN` | `group-batch:manage` | **true** | | `GB-ADM-071 transfer-in` | `ADMIN` | `group-batch:manage` | **true** | | `GB-ADM-071b transfer-candidates` | `CUSTOMIZER` / `FINANCE` / `ADMIN` | **`(not-evaluated)`** | — | 两点被这张表钉死: 1. **探针的判定与真实权限模型一致** —— 低权限角色 `false`、管理角色 `true`,说明观察窗收上来的数据可以直接作为批 2 / 批 3 选码的依据。 2. **`transfer-candidates` 确实不发 Feign** —— `intendedPermission=(not-evaluated)`。它是转单弹窗的搜索下拉,用户每敲一个字就打一次,走 Feign 判权会同时拖慢下拉并放大 user-service 压力;观察阶段只需要知道「哪些角色在调」。 ### 本地全量单测(四数对照) | 检出 | Tests / Failures / Errors / Skipped | |---|---| | 基线 `cb293d2ab`(本分支 merge-base) | `10220 / 0 / 13 / 51` | | 本分支 `ac4fbf0f7` | **`10232 / 0 / 13 / 51`** | `+12` 逐条可归因:`+6` = 本单新增的 6 条用例;`+6` = 合并 dev-v3 带进来的 `VehicleRequirementKindErrorCodeMessageTest`(实测正好 6 条)。**Failures / Errors / Skipped 三项零变化**,13 条 Errors 的类名集合完全相同,全部是 Testcontainers 无 Docker 环境所致,与本单无关。 ### 未采集到的证据(如实列出) - **AC-1 的 roleKey × endpoint × 次数观察表**:本单只负责把探针种下去,统计窗 ≥2 周,表本身不在本单产出。 > 采集说明:部署面板对 order-v3 配了分支白名单(`service-ports.conf`),**检出切到特性分支时 order-v3 会从 `/api/services` 消失、日志端点 404**,所以日志类证据是在合并进 `dev-v3` 并部署 `dev-v3` 之后补采的。另:面板日志端点**只读 primary 实例**,双实例轮询会漏,需多打几轮才能覆盖全。 --- ## 十、相关文档 - Issue `#7608`(含 58 端点判权清点、网关三层排除、FINANCE 三步可达链) - PR `#7616` --- ## 关联 / 联系人 - 后端:jw - 前端:mmg