学校应用开通硬闸门:单点前置闸门+应用列表/authorized-codes 收窄(D1 保超管 bypass、D2 收敛)
build / build-push (push) Successful in 54s

- GroupAuthzService: findUserModulesForApp 超管短路后插生效学校单点开通前置闸门;新增 findUserAuthorizedAppIds(userId,schoolId) 收窄重载
- AppCategoryService.listBoaAppType 改用收窄口径;authorized-codes 端点迁至 (userId,null) 走当前身份校兜底
- 测试: 新增 GroupAuthzServiceSchoolGateTest,适配 ModuleTree/Alias/BaseFilter/AuthorizedCodes mock 签名
This commit is contained in:
chenxing
2026-10-12 14:23:05 +08:00
parent d7d239e77c
commit c790187f35
10 changed files with 423 additions and 21 deletions
@@ -79,8 +79,9 @@ public class AppController {
/**
* 查询用户有权应用的 app_code 集合(消息跳转判权,C1)。<br>
* 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用,
* 超级管理员返回全部),再映射为 app_code(空值行跳过)。只读,不透出 appSecret。<br>
* 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}(2026-10-12 硬闸门 D2 收敛:
* 走生效学校锚点单点开通集合,不沿祖先、角色不补充未开通应用,超级管理员返回全部),
* 再映射为 app_code(空值行跳过)。只读,不透出 appSecret。<br>
* 消费方(UserHome 消息代理)以「集合是否包含消息 jump_app」决定是否显示跳转入口;
* 本端点不可达时消费方放行(不判权),绝不因判权通道故障批量隐藏入口。
*
@@ -89,7 +90,7 @@ public class AppController {
*/
@GetMapping("authorized-codes")
public List<String> authorizedCodes(@RequestParam("userId") Integer userId) {
return applicationInfoService.listAppCodesByIds(groupAuthzService.findUserAuthorizedAppIds(userId));
return applicationInfoService.listAppCodesByIds(groupAuthzService.findUserAuthorizedAppIds(userId, null));
}
private ApplicationInfoVo toVo(ApplicationInfo info) {
@@ -105,8 +106,9 @@ public class AppController {
/**
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br>
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
* 超级管理员返回全部;不透出 appSecret。<br>
* 权限过滤(2026-10-12 学校单点开通硬闸门)复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)}
* ——生效学校锚点单点开通的应用集合(不沿祖先、角色授权不再补充学校未开通的应用),超级管理员返回全部;
* 不透出 appSecret。<br>
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br>
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
* 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
@@ -39,7 +39,9 @@ public interface IAppCategoryService {
* 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组),
* 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。<br>
* 规则:<br>
* ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});<br>
* ① 权限过滤(2026-10-12 学校单点开通硬闸门):仅返回<b>生效学校锚点单点开通</b>的应用
* (复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)} 收窄口径,
* 不沿祖先链、角色授权不补充未开通应用;超管保留全量 bypass);<br>
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);<br>
* ③ 不透出 appSecret;<br>
* ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖,
@@ -151,6 +151,10 @@ public interface IGroupAuthzService {
* (子树内无归属组则仅靠角色授权,不用 schId 冒充本组);schId 为空=取用户全部真实归属组
* (与权限一览「用户全局模式」一致)。<br>
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。<br>
* 学校单点开通硬闸门(2026-10-12):超管短路之后、归属组解析之前,先判定目标应用是否在
* <b>生效学校锚点自身</b>的开通集合({@link #findAuthorizedAppIds(Integer)} 口径,不沿祖先、不并角色);
* 未开通直接返回空列表——角色授权与祖先组开通均不得绕过。生效锚点=显式 schId(过 assertSchoolId)
* → 当前激活身份学校 → 主归属组向上最近锚点;均解析不到=空。<br>
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。<br>
* <br>
* 树形语义(2026-09-15 定稿):顶层只放真根(parentId 为空),兄弟按 customId 升序,叶子 children 为空列表;
@@ -209,13 +213,31 @@ public interface IGroupAuthzService {
void setGroupModulePerms(List<GroupModuleAuthzItem> items);
/**
* 查询用户有权访问的应用ID集合(oapi 开放接口 getUserApp 权限过滤用)。<br>
* 查询用户有权访问的应用ID集合(历史全局口径:组开通沿祖先 ∪ 启用角色模块所属应用,超管全部)。<br>
* 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:<br>
* - 超级管理员:全部应用;<br>
* - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。<br>
* <br>
* ⚠️ 2026-10-12 学校单点开通硬闸门后,用户端应用可见性(门户列表 / authorized-codes 判权)一律走学校收窄重载
* {@link #findUserAuthorizedAppIds(Integer, Integer)};本方法现仅由该重载的超管分支内部委托,
* 不再作为对外应用可见性口径。
*
* @param userId 用户ID
* @return 有权应用ID列表(无则空列表)
*/
List<Integer> findUserAuthorizedAppIds(Integer userId);
/**
* 按生效学校收窄的用户有权应用集合(2026-10-12 学校单点开通硬闸门,门户应用列表链口径)。<br>
* 语义 = <b>生效学校锚点单点开通的应用集合</b>(sys_group_app_rel 本组,不沿祖先、不并角色模块):<br>
* - 超级管理员:保留 bypass,返回全部应用(同无学校版口径);<br>
* - schoolId 非空:过 assertSchoolId 锚点校验后按该校自身单点判定;<br>
* - schoolId 为空:按「当前激活身份学校 → 主归属组向上最近锚点(学校/区域/根)」兜底,
* 区域/根锚点按该锚点自身单点判定;均解析不到(无学校语境)→ 空列表。
*
* @param userId 用户ID(必填)
* @param schoolId 学校上下文(可选;null 走身份/主归属兜底)
* @return 生效学校单点开通的应用ID列表(无则空列表)
*/
List<Integer> findUserAuthorizedAppIds(Integer userId, Integer schoolId);
}
@@ -198,7 +198,10 @@ public class AppCategoryService implements IAppCategoryService {
public List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) {
// 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并
Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId);
Set<Integer> authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId));
// 权限过滤走学校单点开通收窄口径(2026-10-12 硬闸门):生效锚点自身开通的应用集合,
// 角色授权不再补充「学校未开通」的应用;锚点解析不到(无学校语境)= 空集合。
Set<Integer> authorizedAppIds =
new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId, effectiveSchool));
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
List<ApplicationInfo> apps = applicationInfoRepository.findAll();
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
@@ -2,6 +2,7 @@ package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.entity.*;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.platform.utils.enums.IEnum;
@@ -127,6 +128,10 @@ public class GroupAuthzService implements IGroupAuthzService {
@Autowired
private ISysGroupService sysGroupService;
/** 学校单点开通闸门的生效锚点解析(当前激活身份校兜底,与 AppCategoryService.resolveEffectiveSchool 同口径)。 */
@Autowired
private IdentityService identityService;
@Autowired
private ApplicationEventPublisher eventPublisher;
@@ -558,6 +563,12 @@ public class GroupAuthzService implements IGroupAuthzService {
List<AppModule> allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
return assembleModuleTree(allModules);
}
// 2.5 学校单点开通硬闸门(2026-10-12):生效锚点(显式 schId,或按「当前身份校→主归属校」兜底)
// 未在自身 sys_group_app_rel 开通该应用 → 直接空,角色授权与祖先组开通均不得绕过。
// 区域/根锚点按该锚点自身单点判定(不沿祖先);解析不到锚点=无学校语境=无应用。
if (!isAppOpenedAtEffectiveSchool(appId, schId, userId)) {
return Collections.emptyList();
}
// 3. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
// - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身),
// 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块,
@@ -653,6 +664,41 @@ public class GroupAuthzService implements IGroupAuthzService {
return assembleModuleTreeById(permittedModuleIds, buildAppModuleMap(appModules));
}
/**
* 学校单点开通硬闸门判定:应用是否落在「生效锚点」自身的开通集合内。<br>
* 闸门查询走 {@link #findAuthorizedAppIds(Integer)}(本组单点、不沿祖先、不并角色),
* 区域/根锚点同样按该锚点自身判定;显式 schId 先过 {@code assertSchoolId} 防误传非法 id。
*/
private boolean isAppOpenedAtEffectiveSchool(Integer appId, Integer schId, Integer userId) {
Integer anchor = resolveEffectiveSchoolAnchor(schId, userId);
if (anchor == null) {
return false;
}
return findAuthorizedAppIds(anchor).contains(appId);
}
/**
* 生效学校锚点解析(与 {@code AppCategoryService.resolveEffectiveSchool} 同口径的只读版):<br>
* ① 显式 schId → assertSchoolId 锚点校验后直接使用(不校验用户归属,越权收窄由闸门自身完成);<br>
* ② null → 当前激活身份学校(sys_user_current_role);<br>
* ③ 仍无 → 主归属组向上最近学校/区域/根锚点;均无(平台账号无组关系)→ null(无学校语境)。
*/
private Integer resolveEffectiveSchoolAnchor(Integer schId, Integer userId) {
if (schId != null) {
sysGroupService.assertSchoolId(schId);
return schId;
}
BoaRoleInfo current = identityService.getCurrent(userId);
if (current != null && current.getSchId() != null) {
return current.getSchId();
}
SysUserGroupRel main = mainGroupResolver.resolve(userId).orElse(null);
if (main == null || main.getGroupId() == null) {
return null;
}
return sysGroupService.resolveSchoolId(main.getGroupId());
}
/**
* 收集用户启用角色下=且属于目标应用 appId 的模块ID集合(角色授权并集专用的"应用过滤版")。<br>
* 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块;
@@ -1986,4 +2032,24 @@ public class GroupAuthzService implements IGroupAuthzService {
}
return new ArrayList<>(appIds);
}
@Override
public List<Integer> findUserAuthorizedAppIds(Integer userId, Integer schoolId) {
Objects.requireNonNull(userId, "用户ID不能为空");
SysUser user = sysUserRepository.findById(userId).orElse(null);
if (user == null) {
throw new DException("用户不存在");
}
// 超级管理员:保留 bypass(D1 默认,基石账号平台级不绑定单一学校),口径与无学校版一致
if (isSuperAdmin(user.getType(), userId)) {
return findUserAuthorizedAppIds(userId);
}
// 学校单点开通口径(2026-10-12 硬闸门):生效锚点自身开通的应用集合,
// 不沿祖先链、不并入角色模块所属应用(角色授权不再补充「学校未开通」的应用)。
Integer anchor = resolveEffectiveSchoolAnchor(schoolId, userId);
if (anchor == null) {
return Collections.emptyList();
}
return findAuthorizedAppIds(anchor);
}
}
@@ -19,9 +19,9 @@ import static org.mockito.Mockito.when;
/**
* /oapi/app/authorized-codes 判权端点单测(消息跳转收口 C1)。<br>
* 控制器薄委托:判权来源 {@code IGroupAuthzService.findUserAuthorizedAppIds}(超管返回全部 /
* 普通用户返回子集 / 空权限返回空),数据面 {@code listAppCodesByIds} 映射 app_code
* (空值跳过/去重语义见 ApplicationInfoLookupTest)。
* 控制器薄委托:判权来源收窄重载 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}
* (2026-10-12 硬闸门 D2 收敛,走当前身份校兜底;超管返回全部 / 普通用户返回子集 / 空权限返回空),
* 数据面 {@code listAppCodesByIds} 映射 app_code(空值跳过/去重语义见 ApplicationInfoLookupTest)。
*/
@DisplayName("AppController.authorizedCodes - 有权 app_code 集合")
class AppControllerAuthorizedCodesTest {
@@ -42,7 +42,7 @@ class AppControllerAuthorizedCodesTest {
@Test
void 超管_返回全部应用code() {
when(groupAuthzService.findUserAuthorizedAppIds(10006)).thenReturn(Arrays.asList(100011, 100013, 100015));
when(groupAuthzService.findUserAuthorizedAppIds(10006, null)).thenReturn(Arrays.asList(100011, 100013, 100015));
when(applicationInfoService.listAppCodesByIds(Arrays.asList(100011, 100013, 100015)))
.thenReturn(Arrays.asList("flow-center", "notice", "userhome"));
@@ -54,7 +54,7 @@ class AppControllerAuthorizedCodesTest {
@Test
void 普通用户_仅返回有权子集() {
when(groupAuthzService.findUserAuthorizedAppIds(201)).thenReturn(Collections.singletonList(100013));
when(groupAuthzService.findUserAuthorizedAppIds(201, null)).thenReturn(Collections.singletonList(100013));
when(applicationInfoService.listAppCodesByIds(Collections.singletonList(100013)))
.thenReturn(Collections.singletonList("notice"));
@@ -63,12 +63,12 @@ class AppControllerAuthorizedCodesTest {
@Test
void 空权限_返回空集合() {
when(groupAuthzService.findUserAuthorizedAppIds(202)).thenReturn(Collections.emptyList());
when(groupAuthzService.findUserAuthorizedAppIds(202, null)).thenReturn(Collections.emptyList());
when(applicationInfoService.listAppCodesByIds(Collections.emptyList()))
.thenReturn(Collections.emptyList());
assertEquals(Collections.emptyList(), controller.authorizedCodes(202));
verify(groupAuthzService).findUserAuthorizedAppIds(202);
verify(groupAuthzService).findUserAuthorizedAppIds(202, null);
verify(applicationInfoService).listAppCodesByIds(Collections.emptyList());
}
}
@@ -91,8 +91,8 @@ class AppCategoryServiceBaseAppFilterTest {
app(2, 100001, "BASE", "用户之家"),
app(3, 100002, "BUSINESS", "通知公告"));
when(applicationInfoRepository.findAll()).thenReturn(apps);
// 超管分支:授权集合为全量(基石也在内)——正是 bug1 的触发条件
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Arrays.asList(1, 2, 3));
// 超管分支:授权集合为全量(基石也在内)——正是 bug1 的触发条件(收窄重载 null 锚点=超管 bypass)
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Arrays.asList(1, 2, 3));
List<BoaAppType> result = appCategoryService.listBoaAppType(1, null, null);
@@ -109,7 +109,7 @@ class AppCategoryServiceBaseAppFilterTest {
when(applicationInfoRepository.findAll()).thenReturn(Arrays.asList(
app(3, 100002, "BUSINESS", "通知公告"),
app(4, null, null, "老应用")));
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Arrays.asList(3, 4));
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Arrays.asList(3, 4));
List<BoaApp> kept = appCategoryService.listBoaAppType(1, null, null).get(0).getApps();
@@ -123,7 +123,7 @@ class AppCategoryServiceBaseAppFilterTest {
void unauthorizedBaseApp_stillNoneKept() {
when(applicationInfoRepository.findAll())
.thenReturn(new ArrayList<>(Collections.singletonList(app(2, 100001, "BASE", "用户之家"))));
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Collections.singletonList(2));
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Collections.singletonList(2));
assertEquals(0, appCategoryService.listBoaAppType(1, null, null).get(0).getApps().size());
}
@@ -31,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -93,7 +94,8 @@ class AppCategoryServiceSchoolAliasTest {
app.setName("通知公告");
app.setSortNo(1);
when(applicationInfoRepository.findAll()).thenReturn(Collections.singletonList(app));
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Collections.singletonList(100));
// 权限集合改用学校收窄重载(2026-10-12 硬闸门);本测试只断言生效学校解析,schoolId 传 null 覆盖
when(groupAuthzService.findUserAuthorizedAppIds(eq(1), any())).thenReturn(Collections.singletonList(100));
// 合并口透传第一参数(本测试只断言入参,合并行为见 SchoolAppOverrideServiceApplyAliasTest)
when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any()))
.thenAnswer(inv -> inv.getArgument(0));
@@ -98,6 +98,8 @@ class GroupAuthzServiceModuleTreeTest {
private com.yunhai.userbase.service.resolve.MainGroupResolver mainGroupResolver;
@Mock
private com.yunhai.userbase.service.ISysGroupService sysGroupService;
@Mock
private IdentityService identityService;
@BeforeEach
void setUp() {
@@ -240,6 +242,10 @@ class GroupAuthzServiceModuleTreeTest {
when(sysUserGroupRelRepository.findByUserId(2))
.thenReturn(Collections.singletonList(rel(2, 16)));
initGroups();
// 学校单点开通闸门:findAuthorizedAppIds(16) 走 findByGroupId 单点查询
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(sysGroupAppRelRepository.findByGroupId(16))
.thenReturn(Collections.singletonList(appRel(16, APP_ID)));
when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv ->
((List<Integer>) inv.getArgument(0)).contains(16)
? Collections.singletonList(appRel(16, APP_ID)) : Collections.emptyList());
@@ -270,6 +276,10 @@ class GroupAuthzServiceModuleTreeTest {
when(sysUserGroupRelRepository.findByUserId(3))
.thenReturn(Collections.singletonList(rel(3, 16)));
initGroups();
// 学校单点开通闸门:findAuthorizedAppIds(16) 走 findByGroupId 单点查询
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(sysGroupAppRelRepository.findByGroupId(16))
.thenReturn(Collections.singletonList(appRel(16, APP_ID)));
when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv ->
((List<Integer>) inv.getArgument(0)).contains(16)
? Collections.singletonList(appRel(16, APP_ID)) : Collections.emptyList());
@@ -313,6 +323,16 @@ class GroupAuthzServiceModuleTreeTest {
when(sysUserGroupRelRepository.findByUserId(4))
.thenReturn(Arrays.asList(rel(4, 29), rel(4, 40)));
initTwoSchoolGroups();
// 学校单点开通闸门:显式上下文 16/28 与 null 上下文(主归属组 29→学校 16 兜底)均须放行
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(sysGroupRepository.existsById(28)).thenReturn(true);
when(sysGroupAppRelRepository.findByGroupId(16))
.thenReturn(Collections.singletonList(appRel(16, APP_ID)));
when(sysGroupAppRelRepository.findByGroupId(28))
.thenReturn(Collections.singletonList(appRel(28, APP_ID)));
when(mainGroupResolver.resolve(4)).thenReturn(Optional.of(rel(4, 29)));
// null 上下文闸门兜底:主归属组 29 向上解析到学校 16(已开通),故放行进入角色取数
when(sysGroupService.resolveSchoolId(29)).thenReturn(16);
// 两校都开通该应用(组授权闸门打开;组侧不配模块,只测角色侧取数范围)
when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> {
List<Integer> ids = inv.getArgument(0);
@@ -386,7 +406,8 @@ class GroupAuthzServiceModuleTreeTest {
// B 校上下文:只带 B 校岗位角色(901 → 模块 101)的模块
assertEquals(Collections.singletonList(101),
customIds(groupAuthzService.findUserModulesForApp(APP_ID, 28, 4)));
// 无学校上下文(现状):两校绑定都生效
// 无学校上下文:闸门按主归属组(29)兜底解析到学校 16 放行(应用已开通),
// 角色取数仍全局模式 → 两校绑定都生效
assertEquals(Arrays.asList(100, 101),
customIds(groupAuthzService.findUserModulesForApp(APP_ID, null, 4)));
}
@@ -0,0 +1,284 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.userbase.entity.AppModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysGroupAppRel;
import com.yunhai.userbase.entity.SysGroupModulePerm;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
import com.yunhai.userbase.model.GroupSnapshot;
import com.yunhai.userbase.repository.AppModuleRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
import com.yunhai.userbase.repository.SysAppRoleRepository;
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserAppRoleRelRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
/**
* 学校单点开通硬闸门(2026-10-12)行为单元测试——直接编码验收方案 V1/V2/V3/V4/V6 的服务层语义:
* <ul>
* <li>V1 角色授权不得绕过学校未开通(模块链返回空、应用集合不含);</li>
* <li>V2 祖先组开通不得顶替学校单点(上级开了、本校没开 → 仍空);</li>
* <li>V3 学校单点开通即恢复(本校开通 → 模块/应用集合放行);</li>
* <li>V4 应用列表收窄口径 = 生效学校单点集合(不并角色/祖先);</li>
* <li>V6 超级管理员保留 bypass(不受学校闸门约束)。</li>
* </ul>
* 基准用例:云海小学(16) 未开通「通知公告」(APP_ID),用户 U=2 归属该校。纯内存桩,无 DB。
*/
@DisplayName("学校单点开通硬闸门 - GroupAuthzService")
class GroupAuthzServiceSchoolGateTest {
private static final int APP_ID = 100003;
private static final int SCHOOL_ID = 16;
@InjectMocks
private GroupAuthzService groupAuthzService;
@Mock
private GroupSnapshotService groupSnapshotService;
@Mock
private SysGroupAppRelRepository sysGroupAppRelRepository;
@Mock
private SysGroupModulePermRepository sysGroupModulePermRepository;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private IApplicationInfoService applicationInfoService;
@Mock
private AppModuleRepository appModuleRepository;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Mock
private SysUserRepository sysUserRepository;
@Mock
private SysAppRoleRepository sysAppRoleRepository;
@Mock
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
@Mock
private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@Mock
private MainGroupResolver mainGroupResolver;
@Mock
private ISysGroupService sysGroupService;
@Mock
private IdentityService identityService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
ApplicationInfo app = new ApplicationInfo();
app.setId(APP_ID);
app.setName("通知公告");
when(applicationInfoService.getById(APP_ID)).thenReturn(app);
// 组层级:5 教育云中心 → 27 教育局 → 16 云海小学
when(groupSnapshotService.snapshot()).thenReturn(new GroupSnapshot(Arrays.asList(
group(5, GroupTypeEnum.NORMAL_GROUP, 0),
group(27, GroupTypeEnum.AREA_GROUP, 5),
group(SCHOOL_ID, GroupTypeEnum.SCHOOL_GROUP, 27))));
when(sysGroupRepository.existsById(SCHOOL_ID)).thenReturn(true);
}
// ===================== V1:学校未开通 → 角色/组模块授权均不 bypass =====================
@Test
void schoolNotOpened_roleGrantedButModuleEmpty() {
normalUserInSchool(2);
// 学校 16 自身未开通(单点集合为空),但祖先链(27/5)开通该应用——闸门应只看本校单点
when(sysGroupAppRelRepository.findByGroupId(SCHOOL_ID)).thenReturn(Collections.emptyList());
when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv ->
((List<Integer>) inv.getArgument(0)).contains(27)
? Collections.singletonList(appRel(27, APP_ID)) : Collections.emptyList());
// 应用下有模块、且有组授权命中(若无闸门本应返回),用于证明被前置闸门清空
when(appModuleRepository.findByAppIdIn(anyList()))
.thenReturn(new ArrayList<>(Collections.singletonList(module(100, "100"))));
when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList()))
.thenReturn(Collections.singletonList(perm(SCHOOL_ID, 100)));
List<BoaModule> modules = groupAuthzService.findUserModulesForApp(APP_ID, SCHOOL_ID, 2);
assertTrue(modules.isEmpty(), "学校未开通时即便模块已授权也应被单点闸门清空");
assertTrue(groupAuthzService.findUserAuthorizedAppIds(2, SCHOOL_ID).isEmpty(),
"应用列表收窄口径同样不含未开通应用");
}
// ===================== V2:祖先开通不顶替学校单点(对照:本校未开→空)=====================
@Test
void ancestorOpenedButSchoolNot_stillEmpty() {
normalUserInSchool(2);
when(sysGroupAppRelRepository.findByGroupId(SCHOOL_ID)).thenReturn(Collections.emptyList());
// 仅上级 27 开通;collectAppIdsForAuthzScope(祖先并集)会命中,证明「非祖先链」口径生效
when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv ->
((List<Integer>) inv.getArgument(0)).contains(27)
? Collections.singletonList(appRel(27, APP_ID)) : Collections.emptyList());
when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(new ArrayList<>());
assertTrue(groupAuthzService.findUserModulesForApp(APP_ID, SCHOOL_ID, 2).isEmpty(),
"祖先开通不得顶替学校未开通的事实");
}
// ===================== V3:学校单点开通即恢复 =====================
@Test
void schoolOpened_moduleAndAppIdsRestored() {
normalUserInSchool(2);
when(sysGroupAppRelRepository.findByGroupId(SCHOOL_ID))
.thenReturn(Collections.singletonList(appRel(SCHOOL_ID, APP_ID)));
// 下游组模块判定仍按既有祖先并集口径(collectAppIdsForAuthzScope)取开通池
when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv ->
((List<Integer>) inv.getArgument(0)).contains(SCHOOL_ID)
? Collections.singletonList(appRel(SCHOOL_ID, APP_ID)) : Collections.emptyList());
AppModule parent = module(100, "100");
when(appModuleRepository.findByAppIdIn(anyList()))
.thenReturn(new ArrayList<>(Collections.singletonList(parent)));
when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList()))
.thenReturn(Collections.singletonList(perm(SCHOOL_ID, 100)));
List<BoaModule> modules = groupAuthzService.findUserModulesForApp(APP_ID, SCHOOL_ID, 2);
assertEquals(1, modules.size(), "本校开通后,已授权模块恢复下发");
assertEquals(Collections.singletonList(APP_ID),
groupAuthzService.findUserAuthorizedAppIds(2, SCHOOL_ID),
"应用列表恢复含本校开通的应用");
}
// ===================== V4:收窄口径 = 本校单点,不并角色/祖先 =====================
@Test
void narrowedAppIds_onlySchoolSinglePoint() {
normalUserInSchool(2);
// 本校只开通 APP_ID;(祖先/角色补充的其他应用不应出现——因不并 collectAppIdsForAuthzScope 与角色模块)
when(sysGroupAppRelRepository.findByGroupId(SCHOOL_ID))
.thenReturn(Collections.singletonList(appRel(SCHOOL_ID, APP_ID)));
List<Integer> result = groupAuthzService.findUserAuthorizedAppIds(2, SCHOOL_ID);
assertEquals(Collections.singletonList(APP_ID), result,
"收窄集合严格=本校单点开通,不含祖先/角色来源");
}
// ===================== V6:超级管理员保留 bypass =====================
@Test
void superAdmin_bypassesSchoolGate() {
int adminId = com.yunhai.userbase.utils.DicUtil.SUPER_ADMIN_ID;
SysUser admin = new SysUser();
admin.setId(adminId);
admin.setType(UserTypeEnum.SUPER_ADMIN);
when(sysUserRepository.findById(adminId)).thenReturn(Optional.of(admin));
// 学校 16 未开通该应用
when(sysGroupAppRelRepository.findByGroupId(SCHOOL_ID)).thenReturn(Collections.emptyList());
when(appModuleRepository.findByAppIdIn(anyList()))
.thenReturn(new ArrayList<>(Collections.singletonList(module(100, "100"))));
// 超管应用全集
ApplicationInfo app = new ApplicationInfo();
app.setId(APP_ID);
when(applicationInfoRepository.findAll()).thenReturn(Collections.singletonList(app));
assertEquals(1, groupAuthzService.findUserModulesForApp(APP_ID, SCHOOL_ID, adminId).size(),
"超管短路先于闸门,未开通学校仍返回全部模块");
assertEquals(Collections.singletonList(APP_ID), groupAuthzService.findUserAuthorizedAppIds(adminId, SCHOOL_ID),
"超管收窄重载委托全量口径");
}
// ===================== 生效学校兜底:null schId 走当前身份校 =====================
@Test
void nullSchool_fallsBackToCurrentIdentitySchool() {
normalUserInSchool(2);
BoaRoleInfo current = new BoaRoleInfo();
current.setSchId(SCHOOL_ID);
when(identityService.getCurrent(2)).thenReturn(current);
// 身份校(16)未开通 → 空(无学校语境/未开通即无应用)
when(sysGroupAppRelRepository.findByGroupId(SCHOOL_ID)).thenReturn(Collections.emptyList());
when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(new ArrayList<>());
assertEquals(Collections.emptyList(), groupAuthzService.findUserAuthorizedAppIds(2, null),
"schId 为空按当前身份校兜底判定,未开通返回空");
}
// ===================== 夹具 =====================
private void normalUserInSchool(int userId) {
SysUser u = new SysUser();
u.setId(userId);
u.setType(UserTypeEnum.NORMAL);
when(sysUserRepository.findById(userId)).thenReturn(Optional.of(u));
when(sysUserGroupRelRepository.findByUserId(userId))
.thenReturn(Collections.singletonList(rel(userId, SCHOOL_ID)));
when(sysUserAppRoleRelRepository.findByUserId(userId)).thenReturn(Collections.emptyList());
}
private static SysGroup group(Integer id, GroupTypeEnum type, Integer parentId) {
SysGroup g = new SysGroup();
g.setId(id);
g.setType(type);
g.setParentId(parentId);
return g;
}
private static SysUserGroupRel rel(Integer userId, Integer groupId) {
SysUserGroupRel r = new SysUserGroupRel();
r.setUserId(userId);
r.setGroupId(groupId);
r.setIsMain(1);
return r;
}
private static SysGroupAppRel appRel(Integer groupId, Integer appId) {
SysGroupAppRel r = new SysGroupAppRel();
r.setGroupId(groupId);
r.setAppId(appId);
return r;
}
private static SysGroupModulePerm perm(Integer groupId, Integer moduleId) {
SysGroupModulePerm p = new SysGroupModulePerm();
p.setGroupId(groupId);
p.setModuleId(moduleId);
p.setInheritFlag(Boolean.FALSE);
return p;
}
private static AppModule module(Integer id, String customId) {
AppModule m = new AppModule();
m.setId(id);
m.setAppId(APP_ID);
m.setName("模块" + customId);
m.setCustomId(customId);
m.setParentId(null);
m.setLevel(1);
m.setSortNo(1);
return m;
}
}