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:
2026-09-13 14:18:01 +08:00
parent 3f6ad0c6ad
commit a4f60ef21c
6 changed files with 168 additions and 11 deletions
@@ -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) {
@@ -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) {