退单审批 4 端点 + 流团审批 approve/reject 共 6 个端点。 不改请求/响应结构、不新增错误码、不改路由、无 Flyway。 TEST 真实网关前后对照:缺 role claim 由 589531/589545(绕过进业务) 变为 589530/589547(拦下);role=ADMIN(非超管)行为不变。 ⚠️ 行为变更:生产库若有活跃零角色账号在做审批,上线即被拦,需运营侧知情。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.4 KiB
schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
| schema | ticket | title | consumer | author | change_type | backend_status | gateway_status | frontend_status | frontend_owner | frontend_ref | target_release | verified_at | status_note | updated_at | base |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| hl-changelog/v2 | 7609 | 退单/流团审批角色守卫改 fail-closed:缺 role claim 的 token 不再绕过角色门 | admin | jw(GIT) | 修复 | deployed | verified | not_required | not_required:不改任何请求/响应结构、不新增错误码、不改路由,无 Flyway。契约一直写的就是「仅管理员可处理」,本单只是让代码真的执行它。受影响的只有「零角色 admin 账号」这一种异常账号状态——正常配了角色的用户行为逐字节不变,前端无触点。⚠️ 但这是行为变更:若线上存在活跃的零角色账号且有人在用它做退单/流团审批,上线即被拦(589530 / 589547),需运营侧知情,见下方「上线前须知」。 | 2026-09-14 | dev-v3 |
退单 / 流团审批角色守卫改 fail-closed(修复)
服务: hl-order-service-v3(
WithdrawApprovalGuard) PR: #7657 Issue: #7609 日期: 2026-09-14 影响范围: 退单审批 4 个端点 + 流团审批 approve/reject 共 6 个端点的判权结果;不涉及请求/响应结构、错误码集合、路由与数据库
⚠️ 关键变化
🔴 缺 role claim 的管理端 token,此前能绕过退单/流团审批的角色门,现已拦下。
| token 的 role claim | 退单审批 approve |
流团审批 approve |
|---|---|---|
ADMIN / SUPER_ADMIN |
放行(不变) | 放行(不变) |
CUSTOMIZER 等非管理员 |
589530(不变) |
589547(不变) |
完全缺 role claim |
589531 → 589530 |
589545 → 589547 |
🟢 正常配了角色的用户,行为逐字节不变。 没有新增错误码——589530 / 589547 本就存在,只是此前这条路径拿不到它们。
一、背景
守卫老实现是:
String role = AdminContextUtil.getRole();
if (role == null || role.isEmpty()) {
return; // 注释称其为「非请求上下文放行」
}
但 AdminContextUtil.getRole() 在真的没有 HTTP 请求上下文时是抛 IllegalStateException,根本走不到这条 return。
它返回 null 的唯一情况是:有 HTTP 请求,但网关没透传 X-Admin-Role——AdminAuthInterceptor 对角色头缺失既不报错也不 setAttribute("role")。而零角色 admin 账号正常登录签发出来的就是这种 token(登录链路没有任何一处在 roleKey 为 null 时拒绝登录)。
所以那条 return 的唯一可达路径就是缺陷本身,形成权限倒置:
没有角色,比拥有一个低权限角色更有权力。
注释把两种语义完全不同的 null 混为一谈,这才是根因——不是有人写漏了判断。
二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 退单审批列表 | GET | /v3/admin/order/group-batch/withdraw/page |
修复 | 缺 role claim 由放行改为 589530 |
| 2 | 退单审批详情 | GET | /v3/admin/order/group-batch/withdraw/:approvalId |
修复 | 同上 |
| 3 | 退单审批通过 | POST | /v3/admin/order/group-batch/withdraw/:approvalId/approve |
修复 | 同上 |
| 4 | 退单审批驳回 | POST | /v3/admin/order/group-batch/withdraw/:approvalId/reject |
修复 | 同上 |
| 5 | 流团审批通过 | POST | /v3/admin/order/group-batch/disband/:approvalId/approve |
修复 | 缺 role claim 由放行改为 589547 |
| 6 | 流团审批驳回 | POST | /v3/admin/order/group-batch/disband/:approvalId/reject |
修复 | 同上 |
请求结构、响应结构、错误码集合、路由均无变化。
三、接口详情
六个端点的请求与响应一字未改,仅判权结果变化,逐端点差异见上表。改法(形状对齐同仓已有的正确参照 SettlementWriteGuard.hasRole,两套守卫一个口径):
try {
role = AdminContextUtil.getRole();
} catch (IllegalStateException noRequestContext) {
log.warn("[{}] 审批角色守卫取不到 HTTP 请求上下文,按系统态放行……", errorCode.code(), noRequestContext);
return;
}
if (role == null || role.isBlank()) {
throw new BusinessException(errorCode); // 有请求却没角色 = 外部请求,fail-closed
}
为什么那条 catch 要打日志
它是本次新增的路径——改前 getRole() 抛的 ISE 会一路冒到调用方(结果是 500),压根没有「系统态放行」这回事。保留它只为与 SettlementWriteGuard 同口径,但当前 0 个调用方走得到(6 个调用点全是同步 MVC 入口,反查全仓无 @Async / 监听器 / 定时任务调用方;同链路的 MQ/事件走 approveInTx,不经过本守卫)。
所以这条 WARN 平时不会有噪音,一旦出现就是要被人看见的。
四、契约约束与正确调用方式
- 调用这六个端点必须持有
ADMIN或SUPER_ADMIN角色的有效管理端 token。 - 这是角色门不是权限码门,不依赖
group-batch:*权限种子。 - 前端无需改动:正常登录的用户 token 必带
roleclaim。
五、数据库行为
无 DDL、无数据迁移、无 Flyway 脚本。
六、边界行为
- 无 HTTP 请求上下文(真系统态):放行并打 WARN。当前无调用方走到。
- 有请求但角色头缺失 / 空白:拒绝(本单修复点)。
- 非管理员角色:拒绝(行为不变)。
七、不影响范围
- 请求/响应结构、错误码集合、路由:零变化。
GroupBatchPermissionGuard(46 个端点走权限码):此前就是 fail-closed,本单未动。HouseWriteGuard:仍是 fail-open(#7609的 G-2,需独立 PR)。同一个零角色 token 目前仍能打穿房务写端点。- 小程序端(
consumer: mp):本单端点均为管理后台端点,无影响。
八、测试环境已验证
构建归属:TEST order-v3 部署
82560872c(面板git-backend确认),合并后为 dev-v342cd9c61d。
前后对照(同端点、同 token 形态、真实网关)
| token 的 role claim | 端点 | 改前(2026-09-13 实测) | 改后(2026-09-14 实测) |
|---|---|---|---|
| 完全缺 role claim | 退单 approve |
589531 退单申请不存在 ⚠️ 绕过进业务 |
589530 仅管理员可处理退单审核 🔒 |
| 完全缺 role claim | 流团 approve |
589545 流团申请不存在 ⚠️ 绕过 |
589547 仅管理员可处理流团审批 🔒 |
CUSTOMIZER |
两者 | 589530 / 589547 |
589530 / 589547(不变) |
ADMIN(非超管) |
退单列表 | — | 200 成功 |
ADMIN(非超管) |
两者 approve |
589531 / 589545 |
589531 / 589545(不变,进业务) |
放行侧用的是非超管 ADMIN —— user-service 对 SUPER_ADMIN 短路放行,只用超管验会掩盖问题。
本地全量单测
| 检出 | Tests / Failures / Errors / Skipped |
|---|---|
基线 23bb56933 |
10310 / 0 / 13 / 52 |
合并后 82560872c |
10367 / 0 / 13 / 52 |
Failures / Errors / Skipped 三项零变化,错误类名集合 diff 逐字节相同(13 条全部为 Testcontainers 无 Docker 环境所致)。
变异测试(证明断言不是恒绿)
| 变异 | 结果 |
|---|---|
| 把 fail-closed 的 throw 改回 return | 12 条红 |
| 整段还原成改前代码 | 红的恰好是类 javadoc 标为「新增覆盖/新增行为」的 4 条;标为「行为不变的回归护栏」的 3 条全绿 |
删掉 GroupBatchDisbandApprovalService 的两行守卫 |
只有 Service 新增的 2 条红,Controller 切片 7/7 仍绿 |
⚠️ 上线前须知(运营侧)
这是行为变更。本单只查了 TEST(43 个 admin 账号 / 1 个零角色 / 且 status=DELETED 登不上),生产库有没有活跃零角色账号尚未确认。
若生产上真有人在用零角色账号做退单/流团审批,上线即被拦。这正是本单要的结果,但不能静默上线——「建了账号忘配角色」是极常见的管理状态,且角色绑定被移除后下次登录即进入该状态。
十、相关文档
- Issue
#7609(含 TEST 真实网关的改前实打证据、三套守卫 fail-open/closed 对照表) - PR
#7657