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
@@ -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<Void> handleBusinessException(BusinessException ex) {
public ApiResponse<Void> 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<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldError() != null
@@ -56,8 +56,8 @@ public class PermissionMenuController {
@Operation(summary = "查询菜单权限列表")
public ApiResponse<List<PermissionMenuItemVo>> 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")
@@ -24,4 +24,10 @@ public class PermissionMenuItemVo {
private Integer sortOrder;
@JsonProperty("created_at")
private LocalDateTime createdAt;
/**
* 当前操作者能否把该菜单授予他人。仅授权树接口(list)填充;菜单 CRUD 回显等
* 其他接口留空,语义为「不限制」。
*/
private Boolean grantable;
}
@@ -57,8 +57,35 @@ public class PermissionMenuService {
/** Returns the flat menu catalog, including each item's direct parent ID. */
public List<PermissionMenuItemVo> list(String menuType) {
return list(null, menuType);
}
/**
* 授权树用:非超管只能授予自己已持有的菜单(与 {@link #ensureGrantable} 同一判据),
* 这里把结论标进 {@code grantable},让前端把不可授予的节点置灰,避免「勾了才在保存时
* 整单被 403 回滚」——创建用户与保存权限共用这一棵树,勾到一个越权项会连建号一起失败。
* operator 为空或超管时不限制。
*/
public List<PermissionMenuItemVo> list(AdminUserEntity operator, String menuType) {
List<PermissionMenuEntity> menus = loadMenus(menuType);
return toItemVos(menus, menus);
List<PermissionMenuItemVo> items = toItemVos(menus, menus);
Set<Long> grantableIds = resolveGrantableMenuIds(operator);
for (PermissionMenuItemVo item : items) {
item.setGrantable(grantableIds == null || grantableIds.contains(item.getId()));
}
return items;
}
/**
* 操作者可授予的菜单 id 全集(自身直接授权 + 其后代)。
*
* @return null 表示不限制(超管或无操作者)
*/
private Set<Long> 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<Long> 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<Long> requestedIds) {
/**
* Throws 403 when a non-super admin **新增** grants outside their own effective set.
*
* <p>目标用户已持有的直接授权不参与校验:那是既有事实(通常由超管分配),普通管理员
* 编辑该用户时整树提交会把它原样带回,若一并判为越权,整笔事务会回滚——连建号、改密
* 都做不成(生产 2026-09-16 现象:勾到一个越权项,创建用户与保存权限双双失败)。
* 只放行「保留已有」不放行「新增」,因此不构成提权。
*/
private void ensureGrantable(AdminUserEntity operator, List<Long> requestedIds, Long targetUserId) {
if (operator == null || isSuperAdmin(operator)) {
return;
}
List<PermissionMenuEntity> menus = loadMenus(null);
Set<Long> effective = expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(operator.getId())), menus);
// 操作者自身 id 缺失(异常数据)时按「无任何可授予项」从严处理,不放行
Set<Long> effective = Objects.requireNonNullElse(resolveGrantableMenuIds(operator), Set.of());
Set<Long> keptIds = targetUserId == null
? Set.of()
: new LinkedHashSet<>(loadDirectColumnIds(targetUserId));
Set<Long> denied = requestedIds.stream()
.filter(id -> !keptIds.contains(id))
.filter(id -> !effective.contains(id))
.collect(Collectors.toCollection(LinkedHashSet::new));
if (!denied.isEmpty()) {
@@ -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
@@ -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<PermissionMenuItemVo> 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<PermissionMenuItemVo> 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<UserColumnPermissionEntity> 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");