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:
@@ -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\)/, '保存校验传入目标用户以放行既有授权')
|
||||
})
|
||||
Reference in New Issue
Block a user