fix(权限): 用户菜单权限“自己没掉”——分区落库时级联清理跨类型误删 + 编辑弹窗空授权提交
- 级联清理的有效集改为按目标「完整直接授权」计算(读库,含 admin/app 全类型)。 此前 admin/app 分区落库把本次提交的 id 当成完整有效集,员工持有的另一类型授权 会整体被判越权删除;两次分区落库互相补刀,最终清空员工全部菜单权限 (生产表现:保存某非超管的权限后,他直建员工的菜单隔三差五自己消失) - AdminUserService 改为两类落库完成后再统一级联一次,新增 cascadeSubordinateOverreach 入口 - 后台「编辑用户」弹窗:授权未加载完成/失败时禁用保存并明确提示, 避免以空 columnIds 整树清空该用户授权(改动密码等操作也会连带触发) - 回归测试:deferredCascadeKeepsSubordinateGrantsOfOtherMenuType、 perTypeReplacementDoesNotCascadeBeforeAllTypesWritten、edit_user_dialog_blocks_save_until_auth_loaded 已知既有红测试(与本次改动无关,干净 HEAD 上同样复现,未新增): ArchitectureBoundaryTest.taskToBusinessDependencyDoesNotGrow(110 > 基线 84)、 HttpClientTimeoutEffectiveTest.connectTimeoutFiresOnUnreachableHost(本机网络环境 60s)
This commit is contained in:
+4
-1
@@ -258,6 +258,9 @@ public class AdminUserService {
|
||||
}
|
||||
replaceDirectPermissionsByType(operator, userId, adminIds, PermissionMenuService.MENU_TYPE_ADMIN);
|
||||
replaceDirectPermissionsByType(operator, userId, appIds, PermissionMenuService.MENU_TYPE_APP);
|
||||
// 两类都落库后再统一级联一次:单类型落库时目标的有效集不完整,提前级联会把
|
||||
// 员工另一类型的合法授权当越权删掉(生产表现:员工菜单权限“自己没掉”)。
|
||||
permissionMenuService.cascadeSubordinateOverreach(userId);
|
||||
}
|
||||
|
||||
private void replaceDirectPermissionsByType(AdminUserEntity operator, Long userId, List<Long> columnIds,
|
||||
@@ -265,7 +268,7 @@ public class AdminUserService {
|
||||
UserColumnPermissionUpdateRequest permissionRequest = new UserColumnPermissionUpdateRequest();
|
||||
permissionRequest.setColumnIds(columnIds);
|
||||
permissionMenuService.updateUserColumnPermissions(
|
||||
operator, userId, permissionRequest, menuType);
|
||||
operator, userId, permissionRequest, menuType, false);
|
||||
}
|
||||
|
||||
public void deleteUser(AdminUserEntity currentUser, Long uid) {
|
||||
|
||||
+31
-3
@@ -454,6 +454,21 @@ public class PermissionMenuService {
|
||||
Long userId,
|
||||
UserColumnPermissionUpdateRequest request,
|
||||
String menuType) {
|
||||
updateUserColumnPermissions(operator, userId, request, menuType, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param cascadeSubordinates 是否在本方法收尾时级联清理下属越权授权。调用方若分
|
||||
* admin/app 多次落库,必须传 false,并在全部落库后调用一次
|
||||
* {@link #cascadeSubordinateOverreach(Long)}:单次落库时目标只写了其中一个
|
||||
* 类型,此时级联会按不完整的有效集判定,把员工另一类型的合法授权当越权删掉。
|
||||
*/
|
||||
@Transactional
|
||||
public void updateUserColumnPermissions(AdminUserEntity operator,
|
||||
Long userId,
|
||||
UserColumnPermissionUpdateRequest request,
|
||||
String menuType,
|
||||
boolean cascadeSubordinates) {
|
||||
AdminUserEntity target = getUserById(userId);
|
||||
ensureTargetAccessible(operator, target);
|
||||
List<Long> requestedIds = request == null ? List.of() : normalizeColumnIds(request.getColumnIds());
|
||||
@@ -527,7 +542,15 @@ public class PermissionMenuService {
|
||||
// 目标管理员的权限被回收后,其直接创建的员工若仍持有超出范围的
|
||||
// 授权(创建时快照遗留,员工权限独立于管理员后续变更),必须级联
|
||||
// 删除,否则回收了管理员的菜单权限,员工菜单权限却还在。
|
||||
cascadeCleanupSubordinateOverreach(target, finalGrantIds);
|
||||
if (cascadeSubordinates) {
|
||||
cascadeCleanupSubordinateOverreach(target);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次逻辑更新(可能分 admin/app 多次落库)全部写完后统一触发一次下属越权清理。 */
|
||||
@Transactional
|
||||
public void cascadeSubordinateOverreach(Long targetUserId) {
|
||||
cascadeCleanupSubordinateOverreach(getUserById(targetUserId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -573,12 +596,17 @@ public class PermissionMenuService {
|
||||
/**
|
||||
* 删除普通管理员直接创建的员工中,超出该管理员当前有效权限范围的授权。
|
||||
* 只清理越权项,保留员工合法持有的其他授权。超级管理员无此限制。
|
||||
*
|
||||
* <p>有效集按目标的<b>完整</b>直接授权(读库,含 admin/app 全类型)计算:
|
||||
* 调用方可能只更新了单个 menuType,若拿本次提交的 id 当完整集,员工持有的
|
||||
* 另一类型授权会整体被判越权删除——生产表现就是菜单权限“自己没掉”。</p>
|
||||
*/
|
||||
private void cascadeCleanupSubordinateOverreach(AdminUserEntity target, Set<Long> targetDirectIds) {
|
||||
private void cascadeCleanupSubordinateOverreach(AdminUserEntity target) {
|
||||
if (target == null || target.getId() == null || isSuperAdmin(target)) {
|
||||
return;
|
||||
}
|
||||
Set<Long> targetEffectiveIds = expandDescendantIds(new LinkedHashSet<>(targetDirectIds), loadMenus(null));
|
||||
Set<Long> targetEffectiveIds = expandDescendantIds(
|
||||
new LinkedHashSet<>(loadDirectColumnIds(target.getId())), loadMenus(null));
|
||||
List<AdminUserEntity> subordinates = adminUserMapper.selectList(new LambdaQueryWrapper<AdminUserEntity>()
|
||||
.eq(AdminUserEntity::getCreatedById, target.getId()));
|
||||
for (AdminUserEntity subordinate : subordinates) {
|
||||
|
||||
+9
-4
@@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -58,9 +59,11 @@ class AdminUserServiceTest {
|
||||
ArgumentCaptor<UserColumnPermissionUpdateRequest> appReq =
|
||||
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
|
||||
verify(permissionService).updateUserColumnPermissions(
|
||||
eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN));
|
||||
eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN), eq(false));
|
||||
verify(permissionService).updateUserColumnPermissions(
|
||||
eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP));
|
||||
eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP), eq(false));
|
||||
// 两类落库完成后只级联清理一次,避免用不完整有效集误删员工另一类型授权
|
||||
verify(permissionService, times(1)).cascadeSubordinateOverreach(20L);
|
||||
assertThat(adminReq.getValue().getColumnIds()).containsExactly(11L);
|
||||
assertThat(appReq.getValue().getColumnIds()).containsExactly(12L);
|
||||
assertThat(AdminUserService.class
|
||||
@@ -95,9 +98,11 @@ class AdminUserServiceTest {
|
||||
ArgumentCaptor<UserColumnPermissionUpdateRequest> appReq =
|
||||
ArgumentCaptor.forClass(UserColumnPermissionUpdateRequest.class);
|
||||
verify(permissionService).updateUserColumnPermissions(
|
||||
eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN));
|
||||
eq(operator), eq(20L), adminReq.capture(), eq(PermissionMenuService.MENU_TYPE_ADMIN), eq(false));
|
||||
verify(permissionService).updateUserColumnPermissions(
|
||||
eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP));
|
||||
eq(operator), eq(20L), appReq.capture(), eq(PermissionMenuService.MENU_TYPE_APP), eq(false));
|
||||
// 两类落库完成后只级联清理一次,避免用不完整有效集误删员工另一类型授权
|
||||
verify(permissionService, times(1)).cascadeSubordinateOverreach(20L);
|
||||
assertThat(adminReq.getValue().getColumnIds()).isEmpty();
|
||||
assertThat(appReq.getValue().getColumnIds()).containsExactly(21L);
|
||||
assertThat(AdminUserService.class
|
||||
|
||||
+72
-2
@@ -21,6 +21,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.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -434,9 +435,9 @@ class PermissionMenuServiceTest {
|
||||
when(menuMapper.selectList(any())).thenReturn(List.of(
|
||||
menu(1L, null, "admin", 1),
|
||||
menu(2L, null, "admin", 2)));
|
||||
// 管理员当前授权:1(新)+ 2(被回收前存在)
|
||||
// 目标当前授权:级联读的是本次落库后的状态——管理员只剩 1,2 已被回收。
|
||||
when(permissionMapper.selectByUserId(10L))
|
||||
.thenReturn(List.of(grant(10L, 1L), grant(10L, 2L)));
|
||||
.thenReturn(List.of(grant(10L, 1L)));
|
||||
// 员工 20 的授权:1(合法)+ 2(越权,应被级联删除)
|
||||
when(permissionMapper.selectByUserId(20L))
|
||||
.thenReturn(List.of(grant(20L, 1L), grant(20L, 2L)));
|
||||
@@ -450,6 +451,75 @@ class PermissionMenuServiceTest {
|
||||
verify(permissionMapper).deleteByUserIdAndColumnIds(20L, List.of(2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deferredCascadeKeepsSubordinateGrantsOfOtherMenuType() {
|
||||
// 回归(生产:员工菜单权限“自己没掉”):admin/app 分区落库时若在每个分区后立刻级联,
|
||||
// 目标的有效集只含当前类型,员工另一类型的合法授权会被整体误判越权删除。
|
||||
// 修复后:两类落库完再统一级联一次,按目标完整直接授权判定。
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||
|
||||
AdminUserEntity operator = user(1L, "super_admin", 1);
|
||||
AdminUserEntity target = user(10L, "admin", 1);
|
||||
target.setCreatedById(1L);
|
||||
AdminUserEntity subordinate = user(20L, "normal", 0);
|
||||
subordinate.setCreatedById(10L);
|
||||
when(userMapper.selectById(10L)).thenReturn(target);
|
||||
when(menuMapper.selectList(any())).thenReturn(List.of(
|
||||
menu(1L, null, "admin", 1),
|
||||
menu(11L, null, "app", 1)));
|
||||
when(menuMapper.selectOne(any())).thenReturn(null);
|
||||
// 目标本次落库后的完整直接授权:admin 1 + app 11
|
||||
when(permissionMapper.selectByUserId(10L))
|
||||
.thenReturn(List.of(grant(10L, 1L), grant(10L, 11L)));
|
||||
// 员工持有:1(admin 合法)、11(app 合法)、99(越权,菜单表里不存在)
|
||||
when(permissionMapper.selectByUserId(20L))
|
||||
.thenReturn(List.of(grant(20L, 1L), grant(20L, 11L), grant(20L, 99L)));
|
||||
when(userMapper.selectList(any())).thenReturn(List.of(subordinate));
|
||||
|
||||
UserColumnPermissionUpdateRequest adminRequest = new UserColumnPermissionUpdateRequest();
|
||||
adminRequest.setColumnIds(List.of(1L));
|
||||
UserColumnPermissionUpdateRequest appRequest = new UserColumnPermissionUpdateRequest();
|
||||
appRequest.setColumnIds(List.of(11L));
|
||||
|
||||
service.updateUserColumnPermissions(
|
||||
operator, 10L, adminRequest, PermissionMenuService.MENU_TYPE_ADMIN, false);
|
||||
service.updateUserColumnPermissions(
|
||||
operator, 10L, appRequest, PermissionMenuService.MENU_TYPE_APP, false);
|
||||
service.cascadeSubordinateOverreach(10L);
|
||||
|
||||
// 只有真正越权的 99 被清理,另一类型(app 11)的合法授权必须保留
|
||||
verify(permissionMapper).deleteByUserIdAndColumnIds(20L, List.of(99L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void perTypeReplacementDoesNotCascadeBeforeAllTypesWritten() {
|
||||
// 分区落库期间(cascadeSubordinates=false)不得触发下属清理,否则会拿不完整的
|
||||
// 有效集误删员工另一类型授权。
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
UserColumnPermissionMapper permissionMapper = mock(UserColumnPermissionMapper.class);
|
||||
AdminUserMapper userMapper = mock(AdminUserMapper.class);
|
||||
PermissionMenuService service = new PermissionMenuService(menuMapper, permissionMapper, userMapper);
|
||||
|
||||
AdminUserEntity operator = user(1L, "super_admin", 1);
|
||||
AdminUserEntity target = user(10L, "admin", 1);
|
||||
target.setCreatedById(1L);
|
||||
when(userMapper.selectById(10L)).thenReturn(target);
|
||||
when(menuMapper.selectList(any())).thenReturn(List.of(menu(1L, null, "admin", 1)));
|
||||
when(menuMapper.selectOne(any())).thenReturn(null);
|
||||
|
||||
UserColumnPermissionUpdateRequest request = new UserColumnPermissionUpdateRequest();
|
||||
request.setColumnIds(List.of(1L));
|
||||
service.updateUserColumnPermissions(
|
||||
operator, 10L, request, PermissionMenuService.MENU_TYPE_ADMIN, false);
|
||||
|
||||
verify(userMapper, never()).selectList(any());
|
||||
// 目标自身分区的整树替换会删自己的授权,但不得波及任何下属
|
||||
verify(permissionMapper, never()).deleteByUserIdAndColumnIds(eq(20L), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replacementStoresOnlyDirectRequestedParentId() {
|
||||
PermissionMenuMapper menuMapper = mock(PermissionMenuMapper.class);
|
||||
|
||||
Reference in New Issue
Block a user