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" node-key="id"
show-checkbox show-checkbox
default-expand-all default-expand-all
:props="{ label: 'name', children: 'children' }" :props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck" @check="onCheck"
/> />
</div> </div>
@@ -89,7 +89,7 @@ watch(
node-key="id" node-key="id"
show-checkbox show-checkbox
default-expand-all default-expand-all
:props="{ label: 'name', children: 'children' }" :props="{ label: 'name', children: 'children', disabled: 'disabled' }"
@check="onCheck" @check="onCheck"
/> />
</div> </div>
@@ -16,6 +16,11 @@ export interface MenuOptionNode {
parentId: number | null parentId: number | null
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */ /** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
type: string type: string
/**
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
*/
disabled?: boolean
children?: MenuOptionNode[] children?: MenuOptionNode[]
} }
@@ -39,6 +44,8 @@ export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode
sort: sortRaw === null ? 0 : sortRaw, sort: sortRaw === null ? 0 : sortRaw,
parentId: parentId === null ? null : parentId, parentId: parentId === null ? null : parentId,
type, 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\)/, '保存校验传入目标用户以放行既有授权')
})
@@ -1,6 +1,7 @@
package com.nanri.aiimage.common.exception; package com.nanri.aiimage.common.exception;
import com.nanri.aiimage.common.api.ApiResponse; 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.common.service.TaskOwnerForwardService;
import com.nanri.aiimage.config.TaskOperationLockConfig; import com.nanri.aiimage.config.TaskOperationLockConfig;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -60,14 +61,23 @@ public class GlobalExceptionHandler {
} }
@ExceptionHandler(BusinessException.class) @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())) { if (Integer.valueOf(BusinessCodes.TASK_ALREADY_FINISHED).equals(ex.getCode())) {
// 幂等忽略:任务已结束时的重复提交无副作用,按成功返回,避免客户端反复重试 // 幂等忽略:任务已结束时的重复提交无副作用,按成功返回,避免客户端反复重试
return ApiResponse.success("任务已结束,忽略重复提交", null); return ApiResponse.success("任务已结束,忽略重复提交", null);
} }
if (Integer.valueOf(BusinessCodes.TASK_BUSY).equals(ex.getCode())) { if (Integer.valueOf(BusinessCodes.TASK_BUSY).equals(ex.getCode())) {
// 锁竞争:必须如实返回失败 + 可重试码,否则 worker 会把「未落库」当成功而停止重试 // 锁竞争:必须如实返回失败 + 可重试码,否则 worker 会把「未落库」当成功而停止重试
log.warn("[business] 任务忙,调用方应稍后重试: {}", ex.getMessage());
return ApiResponse.fail(BusinessCodes.TASK_BUSY, ex.getMessage()); return ApiResponse.fail(BusinessCodes.TASK_BUSY, ex.getMessage());
} }
return ex.getCode() == null return ex.getCode() == null
@@ -75,6 +85,12 @@ public class GlobalExceptionHandler {
: ApiResponse.fail(ex.getCode(), ex.getMessage()); : 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) @ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldError() != null String message = ex.getBindingResult().getFieldError() != null
@@ -56,8 +56,8 @@ public class PermissionMenuController {
@Operation(summary = "查询菜单权限列表") @Operation(summary = "查询菜单权限列表")
public ApiResponse<List<PermissionMenuItemVo>> listMenus(HttpServletRequest request, public ApiResponse<List<PermissionMenuItemVo>> listMenus(HttpServletRequest request,
@Parameter(description = "菜单类型: app/admin") @RequestParam(required = false) String menuType) { @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") @PostMapping("/permission-menus")
@@ -24,4 +24,10 @@ public class PermissionMenuItemVo {
private Integer sortOrder; private Integer sortOrder;
@JsonProperty("created_at") @JsonProperty("created_at")
private LocalDateTime createdAt; 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. */ /** Returns the flat menu catalog, including each item's direct parent ID. */
public List<PermissionMenuItemVo> list(String menuType) { 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); 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)) .filter(id -> !protectedIds.contains(id))
.toList(); .toList();
} }
ensureGrantable(operator, grantIds); ensureGrantable(operator, grantIds, userId);
LinkedHashSet<Long> finalGrantIds = new LinkedHashSet<>(grantIds); LinkedHashSet<Long> finalGrantIds = new LinkedHashSet<>(grantIds);
if (!protectedIds.isEmpty()) { 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)) { if (operator == null || isSuperAdmin(operator)) {
return; return;
} }
List<PermissionMenuEntity> menus = loadMenus(null); // 操作者自身 id 缺失(异常数据)时按「无任何可授予项」从严处理,不放行
Set<Long> effective = expandDescendantIds(new LinkedHashSet<>(loadDirectColumnIds(operator.getId())), menus); 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() Set<Long> denied = requestedIds.stream()
.filter(id -> !keptIds.contains(id))
.filter(id -> !effective.contains(id)) .filter(id -> !effective.contains(id))
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
if (!denied.isEmpty()) { 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -125,12 +126,12 @@ class PermissionMenuControllerTest {
when(request.getHeader("X-Internal-Token")).thenReturn("shared-token"); when(request.getHeader("X-Internal-Token")).thenReturn("shared-token");
when(request.getParameter("operatorId")).thenReturn("12"); when(request.getParameter("operatorId")).thenReturn("12");
when(service.requireAdminOperator(12L)).thenReturn(operator); 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"); controller.listMenus(request, "app");
verify(service).requireAdminOperator(12L); verify(service).requireAdminOperator(12L);
verify(service).list("app"); verify(service).list(operator, "app");
verify(request, never()).getParameter("superAdmin"); verify(request, never()).getParameter("superAdmin");
} }
@@ -149,7 +150,7 @@ class PermissionMenuControllerTest {
assertThatThrownBy(() -> controller.listMenus(request, "app")) assertThatThrownBy(() -> controller.listMenus(request, "app"))
.isSameAs(authFailure); .isSameAs(authFailure);
verify(service, never()).requireAdminOperator(anyLong()); verify(service, never()).requireAdminOperator(anyLong());
verify(service, never()).list("app"); verify(service, never()).list(any(AdminUserEntity.class), eq("app"));
} }
@Test @Test
@@ -170,12 +171,12 @@ class PermissionMenuControllerTest {
when(request.getHeader("X-Internal-Token")).thenReturn("file-token"); when(request.getHeader("X-Internal-Token")).thenReturn("file-token");
when(request.getParameter("operatorId")).thenReturn("12"); when(request.getParameter("operatorId")).thenReturn("12");
when(service.requireAdminOperator(12L)).thenReturn(operator); 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"); controller.listMenus(request, "admin");
verify(service).requireAdminOperator(12L); verify(service).requireAdminOperator(12L);
verify(service).list("admin"); verify(service).list(operator, "admin");
} }
@Test @Test
@@ -25,6 +25,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; 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.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -1168,6 +1169,109 @@ class PermissionMenuServiceTest {
assertThat(updated.getValue().getName()).isEqualTo("改名"); 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) { private PermissionMenuCreateRequest createRequest(Long parentId, String menuType) {
PermissionMenuCreateRequest request = new PermissionMenuCreateRequest(); PermissionMenuCreateRequest request = new PermissionMenuCreateRequest();
request.setName("child"); request.setName("child");