diff --git a/admin-frontend-vue/src/pages/account/UserMenuAuthTree.vue b/admin-frontend-vue/src/pages/account/UserMenuAuthTree.vue index da41f7db..670bfec8 100644 --- a/admin-frontend-vue/src/pages/account/UserMenuAuthTree.vue +++ b/admin-frontend-vue/src/pages/account/UserMenuAuthTree.vue @@ -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" /> @@ -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" /> diff --git a/admin-frontend-vue/src/pages/account/user-menu-auth.ts b/admin-frontend-vue/src/pages/account/user-menu-auth.ts index 5e1de49b..9942649d 100644 --- a/admin-frontend-vue/src/pages/account/user-menu-auth.ts +++ b/admin-frontend-vue/src/pages/account/user-menu-auth.ts @@ -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, } } diff --git a/admin-frontend-vue/tests/align-user-menu-grantable.test.ts b/admin-frontend-vue/tests/align-user-menu-grantable.test.ts new file mode 100644 index 00000000..73e17f50 --- /dev/null +++ b/admin-frontend-vue/tests/align-user-menu-grantable.test.ts @@ -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\)/, '保存校验传入目标用户以放行既有授权') +}) diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java b/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java index 5fd99a23..030d125b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java +++ b/backend-java/src/main/java/com/nanri/aiimage/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.common.exception; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.security.DeviceSessionPolicy; import com.nanri.aiimage.common.service.TaskOwnerForwardService; import com.nanri.aiimage.config.TaskOperationLockConfig; import jakarta.servlet.http.HttpServletRequest; @@ -60,14 +61,23 @@ public class GlobalExceptionHandler { } @ExceptionHandler(BusinessException.class) - public ApiResponse handleBusinessException(BusinessException ex) { + public ApiResponse handleBusinessException(BusinessException ex, HttpServletRequest request) { + // 业务异常此前完全不记日志:2026-09-16 线上「保存权限/创建用户」双双失败时, + // 服务端只留 RequestTraceFilter 的 200 一行,根因只能靠反推响应体字节数才定位到。 + // 401/4011(未登录、被顶下线)属于轮询类接口的常态噪声,降为 debug 以免淹没真实业务错。 + if (isRoutineAuthNoise(ex.getCode())) { + log.debug("[business] {} {} code={} message={}", request.getMethod(), request.getRequestURI(), + ex.getCode(), ex.getMessage()); + } else { + log.warn("[business] {} {} code={} message={}", request.getMethod(), request.getRequestURI(), + ex.getCode(), ex.getMessage()); + } if (Integer.valueOf(BusinessCodes.TASK_ALREADY_FINISHED).equals(ex.getCode())) { // 幂等忽略:任务已结束时的重复提交无副作用,按成功返回,避免客户端反复重试 return ApiResponse.success("任务已结束,忽略重复提交", null); } if (Integer.valueOf(BusinessCodes.TASK_BUSY).equals(ex.getCode())) { // 锁竞争:必须如实返回失败 + 可重试码,否则 worker 会把「未落库」当成功而停止重试 - log.warn("[business] 任务忙,调用方应稍后重试: {}", ex.getMessage()); return ApiResponse.fail(BusinessCodes.TASK_BUSY, ex.getMessage()); } return ex.getCode() == null @@ -75,6 +85,12 @@ public class GlobalExceptionHandler { : ApiResponse.fail(ex.getCode(), ex.getMessage()); } + /** 未登录 / 登录态失效 / 被其他设备顶下线:按 401 语义的常态噪声,不占 WARN。 */ + private boolean isRoutineAuthNoise(Integer code) { + return Integer.valueOf(401).equals(code) + || Integer.valueOf(DeviceSessionPolicy.CODE_KICKED).equals(code); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ApiResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { String message = ex.getBindingResult().getFieldError() != null diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java index 687951cd..1a028595 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuController.java @@ -56,8 +56,8 @@ public class PermissionMenuController { @Operation(summary = "查询菜单权限列表") public ApiResponse> listMenus(HttpServletRequest request, @Parameter(description = "菜单类型: app/admin") @RequestParam(required = false) String menuType) { - requireAdmin(request); - return ApiResponse.success(permissionMenuService.list(menuType)); + // 传入操作者:授权树据此把「非超管无权授予」的菜单标记为不可勾选 + return ApiResponse.success(permissionMenuService.list(requireAdmin(request), menuType)); } @PostMapping("/permission-menus") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java index 8991471c..6afdbc05 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java @@ -24,4 +24,10 @@ public class PermissionMenuItemVo { private Integer sortOrder; @JsonProperty("created_at") private LocalDateTime createdAt; + + /** + * 当前操作者能否把该菜单授予他人。仅授权树接口(list)填充;菜单 CRUD 回显等 + * 其他接口留空,语义为「不限制」。 + */ + private Boolean grantable; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index 193439be..a2de5031 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -57,8 +57,35 @@ public class PermissionMenuService { /** Returns the flat menu catalog, including each item's direct parent ID. */ public List list(String menuType) { + return list(null, menuType); + } + + /** + * 授权树用:非超管只能授予自己已持有的菜单(与 {@link #ensureGrantable} 同一判据), + * 这里把结论标进 {@code grantable},让前端把不可授予的节点置灰,避免「勾了才在保存时 + * 整单被 403 回滚」——创建用户与保存权限共用这一棵树,勾到一个越权项会连建号一起失败。 + * operator 为空或超管时不限制。 + */ + public List list(AdminUserEntity operator, String menuType) { List menus = loadMenus(menuType); - return toItemVos(menus, menus); + List items = toItemVos(menus, menus); + Set grantableIds = resolveGrantableMenuIds(operator); + for (PermissionMenuItemVo item : items) { + item.setGrantable(grantableIds == null || grantableIds.contains(item.getId())); + } + return items; + } + + /** + * 操作者可授予的菜单 id 全集(自身直接授权 + 其后代)。 + * + * @return null 表示不限制(超管或无操作者) + */ + private Set resolveGrantableMenuIds(AdminUserEntity operator) { + if (operator == null || operator.getId() == null || isSuperAdmin(operator)) { + return null; + } + return expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(operator.getId())), loadMenus(null)); } /** @@ -543,7 +570,7 @@ public class PermissionMenuService { .filter(id -> !protectedIds.contains(id)) .toList(); } - ensureGrantable(operator, grantIds); + ensureGrantable(operator, grantIds, userId); LinkedHashSet finalGrantIds = new LinkedHashSet<>(grantIds); if (!protectedIds.isEmpty()) { @@ -833,14 +860,25 @@ public class PermissionMenuService { } } - /** Throws 403 when a non-super admin requests grants outside their own effective set. */ - private void ensureGrantable(AdminUserEntity operator, List requestedIds) { + /** + * Throws 403 when a non-super admin **新增** grants outside their own effective set. + * + *

目标用户已持有的直接授权不参与校验:那是既有事实(通常由超管分配),普通管理员 + * 编辑该用户时整树提交会把它原样带回,若一并判为越权,整笔事务会回滚——连建号、改密 + * 都做不成(生产 2026-09-16 现象:勾到一个越权项,创建用户与保存权限双双失败)。 + * 只放行「保留已有」不放行「新增」,因此不构成提权。 + */ + private void ensureGrantable(AdminUserEntity operator, List requestedIds, Long targetUserId) { if (operator == null || isSuperAdmin(operator)) { return; } - List menus = loadMenus(null); - Set effective = expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(operator.getId())), menus); + // 操作者自身 id 缺失(异常数据)时按「无任何可授予项」从严处理,不放行 + Set effective = Objects.requireNonNullElse(resolveGrantableMenuIds(operator), Set.of()); + Set keptIds = targetUserId == null + ? Set.of() + : new LinkedHashSet<>(loadDirectColumnIds(targetUserId)); Set denied = requestedIds.stream() + .filter(id -> !keptIds.contains(id)) .filter(id -> !effective.contains(id)) .collect(Collectors.toCollection(LinkedHashSet::new)); if (!denied.isEmpty()) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuControllerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuControllerTest.java index aff2cbc0..cd5100a2 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuControllerTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/controller/PermissionMenuControllerTest.java @@ -19,6 +19,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -125,12 +126,12 @@ class PermissionMenuControllerTest { when(request.getHeader("X-Internal-Token")).thenReturn("shared-token"); when(request.getParameter("operatorId")).thenReturn("12"); when(service.requireAdminOperator(12L)).thenReturn(operator); - when(service.list("app")).thenReturn(List.of()); + when(service.list(operator, "app")).thenReturn(List.of()); controller.listMenus(request, "app"); verify(service).requireAdminOperator(12L); - verify(service).list("app"); + verify(service).list(operator, "app"); verify(request, never()).getParameter("superAdmin"); } @@ -149,7 +150,7 @@ class PermissionMenuControllerTest { assertThatThrownBy(() -> controller.listMenus(request, "app")) .isSameAs(authFailure); verify(service, never()).requireAdminOperator(anyLong()); - verify(service, never()).list("app"); + verify(service, never()).list(any(AdminUserEntity.class), eq("app")); } @Test @@ -170,12 +171,12 @@ class PermissionMenuControllerTest { when(request.getHeader("X-Internal-Token")).thenReturn("file-token"); when(request.getParameter("operatorId")).thenReturn("12"); when(service.requireAdminOperator(12L)).thenReturn(operator); - when(service.list("admin")).thenReturn(List.of()); + when(service.list(operator, "admin")).thenReturn(List.of()); controller.listMenus(request, "admin"); verify(service).requireAdminOperator(12L); - verify(service).list("admin"); + verify(service).list(operator, "admin"); } @Test diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java index 587a167e..a481672f 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java @@ -25,6 +25,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -1168,6 +1169,109 @@ class PermissionMenuServiceTest { assertThat(updated.getValue().getName()).isEqualTo("改名"); } + /** + * 授权树接口:非超管只能授予自己已持有的菜单,越界的项必须被标记为不可授予, + * 否则前端会渲染成可勾选,用户勾了才在保存时整单被 403 回滚。 + */ + @Test + void listMarksMenusOutsideOperatorGrantableSetAsNonGrantable() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + when(menuMapper.selectList(any())).thenReturn(List.of( + menu(1L, null, "admin", 1), + menu(2L, 1L, "admin", 2), + menu(3L, null, "admin", 3))); + // 操作者只有 1(子菜单 2 随父展开),3 在他可授予范围之外 + when(permissionMapper.selectByUserId(30L)).thenReturn(List.of(grant(30L, 1L))); + + List items = service.list(user(30L, "admin", 1), null); + + assertThat(items) + .extracting(PermissionMenuItemVo::getId, PermissionMenuItemVo::getGrantable) + .containsExactlyInAnyOrder(tuple(1L, true), tuple(2L, true), tuple(3L, false)); + } + + @Test + void listMarksEveryMenuGrantableForSuperAdmin() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + when(menuMapper.selectList(any())).thenReturn(List.of( + menu(1L, null, "admin", 1), + menu(3L, null, "admin", 3))); + + List items = service.list(user(31L, "super_admin", 1), null); + + assertThat(items).extracting(PermissionMenuItemVo::getGrantable).containsOnly(true); + verify(permissionMapper, never()).selectByUserId(any()); + } + + /** + * 目标用户已持有的授权(通常由超管分配)不参与「只能授自己已有的」校验: + * 普通管理员编辑该用户时整树提交会把它原样带回,若一并判为越权,整笔事务回滚, + * 连建号/改密都做不成——即生产 2026-09-16 的现象。 + */ + @Test + void allowsNonSuperAdminToKeepExistingGrantsOutsideOwnSet() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + when(userMapper.selectById(7L)).thenReturn(ownedNormalUser(7L, 30L)); + when(menuMapper.selectList(any())).thenReturn(List.of( + menu(1L, null, "admin", 1), + menu(2L, null, "admin", 2))); + when(menuMapper.selectCount(any())).thenReturn(2L); + when(menuMapper.selectOne(any())).thenReturn(null); + when(permissionMapper.selectByUserId(30L)).thenReturn(List.of(grant(30L, 2L))); + when(permissionMapper.selectByUserId(7L)).thenReturn(List.of(grant(7L, 1L))); + + UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest(); + request.setColumnIds(List.of(1L, 2L)); + service.updateUserColumnPermissions(user(30L, "admin", 1), 7L, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UserColumnPermissionEntity.class); + verify(permissionMapper, times(2)).insert(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(UserColumnPermissionEntity::getColumnId) + .containsExactly(1L, 2L); + } + + /** 只放行「保留已有」,不放行「新增」:越权新增仍然拒绝,不构成提权。 */ + @Test + void rejectsNewGrantOutsideOperatorSet() { + PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class); + UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class); + AdminUserMapper userMapper = mock(AdminUserMapper.class); + PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper); + when(userMapper.selectById(7L)).thenReturn(ownedNormalUser(7L, 30L)); + when(menuMapper.selectList(any())).thenReturn(List.of( + menu(1L, null, "admin", 1), + menu(2L, null, "admin", 2), + menu(3L, null, "admin", 3))); + when(menuMapper.selectCount(any())).thenReturn(1L); + when(menuMapper.selectOne(any())).thenReturn(null); + when(permissionMapper.selectByUserId(30L)).thenReturn(List.of(grant(30L, 2L))); + when(permissionMapper.selectByUserId(7L)).thenReturn(List.of(grant(7L, 1L))); + + UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest(); + request.setColumnIds(List.of(3L)); + + assertThatThrownBy(() -> service.updateUserColumnPermissions(user(30L, "admin", 1), 7L, request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("只能分配自己已有的菜单权限"); + verify(permissionMapper, never()).insert(any(UserColumnPermissionEntity.class)); + } + + private AdminUserEntity ownedNormalUser(Long id, Long createdById) { + AdminUserEntity entity = user(id, "normal", 0); + entity.setCreatedById(createdById); + return entity; + } + private PermissionMenuCreateRequest createRequest(Long parentId, String menuType) { PermissionMenuCreateRequest request = new PermissionMenuCreateRequest(); request.setName("child");