feat(access): 数据范围收敛到当前激活身份所在学校 + 「本组」备注规则修正
build / build-push (push) Successful in 1m17s

问题1 学校管理员多校并存:SCHOOL 级判定原先收拢该管理员名下所有学校
(多校 = 多棵子树并集),导致一个在云海小学+云海中学都有管理员身份的账号
左侧组织树出现两个学校根。

- AccessScopeService.computeScope:SCHOOL 分支优先取 sys_user_current_role
  的 schoolId(须命中「该用户确有 {校领导,管理员} 正常态身份行且组为学校组」白名单,
  一并挡住脏记录指向别校、切到非管理身份两类越权)→ 无记录/不合法回落本职主校
  → 都解析不出才保留历史宽口径(范围为空会误降 NONE 被整体 403,宁宽勿窄)
- 落在 scopeGroupIds 上游:树/列表/搜索/导入导出/写口 guard 一起收敛,
  避免「树里看不到但能搜到、能跨校写」的半修复
- 新增 AccessScopeServiceTest 9 例(该类此前无测试):当前身份收敛/回落主校/
  脏记录与区域组回落/主组链无学校的宽口径/超管不受影响/无管理身份行不进 SCHOOL
- 类注释与《管理端权限分级改造方案》§1/§5/§6/§8 同步修订留痕(原写明与当前身份无关)

问题3 权限一览「(本组)」备注:撤销上一版「上下文组覆盖主组」的做法,
改为 own = 用户归属组数>1 且节点为主组——单组用户不再带备注,多组用户
只有本职组带标记(兼职组不带),切换查看时才具备区分意义。
This commit is contained in:
chenxing
2026-09-16 19:13:46 +08:00
parent 30b4b0504a
commit 8799d72d47
3 changed files with 315 additions and 5 deletions
@@ -1332,12 +1332,14 @@ public class GroupAuthzService implements IGroupAuthzService {
if (selfGroupIds.isEmpty()) { if (selfGroupIds.isEmpty()) {
return Collections.emptyList(); return Collections.emptyList();
} }
// 1.5 上下文收窄:指定组且用户确属该组时只画该组链路(权限一览按行查看「用户×组」对); // 「本组」备注只在用户归属多个组时才有区分意义(单组用户整棵树都是他的组,备注冗余);
// 「本组」标记同步指向该组——主组可能在被收窄掉的链路上,沿用主组会导致整棵树没有「本组」 // 需在收窄前统计,故单独记一份
boolean multiGroup = selfGroupIds.size() > 1;
// 1.5 上下文收窄:指定组且用户确属该组时只画该组链路(权限一览按行查看「用户×组」对)。
// 「本组」标记不跟随上下文组:兼职组不标,只有本职主组才标——否则切到哪个组都显示「本组」
if (contextGroupId != null && selfGroupIds.contains(contextGroupId)) { if (contextGroupId != null && selfGroupIds.contains(contextGroupId)) {
selfGroupIds = new LinkedHashSet<>(); selfGroupIds = new LinkedHashSet<>();
selfGroupIds.add(contextGroupId); selfGroupIds.add(contextGroupId);
mainGroupId = contextGroupId;
} }
// 2. 祖先链并集(含自身);多个归属组共享祖先时只出现一次 // 2. 祖先链并集(含自身);多个归属组共享祖先时只出现一次
Map<Integer, SysGroup> allGroupMap = buildAllGroupMap(); Map<Integer, SysGroup> allGroupMap = buildAllGroupMap();
@@ -1360,7 +1362,7 @@ public class GroupAuthzService implements IGroupAuthzService {
UserGroupNodeVo vo = new UserGroupNodeVo(); UserGroupNodeVo vo = new UserGroupNodeVo();
vo.setId(id); vo.setId(id);
vo.setName(g == null || g.getName() == null ? String.valueOf(id) : g.getName()); 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); voMap.put(id, vo);
} }
List<UserGroupNodeVo> roots = new ArrayList<>(); List<UserGroupNodeVo> roots = new ArrayList<>();
@@ -4,6 +4,7 @@ import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext; import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserCurrentRole;
import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.SysUserRoleRel; import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.AccessLevelEnum; 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.SysRoleEnum;
import com.yunhai.userbase.enums.UserTypeEnum; import com.yunhai.userbase.enums.UserTypeEnum;
import com.yunhai.userbase.repository.SysGroupRepository; import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository; import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository; import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.SysUserRoleRelRepository; import com.yunhai.userbase.repository.SysUserRoleRelRepository;
@@ -36,7 +38,10 @@ import java.util.Set;
* <li>SUPER:sys_user.type=1(内置超管账号)或基石账号 id=100000,行为与现状完全一致;</li> * <li>SUPER:sys_user.type=1(内置超管账号)或基石账号 id=100000,行为与现状完全一致;</li>
* <li>SCHOOL:sys_user_role_rel 存在正常态身份行 roleId∈{2 校领导, 7 管理员} 且其 schoolId * <li>SCHOOL:sys_user_role_rel 存在正常态身份行 roleId∈{2 校领导, 7 管理员} 且其 schoolId
* 对应组类型为学校组(schoolId 恒为真实组 id,局管理员的 schoolId=区域组,必须校验组类型 * 对应组类型为学校组(schoolId 恒为真实组 id,局管理员的 schoolId=区域组,必须校验组类型
* 防止误判)。与当前激活身份无关,不看 sys_user_current_role;</li> * 防止误判)。<b>范围收敛到「当前激活身份」所在学校</b>(sys_user_current_role →
* userhome 切换身份时写入;无记录或记录不合法回落本职主校)——多校管理员同一时刻只管理
* 一所学校,要管理另一所须先切换身份;见 {@link #computeScope} 2.1/2.2;
* </li>
* <li>BUREAU:主归属组链(主组 → 根)含区域组且不含学校组,不限角色;</li> * <li>BUREAU:主归属组链(主组 → 根)含区域组且不含学校组,不限角色;</li>
* <li>NONE:其余(登录用户但无任何管理面)。</li> * <li>NONE:其余(登录用户但无任何管理面)。</li>
* </ol> * </ol>
@@ -75,6 +80,9 @@ public class AccessScopeService {
@Autowired @Autowired
private SysGroupRepository sysGroupRepository; private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
@Autowired @Autowired
private MainGroupResolver mainGroupResolver; private MainGroupResolver mainGroupResolver;
@@ -132,6 +140,20 @@ public class AccessScopeService {
} }
} }
if (!schoolGroupIds.isEmpty()) { 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); return AccessScope.school(schoolGroupIds);
} }
// 3) BUREAU:主归属组链含区域组且不含学校组(不限角色) // 3) BUREAU:主归属组链含区域组且不含学校组(不限角色)
@@ -171,6 +193,41 @@ public class AccessScopeService {
return DicUtil.SUPER_ADMIN_ID.equals(userId); return DicUtil.SUPER_ADMIN_ID.equals(userId);
} }
/**
* 当前激活身份所在学校(sys_user_current_role):仅当该记录指向的 schoolId 属于
* {@code adminSchoolIds}(即用户确有 {2 校领导, 7 管理员} 正常态身份行且该组为学校组)时采用,
* 借此一次性挡住「脏记录指向别校」「切到非管理身份(如老师身份在另一校)」两类越权。<br>
* 无切换记录 / 记录非法 → null,由调用方回落主校。
*/
private Integer resolveActiveSchoolId(Integer userId, List<Integer> 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 最小)沿祖先链找最近的学校组,且该校须在其可管理学校内。<br>
* 解析不出(无组关系 / 主组不在学校内 / 主校非其管理学校)→ null,由调用方保留历史宽口径。
*/
private Integer resolveMainSchoolId(Integer userId, List<Integer> adminSchoolIds, Map<Integer, SysGroup> 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;
}
// ===================== 范围展开 ===================== // ===================== 范围展开 =====================
/** /**
@@ -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 级「范围收敛到当前激活身份所在学校」。<br>
* 规则:优先 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);
}
}