docs(changelog): #7608 团期转订单 transfer-in / transfer-candidates 收口判权(批 2)
changelog-filename-gate / validate (push) Failing after 2s
changelog-filename-gate / validate (push) Failing after 2s
两个端点从零判权改为 transfer-in 接 group-batch:manage、transfer-candidates 接 group-batch:view,无权限 589507;各带一个 nacos 灰度开关。响应结构、成功码、 业务错误码不变,有权限的调用方行为与改前一致。 TEST 已部署(dev-v3 @ 22f9fb83d)并实测:五角色矩阵逐格吻合(部署前同组请求十格 全非 589507)、nacos 双开关三态往返含还原后复现、观察日志反证强制态未走旁路分支。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
@@ -0,0 +1,334 @@
|
|||||||
|
---
|
||||||
|
schema: "hl-changelog/v2"
|
||||||
|
ticket: "7608"
|
||||||
|
title: "团期转订单收口判权:transfer-in 接 group-batch:manage、transfer-candidates 接 group-batch:view(589507),各带 nacos 灰度开关"
|
||||||
|
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: "2026-09-27"
|
||||||
|
status_note: "已部署 dev-v3 到 TEST 并实打验证:五角色矩阵逐格吻合、nacos 两开关三态往返(含还原后复现)、观察日志反证强制态未走旁路。"
|
||||||
|
updated_at: "2026-09-27"
|
||||||
|
base: "dev-v3"
|
||||||
|
---
|
||||||
|
|
||||||
|
# order-v3: 团期转订单两个端点收口判权(批 2)
|
||||||
|
|
||||||
|
**服务**: hl-order-service-v3
|
||||||
|
**PR**: `#8416`(已合入 `dev-v3`,合并后 head `22f9fb83d`)
|
||||||
|
**Issue**: #7608
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 关键变化
|
||||||
|
|
||||||
|
🔴 **`transfer-in`(转订单写口)从「任何后台角色都能调」变为「必须持 `group-batch:manage`」,无权限返回 `589507`。** 改前**零判权**,而它**涉钱**:改应收金额、改归属两列、改客户出行日期、动两期名额,且**没有反向端点**。
|
||||||
|
|
||||||
|
🔴 **`transfer-candidates`(转单弹窗的搜索下拉)从「连权限码都不判」变为「必须持 `group-batch:view`」。** 改前它跨团期返回订单号 / 客户名 / 期号,并支持手机号整串精确检索,而且是上面那个写口的**攻击目标供给器**。
|
||||||
|
|
||||||
|
🟡 **两个端点必须成对看**:只收口写口会让弹窗「能搜不能转」,只收口读口会「能转搜不到」。本单同批上线。
|
||||||
|
|
||||||
|
🟢 **响应结构、成功码、业务错误码全部不变。** 有权限的调用方行为与改前逐字节一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
`#7455` 自称做了「全端点扫描」,但漏掉 `GroupBatchActionController` 里的 4 个端点。根因是审计方法按**类**计数 `GroupBatchPermissionGuard` 命中数——该类因 `advance` / `recheck-material-gate` 两处有 guard 就被整类归入「已判权」。
|
||||||
|
|
||||||
|
收口分四批:批 0 给裸端点加旁路观察探针;批 1 收口 `cancel-group`(已上线);**本单是批 2**;批 3(`withdraw` 提交)待定案。
|
||||||
|
|
||||||
|
**为什么批 2 要等**:码的选择依赖批 0 的观察窗。观察窗 2026-09-13~09-27 满 14 天,结论是这两个端点在留存日志里**零调用、无意外调用方**,故按静态推导定案。
|
||||||
|
⚠️ 这是「**没有反证**」而非「**有正证**」:TEST 上 order-v3 跑两个实例,8086 实例 09-13~09-25 的日志已不可取(落点随 09-23 端口迁移挪过)。对冲手段就是下面两个 nacos 开关。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、变更接口清单
|
||||||
|
|
||||||
|
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||||
|
|---|------|------|------|----------|------|
|
||||||
|
| 1 | 转订单(子订单跨期转入) | POST | `/v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/transfer-in` | 修改 | **新增判权**:需 `group-batch:manage`,无权限 `589507`;响应结构与业务错误码不变 |
|
||||||
|
| 2 | 转订单候选子订单列表 | GET | `/v3/admin/order/group-batch/:groupBatchId/transfer-candidates` | 修改 | **新增判权**:需 `group-batch:view`,无权限 `589507`;响应结构不变 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、接口详情
|
||||||
|
|
||||||
|
### 1. 转订单 `POST /v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/transfer-in`
|
||||||
|
|
||||||
|
**VO**: `TransferSubOrderReqVO` → `Result<TransferSubOrderRespVO>`
|
||||||
|
|
||||||
|
#### 使用场景
|
||||||
|
|
||||||
|
管理后台团期详情「转订单」弹窗:把另一期的子订单转入本期。**本单起需要 `group-batch:manage` 权限。**
|
||||||
|
|
||||||
|
#### 入参字段表
|
||||||
|
|
||||||
|
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|------|------|
|
||||||
|
| groupBatchId | Path | Long | ✅ | 转入期(本期)团期主键 | **行为不变** |
|
||||||
|
| orderId | Path | Long | ✅ | 待转入的子订单 ID | **行为不变** |
|
||||||
|
| fromGroupBatchId | Body | Long | ✅ | 转出期团期主键 | **行为不变** |
|
||||||
|
|
||||||
|
#### 出参字段表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| data | `TransferSubOrderRespVO` | **结构完全不变** |
|
||||||
|
|
||||||
|
#### 请求示例
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v3/admin/order/group-batch/2098606570236481538/sub-order/2103877643344691202/transfer-in HTTP/1.1
|
||||||
|
X-Admin-Id: 1001
|
||||||
|
X-Admin-Role: ADMIN
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"fromGroupBatchId": 2098979230774657025}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "成功",
|
||||||
|
"data": {},
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 空数据 / 降级响应
|
||||||
|
|
||||||
|
无分页语义。**降级**:nacos `group-batch.acl.enforce.transfer-in` 置 `false` 时,判权**退化为旁路观察日志**(`GB_ACL_PROBE`)而非静默放行——所有角色恢复放行,行为与改前一致,但每次调用仍在服务端留痕。开关默认 `true`。
|
||||||
|
|
||||||
|
#### 错误响应
|
||||||
|
|
||||||
|
| 码 | 符号 | 触发 | 本单 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| **589507** | `GROUP_BATCH_PERMISSION_DENIED` | 调用方不持 `group-batch:manage` | 🆕 **本端点新增**(码本身早已存在) |
|
||||||
|
| 589500 | — | 团期不存在 | 不变 |
|
||||||
|
| 其它业务码 | — | 转入的各项业务前置 | 不变 |
|
||||||
|
|
||||||
|
`589507` 实打响应体(TEST,2026-09-27):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 589507,
|
||||||
|
"message": "无操作权限(当前角色未授予团期权限,或该团期不在您名下)",
|
||||||
|
"data": null,
|
||||||
|
"traceId": null,
|
||||||
|
"success": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 业务边界
|
||||||
|
|
||||||
|
- **拒绝时零写入**:判权在 Controller 入口,排在 Service 任何库读写之前。
|
||||||
|
- **`OrderViewGuard.assertNotGroupBatchManagerWrite()` 保留**:#8154 起团期管理员(GBM)「只能看不能改」,那道拦在判权之前、只挡这一个角色,两者并存不互相取代。
|
||||||
|
- **取 `manage` 不取 `view`**:本端点涉钱且无反向端点,取写向最高码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 转订单候选列表 `GET /v3/admin/order/group-batch/:groupBatchId/transfer-candidates`
|
||||||
|
|
||||||
|
**VO**: `Result<List<TransferCandidateVO>>`
|
||||||
|
|
||||||
|
#### 使用场景
|
||||||
|
|
||||||
|
转订单弹窗的搜索下拉:候选 = 同产品其他可转期下的可转子订单。keyword 匹配订单号 / 客户名 / 期号(模糊)与手机号(**整串精确**,加密列不支持前缀模糊)。**本单起需要 `group-batch:view` 权限。**
|
||||||
|
|
||||||
|
#### 入参字段表
|
||||||
|
|
||||||
|
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||||
|
|------|------|------|------|------|------|
|
||||||
|
| groupBatchId | Path | Long | ✅ | 转入期(本期)团期主键 | **行为不变** |
|
||||||
|
| keyword | Query | String | ❌ | 订单号 / 客户名 / 期号 / 手机号(整串) | **行为不变** |
|
||||||
|
|
||||||
|
#### 出参字段表
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| data | `List<TransferCandidateVO>` | **结构完全不变** |
|
||||||
|
|
||||||
|
#### 请求示例
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /v3/admin/order/group-batch/2098606570236481538/transfer-candidates?keyword=GT-26-0085 HTTP/1.1
|
||||||
|
X-Admin-Id: 1001
|
||||||
|
X-Admin-Role: ADMIN
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "成功",
|
||||||
|
"data": [],
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 空数据 / 降级响应
|
||||||
|
|
||||||
|
无候选时 `data: []`(**行为不变**)。**降级**:nacos `group-batch.acl.enforce.transfer-candidates` 置 `false` 时退回批 0 形态——只记角色、不发 Feign 判权。
|
||||||
|
|
||||||
|
#### 错误响应
|
||||||
|
|
||||||
|
| 码 | 符号 | 触发 | 本单 |
|
||||||
|
|----|------|------|------|
|
||||||
|
| **589507** | `GROUP_BATCH_PERMISSION_DENIED` | 调用方不持 `group-batch:view` | 🆕 **本端点新增** |
|
||||||
|
|
||||||
|
`589507` 实打响应体(TEST,2026-09-27):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 589507,
|
||||||
|
"message": "无操作权限(当前角色未授予团期权限,或该团期不在您名下)",
|
||||||
|
"data": null,
|
||||||
|
"traceId": null,
|
||||||
|
"success": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 业务边界
|
||||||
|
|
||||||
|
- **拒绝时不查库**:判权排在查询之前,被拒的请求拿不到任何候选数据。
|
||||||
|
- ⚠️ **本单推翻了批 0 自己写的「高频下拉不判权」**:那条理由(逐字触发、每次多一跳 Feign 到 user-service)成立,但挡不住泄露面。真实测出下拉不可接受时,出路是给守卫加短 TTL 本地缓存(**另立单**),不是把开关关掉长期不判。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、契约约束与正确调用方式
|
||||||
|
|
||||||
|
- 前端应按权限码控制「转订单」入口:写口按 `group-batch:manage`,搜索下拉按 `group-batch:view`。
|
||||||
|
- `589507` 建议直接透出 `message`,无需前端自造提示。
|
||||||
|
- 权限码与角色绑定**没有运行期写入口**(只能走 Flyway 种子)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、数据库行为
|
||||||
|
|
||||||
|
**无 DDL、无数据迁移、无 Flyway 脚本。**
|
||||||
|
|
||||||
|
两个码及其角色授予由既有种子提供:`group-batch:manage`(`V20260906_002`:ADMIN / SUPER_ADMIN)、`group-batch:view`(`V20260831_002` ADMIN/FINANCE/SUPER_ADMIN + `V20260917_001` GROUP_BATCH_MANAGER + `V20260918_005` CUSTOMIZER)。**hl-user-service 零改动** ⇒ 不涉及 #7154 那条「user-service 迁移必须先发」的铁律。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、边界行为
|
||||||
|
|
||||||
|
- 有权限的调用方:与改前逐字节一致。
|
||||||
|
- 无权限的调用方:改前执行成功,改后 `589507` 且零写入 / 不查库。
|
||||||
|
- 角色头缺失:`GroupBatchPermissionGuard` **fail-closed**,`roleKey` 为空一律 `589507`(既有口径,行为不变)。
|
||||||
|
- user-service 不可达:权限查询 `catch (RuntimeException) → false`,即 **fail-closed**。
|
||||||
|
|
||||||
|
## 六.6、修改前后对比
|
||||||
|
|
||||||
|
| 角色 | `transfer-in` 改前 → 改后 | `transfer-candidates` 改前 → 改后 |
|
||||||
|
|------|---|---|
|
||||||
|
| `SUPER_ADMIN` | 正常 → 正常 | 正常 → 正常 |
|
||||||
|
| `ADMIN` | 正常 → 正常 | 正常 → 正常 |
|
||||||
|
| `FINANCE` | **正常 → 589507** | 正常 → 正常(持 view) |
|
||||||
|
| `CUSTOMIZER` | **正常 → 589507** | 正常 → 正常(`V20260918_005` 起持 view) |
|
||||||
|
| `GROUP_BATCH_MANAGER` | 589xxx(#8154 已挡)→ 不变 | 正常 → 正常(持 view) |
|
||||||
|
| `ROOM_MANAGER` / `VEHICLE_MANAGER` | **正常 → 589507** | **正常 → 589507** |
|
||||||
|
|
||||||
|
## 六.7、影响评估
|
||||||
|
|
||||||
|
- **前端**:需按两个码分别控制写口与下拉的可见性,否则无码角色会点到必然失败的入口。响应结构无变化,无需改解析逻辑。
|
||||||
|
- **后端**:无下游契约变化。`transfer-candidates` 每次调用多一跳 Feign(逐字下拉,可感知)。
|
||||||
|
- **回滚**:改对应 nacos key 为 `false`,批 1 实测热刷新 ≈7s,**不需重发服务**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、不影响范围
|
||||||
|
|
||||||
|
- `cancel-group`(批 1 已收口)、成团 / 流团 / 名额调整 / 预支 / 确认:判权口径不变。
|
||||||
|
- `withdraw` 提交:**本批不动**,仍只有旁路观察。它的收口是批 3,因为「提交集合是否该等于审批集合」需要定案(接 `manage` 会让两个集合相同,毁掉 #7100 建立的两人制)。
|
||||||
|
- 小程序端(`consumer: mp`):本单端点均为管理后台端点,无影响。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、测试环境已验证
|
||||||
|
|
||||||
|
**环境**:TEST(`https://api.test.1814.love`) **验证时间**:2026-09-27 11:31~11:37
|
||||||
|
**构建身份**:部署前 `ecc92b95c`(旧字节,批 0 形态)→ 部署后 `22f9fb83d`(本单合并提交),部署前后各打一轮,故下表是**对照**而非单点。
|
||||||
|
|
||||||
|
### 8.1 五角色矩阵(部署后,强制态)
|
||||||
|
|
||||||
|
| 角色 | `transfer-in`(接 `manage`) | `transfer-candidates`(接 `view`) |
|
||||||
|
|---|---|---|
|
||||||
|
| SUPER_ADMIN | `589500` 过守卫 | `589500` 过守卫 |
|
||||||
|
| ADMIN | `589500` 过守卫 | `589500` 过守卫 |
|
||||||
|
| CUSTOMIZER | **`589507`** 拦截 | `589500` 过守卫 |
|
||||||
|
| FINANCE | **`589507`** 拦截 | `589500` 过守卫 |
|
||||||
|
| ROOM_MANAGER | **`589507`** 拦截 | **`589507`** 拦截 |
|
||||||
|
|
||||||
|
部署前同一组请求:**十格全部非 `589507`**(旧字节只旁路观察不拦截),故新增拦截可归因到本单字节。
|
||||||
|
|
||||||
|
> **为什么这组读数能证明码选对了**:CUSTOMIZER 在读口过、在写口被拦。若 `transfer-candidates` 误接 `manage`,CUSTOMIZER 读口也会被拦;若误接更宽的口径,ROOM_MANAGER 读口不会被拦。这一格同时验证了**码的选择**和 `V20260918_005` 给 CUSTOMIZER 授 `view` 的**种子实际状态**。
|
||||||
|
> **未只用超管自测**:ADMIN 过、CUSTOMIZER/FINANCE/ROOM_MANAGER 被拦,均为非超管角色,绕开了 user-service 的超管短路放行。
|
||||||
|
|
||||||
|
### 8.2 nacos 灰度开关三态往返
|
||||||
|
|
||||||
|
配置 `hl-order-service-v3-test.yml`(`tenant=test`)原本**不含** `group-batch.acl.*` 键,即跑代码默认值 `true`。
|
||||||
|
|
||||||
|
| 态 | 配置 | CUSTOMIZER `transfer-in` | ROOM_MANAGER `transfer-candidates` | ADMIN(对照) | `GB_ACL_PROBE` 观察日志 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| A | 无键(默认 `true`) | `589507` | `589507` | `589500` | **无** |
|
||||||
|
| B | 两键置 `false` | `589500` | `589500` | `589500` | **有** |
|
||||||
|
| C | 逐字节还原 | `589507` | `589507` | `589500` | **无** |
|
||||||
|
|
||||||
|
- 热刷新耗时:发布后 12s 内生效(态 A→B 时刻 11:36:38→11:36:52)。
|
||||||
|
- **还原已校验到字节**:`md5` 前后同为 `c2206934960057f70b7173159046dc54`,`cmp` 一致;还原动作挂在 `trap EXIT` 上,探针失败也不会把 TEST 留在 `false`。
|
||||||
|
|
||||||
|
**态 B 的放行是「退回旁路观察」而不是「判权整体失效」**——两条方向相反的证据:
|
||||||
|
|
||||||
|
1. 正面:态 B 期间日志记下
|
||||||
|
`endpoint=GB-ADM-071 transfer-in roleKey=CUSTOMIZER intendedPermission=group-batch:manage hasPermission=false enforced=false`
|
||||||
|
即「本应判 `manage`、该角色实际不持有、但当前未强制」。守卫算过了权限,只是没拦。
|
||||||
|
2. 反面:态 A 与态 C 的时间窗内 `GB_ACL_PROBE` **零命中**。若强制分支底下还在跑观察,这里会有日志。零命中说明走的是 `require()` 而非 `observe()`,`if/else` 是真互斥。
|
||||||
|
|
||||||
|
> 附带加固 8.1:态 B 六条日志里 `transfer-in` 落 `http-nio-8186`、`transfer-candidates` 落 `http-nio-8086`,**两个 order-v3 实例都是新字节**,8.1 的矩阵不是「只有一个实例部署到」的假象。
|
||||||
|
> `transfer-candidates` 的观察日志记 `intendedPermission=(not-evaluated)`,与代码里用 `observeRoleOnly()`(不查权限只记角色)一致。
|
||||||
|
|
||||||
|
### 8.3 本地全量对照(AC-10)
|
||||||
|
|
||||||
|
| 项 | 读数 |
|
||||||
|
|---|---|
|
||||||
|
| 范围 / 结果 | 1000 个测试类,`Tests run: 13614, Failures: 11, Errors: 10, Skipped: 7`,零 OOM,31 分钟 |
|
||||||
|
| 定责 | 10 个失败类**全部**在基底 `57199b539` 上同样红,逐类比对 tests/F/E 一致 → 无一条由本单引入 |
|
||||||
|
| 其中 3 类同源 | `fin_reimburse` 列未同步进 `integration/schema.sql`(`#8362` 遗留,finance 域,**不在本单范围**,建议另开单) |
|
||||||
|
|
||||||
|
判据按 `#8370` 立的口径:**「不引入新失败 + 既有失败逐条定责」**,而非「全量全绿」——基底每天在变,小修单担保不了全绿。
|
||||||
|
|
||||||
|
### 本地证据(已完成)
|
||||||
|
|
||||||
|
| 项 | 读数 |
|
||||||
|
|---|---|
|
||||||
|
| `GroupBatchActionControllerTest` | **27/0/0/0**(批 1 的 21 条 + 批 2 新增 6 条) |
|
||||||
|
| 变异①:`transfer-in` 的 MANAGE→VIEW(接错码) | 红 1 条(判「码对不对」那条) |
|
||||||
|
| 变异②:`transfer-candidates` 的 `require` 删掉(漏接) | 红 2 条,含 `expected:<589507> but was:<200>` |
|
||||||
|
| 两次还原 | 均复绿 27/0/0/0,`MUTATION-7608-TEMP` 残留 0 |
|
||||||
|
|
||||||
|
> 批 0 留下的两条「记观察日志但不拦截」用例在本单下**必然会红**,已改写成批 2 形态——这正是它们有分辨力的证明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、相关文档
|
||||||
|
|
||||||
|
- Issue `#7608`(含 58 端点判权清点、FINANCE 三步可达链、批 0 观察窗取证)
|
||||||
|
- 批 0+1:PR `#7616`;AC-9 文档侧:PR `#7661`
|
||||||
|
- 批 2/批 3 判权口径定案:#7608 评论 #60798(wx,2026-09-23)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关联 / 联系人
|
||||||
|
|
||||||
|
- 后端:jw
|
||||||
|
- 前端:mmg
|
||||||
在新工单中引用
屏蔽一个用户