From a4f60ef21c2dd3c216fc040d9be985462d282bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 13 Sep 2026 14:18:01 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E6=9D=83=E9=99=90):=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=9D=83=E9=99=90=E2=80=9C=E8=87=AA=E5=B7=B1?= =?UTF-8?q?=E6=B2=A1=E6=8E=89=E2=80=9D=E2=80=94=E2=80=94=E5=88=86=E5=8C=BA?= =?UTF-8?q?=E8=90=BD=E5=BA=93=E6=97=B6=E7=BA=A7=E8=81=94=E6=B8=85=E7=90=86?= =?UTF-8?q?=E8=B7=A8=E7=B1=BB=E5=9E=8B=E8=AF=AF=E5=88=A0=20+=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=BC=B9=E7=AA=97=E7=A9=BA=E6=8E=88=E6=9D=83=E6=8F=90?= =?UTF-8?q?=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 级联清理的有效集改为按目标「完整直接授权」计算(读库,含 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) --- .../src/pages/account/EditUserDialog.vue | 18 ++++- .../tests/align-edit-user-auth-guard.test.ts | 35 +++++++++ .../admin/service/AdminUserService.java | 5 +- .../service/PermissionMenuService.java | 34 ++++++++- .../admin/service/AdminUserServiceTest.java | 13 +++- .../service/PermissionMenuServiceTest.java | 74 ++++++++++++++++++- 6 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 admin-frontend-vue/tests/align-edit-user-auth-guard.test.ts diff --git a/admin-frontend-vue/src/pages/account/EditUserDialog.vue b/admin-frontend-vue/src/pages/account/EditUserDialog.vue index e4da837e..e6a1b269 100644 --- a/admin-frontend-vue/src/pages/account/EditUserDialog.vue +++ b/admin-frontend-vue/src/pages/account/EditUserDialog.vue @@ -15,6 +15,8 @@ const form = reactive({ uid: 0, username: '', password: '', role: const errors = reactive({}) const busy = ref(false) const loadingAuth = ref(false) +/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */ +const authReady = ref(false) watch( () => props.modelValue, @@ -28,6 +30,7 @@ watch( form.columnIds = [] form.originalRole = base.originalRole errors.password = undefined + authReady.value = false void refreshAuth() }, ) @@ -38,7 +41,10 @@ async function refreshAuth(): Promise { try { const { checkedIds } = await loadUserMenuAuth(props.user.id) form.columnIds = checkedIds + authReady.value = true } catch (error) { + // 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。 + authReady.value = false showAdminFeedback(actionableErrorText(error), 'error') } finally { loadingAuth.value = false @@ -50,6 +56,10 @@ function close(): void { } async function save(): Promise { + if (!authReady.value) { + showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error') + return + } const { valid, errors: errs } = validateEditUserForm(form) Object.assign(errors, errs) if (!valid) return @@ -103,12 +113,15 @@ async function save(): Promise {
菜单权限加载中…
+
+ 菜单权限加载失败,保存已禁用;请关闭后重试 +
@@ -126,4 +139,7 @@ async function save(): Promise { font-size: 12px; color: var(--admin-muted); } +.auth-loading-error { + color: var(--el-color-danger); +} diff --git a/admin-frontend-vue/tests/align-edit-user-auth-guard.test.ts b/admin-frontend-vue/tests/align-edit-user-auth-guard.test.ts new file mode 100644 index 00000000..db9e355f --- /dev/null +++ b/admin-frontend-vue/tests/align-edit-user-auth-guard.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' + +// 回归(生产:用户菜单权限“自己没掉”):编辑用户弹窗打开时先把 columnIds 清空,再异步回填; +// 若授权尚未加载完成(或加载失败)就点保存,会把空数组提交成“清空授权”,整树权限被抹掉。 +// 修复:授权未就绪时禁用保存并明确提示。 + +test('edit_user_dialog_blocks_save_until_auth_loaded', () => { + const dialog = readSource('src/pages/account/EditUserDialog.vue') + + assert.match(dialog, /authReady/, '存在授权就绪标记') + assert.match(dialog, /authReady\.value = false/, '打开/失败时标记为未就绪') + + // 加载失败分支必须保持未就绪:不能回退成“可以保存空授权” + const refreshBlock = dialog.slice( + dialog.indexOf('async function refreshAuth'), + dialog.indexOf('function close'), + ) + assert.match( + refreshBlock, + /catch \(error\) \{[\s\S]*?authReady\.value = false/, + '授权加载失败保持未就绪,不用空数组覆盖服务端授权', + ) + assert.match(refreshBlock, /authReady\.value = true/, '仅在加载成功后置为就绪') + + // save() 自身兜底拦截,不依赖按钮禁用 + const saveBlock = dialog.slice(dialog.indexOf('async function save'), dialog.indexOf('async function save') + 400) + assert.match(saveBlock, /if \(!authReady\.value\)/, '未就绪时直接拦截保存') + + // 保存按钮在未就绪时禁用 + assert.match(dialog, /:disabled="!authReady"/, '未就绪时保存按钮禁用') + assert.match(dialog, /加载中/, '加载中有提示') + assert.match(dialog, /保存已禁用/, '加载失败有明确提示') +}) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java index d768cb50..f1035184 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java @@ -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 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) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index 1c5f898a..8ae1c348 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -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 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 { /** * 删除普通管理员直接创建的员工中,超出该管理员当前有效权限范围的授权。 * 只清理越权项,保留员工合法持有的其他授权。超级管理员无此限制。 + * + *

有效集按目标的完整直接授权(读库,含 admin/app 全类型)计算: + * 调用方可能只更新了单个 menuType,若拿本次提交的 id 当完整集,员工持有的 + * 另一类型授权会整体被判越权删除——生产表现就是菜单权限“自己没掉”。

*/ - private void cascadeCleanupSubordinateOverreach(AdminUserEntity target, Set targetDirectIds) { + private void cascadeCleanupSubordinateOverreach(AdminUserEntity target) { if (target == null || target.getId() == null || isSuperAdmin(target)) { return; } - Set targetEffectiveIds = expandDescendantIds(new LinkedHashSet<>(targetDirectIds), loadMenus(null)); + Set targetEffectiveIds = expandDescendantIds( + new LinkedHashSet<>(loadDirectColumnIds(target.getId())), loadMenus(null)); List subordinates = adminUserMapper.selectList(new LambdaQueryWrapper() .eq(AdminUserEntity::getCreatedById, target.getId())); for (AdminUserEntity subordinate : subordinates) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/admin/service/AdminUserServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/admin/service/AdminUserServiceTest.java index 3117d5b7..02274e5b 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/admin/service/AdminUserServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/admin/service/AdminUserServiceTest.java @@ -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 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 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 diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java index 795d60a2..bbe6c5cb 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/permission/service/PermissionMenuServiceTest.java @@ -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);