fix(菜单权限): 授权树按操作者置灰不可授予项 + 放行保留既有授权

普通管理员勾到无权授予的菜单后,ensureGrantable 抛 403,而 createUser/updateUser
都是 @Transactional,整笔回滚——线上表现就是「后台不能保存权限,也不能创建用户」
(2026-09-16 周丽娥账号创建用户与保存 uid=45 权限双双失败,库里查无新用户)。
根因是校验 2026-07-28 就加了,但授权树一直拿全量菜单,两边规则不一致。

- permission-menus 按操作者标记 grantable;授权树据此置灰(不隐藏:授权是整树替换,
  隐藏会把超管授予过、操作者自己没有的菜单当取消勾选删掉,与 09-13「权限自己没掉」同类)
- ensureGrantable 放行目标已持有的授权,只拦新增,不构成提权
- GlobalExceptionHandler 补业务异常日志:此前普通业务异常一行都不记,本次排查只能靠
  反推响应体字节数(nginx body_bytes_sent 含 chunked 开销)才定位到根因
This commit is contained in:
2026-09-16 18:46:21 +08:00
parent 803b5d583d
commit 7a6c3c3fa3
9 changed files with 275 additions and 17 deletions
@@ -77,7 +77,7 @@ watch(
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children' }"
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck"
/>
</div>
@@ -89,7 +89,7 @@ watch(
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'name', children: 'children' }"
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck"
/>
</div>
@@ -16,6 +16,11 @@ export interface MenuOptionNode {
parentId: number | null
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
type: string
/**
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
*/
disabled?: boolean
children?: MenuOptionNode[]
}
@@ -39,6 +44,8 @@ export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode
sort: sortRaw === null ? 0 : sortRaw,
parentId: parentId === null ? null : parentId,
type,
// 缺省(菜单管理页等未标记的接口)按可授予处理,保持旧行为
disabled: record.grantable === false,
}
}
@@ -0,0 +1,86 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
buildMenuOptionTree,
compactDirectGrantIds,
parseMenuOptionList,
parsePermissionMenuItem,
} from '../src/pages/account/user-menu-auth.ts'
// 2026-09-16 线上事故:非超管(普通管理员)在授权树里勾到自己无权授予的菜单后,
// 后端 ensureGrantable 抛 403 并回滚整笔事务——创建用户与保存权限双双失败,
// 前端只显示「普通管理员只能分配自己已有的菜单权限」。
// 修复:菜单列表按操作者标记 grantable,前端把不可授予的节点置灰不可勾。
//
// 注意:这里是「置灰」而非「隐藏」。授权保存是整树替换,隐藏会让超管早先授予、
// 而操作者自己没有的菜单在提交时被当作取消勾选删掉(与 09-13「权限自己没掉」同类)。
test('align_user_menu_grantable_false_maps_to_disabled_node', () => {
const locked = parsePermissionMenuItem(
{ id: 5, name: '查询ASIN', parent_id: null, sort_order: 1, grantable: false },
'admin',
)
assert.equal(locked?.disabled, true, 'grantable=false → 节点置灰')
const allowed = parsePermissionMenuItem(
{ id: 6, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
'admin',
)
assert.equal(allowed?.disabled, false, 'grantable=true → 可勾选')
// 菜单管理页等未标记 grantable 的接口必须保持旧行为(全部可勾选)
const unmarked = parsePermissionMenuItem({ id: 7, name: '菜单权限配置', parent_id: null, sort_order: 3 }, 'admin')
assert.equal(unmarked?.disabled, false, '缺省 grantable 视为可授予')
})
test('align_user_menu_grantable_survives_tree_build', () => {
const nodes = parseMenuOptionList(
[
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
{ id: 100, name: '店铺管理', parent_id: null, sort_order: 2, grantable: true },
],
'admin',
)
const tree = buildMenuOptionTree(nodes)
const account = tree.find((node) => node.id === 98)
assert.equal(account?.disabled, true, '分组节点置灰')
assert.equal(account?.children?.[0]?.disabled, true, '子节点置灰随树保留')
assert.equal(tree.find((node) => node.id === 100)?.disabled, false, '可授予节点不受影响')
})
test('align_user_menu_grantable_disabled_node_still_compactable', () => {
// 已持有但无权授予的节点会保持勾选并原样提交,压缩逻辑不能因 disabled 漏掉它
const tree = buildMenuOptionTree(
parseMenuOptionList(
[
{ id: 98, name: '账号与权限', parent_id: null, sort_order: 1, grantable: false },
{ id: 7, name: '用户管理', parent_id: 98, sort_order: 1, grantable: false },
],
'admin',
),
)
assert.deepEqual(compactDirectGrantIds([98, 7], tree), [98], '父级已勾选时仍压缩掉后代')
})
test('align_user_menu_grantable_wired_end_to_end', () => {
const tree = readSource('src/pages/account/UserMenuAuthTree.vue')
assert.match(tree, /disabled: 'disabled'/, 'el-tree 按 disabled 键置灰节点')
const vo = readSource(
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java',
)
assert.match(vo, /private Boolean grantable;/, 'VO 暴露 grantable')
const controller = readSource(
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java',
)
assert.match(controller, /permissionMenuService\.list\(requireAdmin\(request\), menuType\)/, '列表接口传入操作者')
const service = readSource(
'../backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java',
)
assert.match(service, /resolveGrantableMenuIds/, '按操作者计算可授予集')
assert.match(service, /ensureGrantable\(operator, grantIds, userId\)/, '保存校验传入目标用户以放行既有授权')
})