From 8799d72d471b6cea7d0b09c7cce520e418907599 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 16 Sep 2026 19:13:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(access):=20=E6=95=B0=E6=8D=AE=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E6=94=B6=E6=95=9B=E5=88=B0=E5=BD=93=E5=89=8D=E6=BF=80?= =?UTF-8?q?=E6=B4=BB=E8=BA=AB=E4=BB=BD=E6=89=80=E5=9C=A8=E5=AD=A6=E6=A0=A1?= =?UTF-8?q?=20+=20=E3=80=8C=E6=9C=AC=E7=BB=84=E3=80=8D=E5=A4=87=E6=B3=A8?= =?UTF-8?q?=E8=A7=84=E5=88=99=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题1 学校管理员多校并存:SCHOOL 级判定原先收拢该管理员名下所有学校 (多校 = 多棵子树并集),导致一个在云海小学+云海中学都有管理员身份的账号 左侧组织树出现两个学校根。 - AccessScopeService.computeScope:SCHOOL 分支优先取 sys_user_current_role 的 schoolId(须命中「该用户确有 {校领导,管理员} 正常态身份行且组为学校组」白名单, 一并挡住脏记录指向别校、切到非管理身份两类越权)→ 无记录/不合法回落本职主校 → 都解析不出才保留历史宽口径(范围为空会误降 NONE 被整体 403,宁宽勿窄) - 落在 scopeGroupIds 上游:树/列表/搜索/导入导出/写口 guard 一起收敛, 避免「树里看不到但能搜到、能跨校写」的半修复 - 新增 AccessScopeServiceTest 9 例(该类此前无测试):当前身份收敛/回落主校/ 脏记录与区域组回落/主组链无学校的宽口径/超管不受影响/无管理身份行不进 SCHOOL - 类注释与《管理端权限分级改造方案》§1/§5/§6/§8 同步修订留痕(原写明与当前身份无关) 问题3 权限一览「(本组)」备注:撤销上一版「上下文组覆盖主组」的做法, 改为 own = 用户归属组数>1 且节点为主组——单组用户不再带备注,多组用户 只有本职组带标记(兼职组不带),切换查看时才具备区分意义。 --- .../service/impl/GroupAuthzService.java | 10 +- .../service/resolve/AccessScopeService.java | 59 +++- .../resolve/AccessScopeServiceTest.java | 251 ++++++++++++++++++ 3 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceTest.java diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index 8a428d4..bbdf60b 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -1332,12 +1332,14 @@ public class GroupAuthzService implements IGroupAuthzService { if (selfGroupIds.isEmpty()) { return Collections.emptyList(); } - // 1.5 上下文收窄:指定组且用户确属该组时只画该组链路(权限一览按行查看「用户×组」对); - // 「本组」标记同步指向该组——主组可能在被收窄掉的链路上,沿用主组会导致整棵树没有「本组」 + // 「本组」备注只在用户归属多个组时才有区分意义(单组用户整棵树都是他的组,备注冗余); + // 需在收窄前统计,故单独记一份 + boolean multiGroup = selfGroupIds.size() > 1; + // 1.5 上下文收窄:指定组且用户确属该组时只画该组链路(权限一览按行查看「用户×组」对)。 + // 「本组」标记不跟随上下文组:兼职组不标,只有本职主组才标——否则切到哪个组都显示「本组」 if (contextGroupId != null && selfGroupIds.contains(contextGroupId)) { selfGroupIds = new LinkedHashSet<>(); selfGroupIds.add(contextGroupId); - mainGroupId = contextGroupId; } // 2. 祖先链并集(含自身);多个归属组共享祖先时只出现一次 Map allGroupMap = buildAllGroupMap(); @@ -1360,7 +1362,7 @@ public class GroupAuthzService implements IGroupAuthzService { UserGroupNodeVo vo = new UserGroupNodeVo(); vo.setId(id); vo.setName(g == null || g.getName() == null ? String.valueOf(id) : g.getName()); - vo.setOwn(mainGroupId != null && id.equals(mainGroupId)); + vo.setOwn(multiGroup && mainGroupId != null && id.equals(mainGroupId)); voMap.put(id, vo); } List roots = new ArrayList<>(); diff --git a/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java b/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java index 6dd5cec..11c6524 100644 --- a/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java +++ b/src/main/java/com/yunhai/userbase/service/resolve/AccessScopeService.java @@ -4,6 +4,7 @@ import com.yunhai.platform.utils.exception.DException; import com.yunhai.userbase.common.context.UserContext; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserCurrentRole; import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.entity.SysUserRoleRel; import com.yunhai.userbase.enums.AccessLevelEnum; @@ -12,6 +13,7 @@ import com.yunhai.userbase.enums.StatusEnum; import com.yunhai.userbase.enums.SysRoleEnum; import com.yunhai.userbase.enums.UserTypeEnum; import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; import com.yunhai.userbase.repository.SysUserGroupRelRepository; import com.yunhai.userbase.repository.SysUserRepository; import com.yunhai.userbase.repository.SysUserRoleRelRepository; @@ -36,7 +38,10 @@ import java.util.Set; *
  • SUPER:sys_user.type=1(内置超管账号)或基石账号 id=100000,行为与现状完全一致;
  • *
  • SCHOOL:sys_user_role_rel 存在正常态身份行 roleId∈{2 校领导, 7 管理员} 且其 schoolId * 对应组类型为学校组(schoolId 恒为真实组 id,局管理员的 schoolId=区域组,必须校验组类型 - * 防止误判)。与当前激活身份无关,不看 sys_user_current_role;
  • + * 防止误判)。范围收敛到「当前激活身份」所在学校(sys_user_current_role → + * userhome 切换身份时写入;无记录或记录不合法回落本职主校)——多校管理员同一时刻只管理 + * 一所学校,要管理另一所须先切换身份;见 {@link #computeScope} 2.1/2.2; + * *
  • BUREAU:主归属组链(主组 → 根)含区域组且不含学校组,不限角色;
  • *
  • NONE:其余(登录用户但无任何管理面)。
  • * @@ -75,6 +80,9 @@ public class AccessScopeService { @Autowired private SysGroupRepository sysGroupRepository; + @Autowired + private SysUserCurrentRoleRepository sysUserCurrentRoleRepository; + @Autowired private MainGroupResolver mainGroupResolver; @@ -132,6 +140,20 @@ public class AccessScopeService { } } if (!schoolGroupIds.isEmpty()) { + // 2.1 收敛到「当前激活身份」所在学校:身份切换(userhome → /oapi/identity/switch → sys_user_current_role) + // 决定本次会话管理哪一所学校——要管理其他学校需先切换身份,避免多校管理员同时看到多棵树、 + // 并在不知情的情况下跨校改动数据 + Integer activeSchoolId = resolveActiveSchoolId(userId, schoolGroupIds); + if (activeSchoolId != null) { + return AccessScope.school(Collections.singletonList(activeSchoolId)); + } + // 2.2 无切换记录(或记录不指向其可管理的学校)→ 回落本职主校 + Integer mainSchoolId = resolveMainSchoolId(userId, schoolGroupIds, groupMap); + if (mainSchoolId != null) { + return AccessScope.school(Collections.singletonList(mainSchoolId)); + } + // 2.3 主校也解析不出(无组关系/主组不在任何管理学校内)→ 保留历史口径(全部可管理学校), + // 宁宽勿窄:范围为空会把 SCHOOL 级误降为 NONE,被 ManageAccessInterceptor 整体 403 return AccessScope.school(schoolGroupIds); } // 3) BUREAU:主归属组链含区域组且不含学校组(不限角色) @@ -171,6 +193,41 @@ public class AccessScopeService { return DicUtil.SUPER_ADMIN_ID.equals(userId); } + /** + * 当前激活身份所在学校(sys_user_current_role):仅当该记录指向的 schoolId 属于 + * {@code adminSchoolIds}(即用户确有 {2 校领导, 7 管理员} 正常态身份行且该组为学校组)时采用, + * 借此一次性挡住「脏记录指向别校」「切到非管理身份(如老师身份在另一校)」两类越权。
    + * 无切换记录 / 记录非法 → null,由调用方回落主校。 + */ + private Integer resolveActiveSchoolId(Integer userId, List adminSchoolIds) { + SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null); + if (current == null || current.getSchoolId() == null) { + return null; + } + return adminSchoolIds.contains(current.getSchoolId()) ? current.getSchoolId() : null; + } + + /** + * 本职主校:主归属组(isMain=1,无主兜底 id 最小)沿祖先链找最近的学校组,且该校须在其可管理学校内。
    + * 解析不出(无组关系 / 主组不在学校内 / 主校非其管理学校)→ null,由调用方保留历史宽口径。 + */ + private Integer resolveMainSchoolId(Integer userId, List adminSchoolIds, Map groupMap) { + SysUserGroupRel mainRel = mainGroupResolver.resolve(userId).orElse(null); + if (mainRel == null || mainRel.getGroupId() == null) { + return null; + } + if (groupMap == null) { + groupMap = buildAllGroupMap(); + } + for (Integer gid : ancestorChain(mainRel.getGroupId(), groupMap)) { + SysGroup g = groupMap.get(gid); + if (g != null && g.getType() == GroupTypeEnum.SCHOOL_GROUP) { + return adminSchoolIds.contains(gid) ? gid : null; + } + } + return null; + } + // ===================== 范围展开 ===================== /** diff --git a/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceTest.java b/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceTest.java new file mode 100644 index 0000000..c763a3a --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceTest.java @@ -0,0 +1,251 @@ +package com.yunhai.userbase.service.resolve; + +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserCurrentRole; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.entity.SysUserRoleRel; +import com.yunhai.userbase.enums.AccessLevelEnum; +import com.yunhai.userbase.enums.GroupTypeEnum; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.enums.SysRoleEnum; +import com.yunhai.userbase.enums.UserTypeEnum; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserCurrentRoleRepository; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +import com.yunhai.userbase.repository.SysUserRoleRelRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + +/** + * 管理端访问范围解析单测:重点是 SCHOOL 级「范围收敛到当前激活身份所在学校」。
    + * 规则:优先 sys_user_current_role 且该校须在其可管理学校内 → 否则回落本职主校 → 都拿不到才保留 + * 历史宽口径(全部可管理学校),宁宽勿窄(范围为空会把 SCHOOL 误降为 NONE 而被整体 403)。 + */ +class AccessScopeServiceTest { + + private static final Integer USER_ID = 1; + /** 区域组 */ + private static final Integer AREA_ID = 5; + /** 学校组:云海小学 */ + private static final Integer SCHOOL_A = 10; + /** 学校组:云海中学 */ + private static final Integer SCHOOL_B = 20; + /** 普通组(云海小学下的教师组) */ + private static final Integer GROUP_A_TEACHER = 101; + + @InjectMocks + private AccessScopeService accessScopeService; + + @Mock + private SysUserRepository sysUserRepository; + @Mock + private SysUserRoleRelRepository sysUserRoleRelRepository; + @Mock + private SysUserGroupRelRepository sysUserGroupRelRepository; + @Mock + private SysGroupRepository sysGroupRepository; + @Mock + private SysUserCurrentRoleRepository sysUserCurrentRoleRepository; + @Mock + private MainGroupResolver mainGroupResolver; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + // 组快照:区域组(5) → 学校组(10/20) → 教师组(101,挂在小学下) + when(sysGroupRepository.findAll()).thenReturn(Arrays.asList( + group(SCHOOL_A, "云海小学", GROUP_A_TEACHER - 100, GroupTypeEnum.SCHOOL_GROUP), + group(SCHOOL_B, "云海中学", AREA_ID, GroupTypeEnum.SCHOOL_GROUP), + group(AREA_ID, "信阳教育局", 0, GroupTypeEnum.AREA_GROUP), + group(GROUP_A_TEACHER, "教师组", SCHOOL_A, GroupTypeEnum.TEACHER_GROUP) + )); + } + + private SysGroup group(Integer id, String name, Integer parentId, GroupTypeEnum type) { + SysGroup g = new SysGroup(); + g.setId(id); + g.setName(name); + g.setParentId(parentId); + g.setType(type); + return g; + } + + /** 平台用户(非超管) */ + private SysUser normalUser() { + SysUser user = new SysUser(); + user.setId(USER_ID); + user.setType(UserTypeEnum.SCHOOL_ADMIN); + return user; + } + + /** 学校管理员身份行(roleId=7 管理员 + 指定学校) */ + private SysUserRoleRel schoolAdminRel(Integer schoolId) { + SysUserRoleRel rel = new SysUserRoleRel(); + rel.setUserId(USER_ID); + rel.setRoleId(SysRoleEnum.ADMIN.getKey()); + rel.setSchoolId(schoolId); + rel.setStatus(StatusEnum.NORMAL); + return rel; + } + + private SysUserGroupRel groupRel(Integer groupId, Integer isMain) { + SysUserGroupRel rel = new SysUserGroupRel(); + rel.setId(1); + rel.setUserId(USER_ID); + rel.setGroupId(groupId); + rel.setIsMain(isMain); + return rel; + } + + private SysUserCurrentRole currentRole(Integer schoolId) { + SysUserCurrentRole cur = new SysUserCurrentRole(); + cur.setUserId(USER_ID); + cur.setSchoolId(schoolId); + cur.setRoleId(SysRoleEnum.ADMIN.getKey()); + return cur; + } + + /** 公共桩:平台用户 + 两所学校的管理员身份行 */ + private void givenTwoSchoolAdmin() { + when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(normalUser())); + when(sysUserRoleRelRepository.findByUserIdAndStatus(USER_ID, StatusEnum.NORMAL)) + .thenReturn(Arrays.asList(schoolAdminRel(SCHOOL_A), schoolAdminRel(SCHOOL_B))); + } + + private AccessScope compute() { + // computeScope(Integer) 为包内可见:绕过 UserContext 直接单测判定逻辑 + return accessScopeService.computeScope(USER_ID); + } + + // ===================== 收敛到当前激活身份 ===================== + + /** 当前身份在小学 → 范围只有小学(即使此人也是中学管理员) */ + @Test + void computeScope_whenCurrentRoleIsSchoolB_shouldNarrowToSchoolB() { + givenTwoSchoolAdmin(); + when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.of(currentRole(SCHOOL_B))); + + AccessScope scope = compute(); + + assertEquals(AccessLevelEnum.SCHOOL, scope.getLevel()); + assertEquals(Collections.singletonList(SCHOOL_B), scope.schoolGroupIds()); + } + + /** 无切换记录 → 回落本职主校(主组在小学下 → 只保留小学) */ + @Test + void computeScope_whenNoCurrentRole_shouldFallbackToMainSchool() { + givenTwoSchoolAdmin(); + when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(mainGroupResolver.resolve(USER_ID)).thenReturn(Optional.of(groupRel(GROUP_A_TEACHER, 1))); + + AccessScope scope = compute(); + + assertEquals(AccessLevelEnum.SCHOOL, scope.getLevel()); + assertEquals(Collections.singletonList(SCHOOL_A), scope.schoolGroupIds()); + } + + // ===================== 非法/脏记录的兜底 ===================== + + /** 当前身份指向非管理学校(如切到老师身份的别校)→ 不认,回落主校 */ + @Test + void computeScope_whenCurrentRoleSchoolNotAdministered_shouldFallbackToMainSchool() { + givenTwoSchoolAdmin(); + when(sysUserCurrentRoleRepository.findByUserId(USER_ID)) + .thenReturn(Optional.of(currentRole(999))); + when(mainGroupResolver.resolve(USER_ID)).thenReturn(Optional.of(groupRel(GROUP_A_TEACHER, 1))); + + assertEquals(Collections.singletonList(SCHOOL_A), compute().schoolGroupIds()); + } + + /** 当前身份指向区域组(局端身份)→ 不属于其可管理学校,回落主校 */ + @Test + void computeScope_whenCurrentRoleIsAreaGroup_shouldFallbackToMainSchool() { + givenTwoSchoolAdmin(); + when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.of(currentRole(AREA_ID))); + when(mainGroupResolver.resolve(USER_ID)).thenReturn(Optional.of(groupRel(GROUP_A_TEACHER, 1))); + + assertEquals(Collections.singletonList(SCHOOL_A), compute().schoolGroupIds()); + } + + /** 主组链上没有学校组(如挂在区域组下)→ 解析不出主校,保留历史宽口径而非降级为空 */ + @Test + void computeScope_whenMainChainHasNoSchool_shouldKeepAllAdminSchools() { + givenTwoSchoolAdmin(); + when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(mainGroupResolver.resolve(USER_ID)).thenReturn(Optional.of(groupRel(AREA_ID, 1))); + + AccessScope scope = compute(); + + assertEquals(AccessLevelEnum.SCHOOL, scope.getLevel()); + assertTrue(scope.schoolGroupIds().containsAll(Arrays.asList(SCHOOL_A, SCHOOL_B))); + } + + /** 无任何组关系且无切换记录 → 同样保留历史宽口径(不误降为 NONE) */ + @Test + void computeScope_whenNoGroupRel_shouldKeepAllAdminSchools() { + givenTwoSchoolAdmin(); + when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + when(mainGroupResolver.resolve(USER_ID)).thenReturn(Optional.empty()); + + assertTrue(compute().schoolGroupIds().containsAll(Arrays.asList(SCHOOL_A, SCHOOL_B))); + } + + /** 主校不在其管理学校内(主组在别校)→ 不认主校,保留历史宽口径 */ + @Test + void computeScope_whenMainSchoolNotAdministered_shouldKeepAllAdminSchools() { + givenTwoSchoolAdmin(); + when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.empty()); + // 主组挂在区域组下(非学校)→ resolveMainSchoolId 解析不出学校 → 宽口径 + when(mainGroupResolver.resolve(USER_ID)).thenReturn(Optional.of(groupRel(AREA_ID, 1))); + + assertEquals(2, compute().schoolGroupIds().size()); + } + + // ===================== 不受影响的分支 ===================== + + /** 超管:SUPER 且不收敛(不查身份行) */ + @Test + void computeScope_whenSuperAdmin_shouldStaySuper() { + SysUser superUser = new SysUser(); + superUser.setId(USER_ID); + superUser.setType(UserTypeEnum.SUPER_ADMIN); + when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(superUser)); + + AccessScope scope = compute(); + + assertTrue(scope.isSuper()); + assertFalse(scope.isNone()); + } + + /** 身份行不是管理员角色(如仅老师身份)→ 不进 SCHOOL 级 */ + @Test + void computeScope_whenNoAdminIdentityRow_shouldNotBeSchoolLevel() { + when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(normalUser())); + SysUserRoleRel teacher = new SysUserRoleRel(); + teacher.setUserId(USER_ID); + teacher.setRoleId(SysRoleEnum.TEACHER.getKey()); + teacher.setSchoolId(SCHOOL_A); + when(sysUserRoleRelRepository.findByUserIdAndStatus(USER_ID, StatusEnum.NORMAL)) + .thenReturn(Collections.singletonList(teacher)); + when(mainGroupResolver.resolve(anyInt())).thenReturn(Optional.empty()); + when(sysUserRoleRelRepository.findByUserIdInAndStatus(any(), any())).thenReturn(Collections.emptyList()); + + assertFalse(compute().getLevel() == AccessLevelEnum.SCHOOL); + } +}