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:
+18
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+6
@@ -24,4 +24,10 @@ public class PermissionMenuItemVo {
|
||||
private Integer sortOrder;
|
||||
@JsonProperty("created_at")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 当前操作者能否把该菜单授予他人。仅授权树接口(list)填充;菜单 CRUD 回显等
|
||||
* 其他接口留空,语义为「不限制」。
|
||||
*/
|
||||
private Boolean grantable;
|
||||
}
|
||||
|
||||
+44
-6
@@ -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()) {
|
||||
|
||||
+6
-5
@@ -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
|
||||
|
||||
+104
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user