From c790187f354c2353103f1fb2c3660724573465ca Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 12 Oct 2026 14:23:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=A6=E6=A0=A1=E5=BA=94=E7=94=A8=E5=BC=80?= =?UTF-8?q?=E9=80=9A=E7=A1=AC=E9=97=B8=E9=97=A8=EF=BC=9A=E5=8D=95=E7=82=B9?= =?UTF-8?q?=E5=89=8D=E7=BD=AE=E9=97=B8=E9=97=A8+=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8/authorized-codes=20=E6=94=B6=E7=AA=84?= =?UTF-8?q?=EF=BC=88D1=20=E4=BF=9D=E8=B6=85=E7=AE=A1=20bypass=E3=80=81D2?= =?UTF-8?q?=20=E6=94=B6=E6=95=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GroupAuthzService: findUserModulesForApp 超管短路后插生效学校单点开通前置闸门;新增 findUserAuthorizedAppIds(userId,schoolId) 收窄重载 - AppCategoryService.listBoaAppType 改用收窄口径;authorized-codes 端点迁至 (userId,null) 走当前身份校兜底 - 测试: 新增 GroupAuthzServiceSchoolGateTest,适配 ModuleTree/Alias/BaseFilter/AuthorizedCodes mock 签名 --- .../controller/oapi/AppController.java | 12 +- .../userbase/service/IAppCategoryService.java | 4 +- .../userbase/service/IGroupAuthzService.java | 24 +- .../service/impl/AppCategoryService.java | 5 +- .../service/impl/GroupAuthzService.java | 66 ++++ .../AppControllerAuthorizedCodesTest.java | 14 +- .../AppCategoryServiceBaseAppFilterTest.java | 8 +- .../AppCategoryServiceSchoolAliasTest.java | 4 +- .../impl/GroupAuthzServiceModuleTreeTest.java | 23 +- .../impl/GroupAuthzServiceSchoolGateTest.java | 284 ++++++++++++++++++ 10 files changed, 423 insertions(+), 21 deletions(-) create mode 100644 src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceSchoolGateTest.java diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java index d59a6a7..b5b552d 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/AppController.java @@ -79,8 +79,9 @@ public class AppController { /** * 查询用户有权应用的 app_code 集合(消息跳转判权,C1)。
- * 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用, - * 超级管理员返回全部),再映射为 app_code(空值行跳过)。只读,不透出 appSecret。
+ * 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}(2026-10-12 硬闸门 D2 收敛: + * 走生效学校锚点单点开通集合,不沿祖先、角色不补充未开通应用,超级管理员返回全部), + * 再映射为 app_code(空值行跳过)。只读,不透出 appSecret。
* 消费方(UserHome 消息代理)以「集合是否包含消息 jump_app」决定是否显示跳转入口; * 本端点不可达时消费方放行(不判权),绝不因判权通道故障批量隐藏入口。 * @@ -89,7 +90,7 @@ public class AppController { */ @GetMapping("authorized-codes") public List 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 { /** * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。
- * 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用), - * 超级管理员返回全部;不透出 appSecret。
+ * 权限过滤(2026-10-12 学校单点开通硬闸门)复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)} + * ——生效学校锚点单点开通的应用集合(不沿祖先、角色授权不再补充学校未开通的应用),超级管理员返回全部; + * 不透出 appSecret。
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5); * 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。 diff --git a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java index f627c7f..c3bd60e 100644 --- a/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/IAppCategoryService.java @@ -39,7 +39,9 @@ public interface IAppCategoryService { * 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组), * 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。
* 规则:
- * ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});
+ * ① 权限过滤(2026-10-12 学校单点开通硬闸门):仅返回生效学校锚点单点开通的应用 + * (复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)} 收窄口径, + * 不沿祖先链、角色授权不补充未开通应用;超管保留全量 bypass);
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);
* ③ 不透出 appSecret;
* ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖, diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java index f079625..dd38748 100644 --- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -151,6 +151,10 @@ public interface IGroupAuthzService { * (子树内无归属组则仅靠角色授权,不用 schId 冒充本组);schId 为空=取用户全部真实归属组 * (与权限一览「用户全局模式」一致)。
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。
+ * 学校单点开通硬闸门(2026-10-12):超管短路之后、归属组解析之前,先判定目标应用是否在 + * 生效学校锚点自身的开通集合({@link #findAuthorizedAppIds(Integer)} 口径,不沿祖先、不并角色); + * 未开通直接返回空列表——角色授权与祖先组开通均不得绕过。生效锚点=显式 schId(过 assertSchoolId) + * → 当前激活身份学校 → 主归属组向上最近锚点;均解析不到=空。
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。
*
* 树形语义(2026-09-15 定稿):顶层只放真根(parentId 为空),兄弟按 customId 升序,叶子 children 为空列表; @@ -209,13 +213,31 @@ public interface IGroupAuthzService { void setGroupModulePerms(List items); /** - * 查询用户有权访问的应用ID集合(oapi 开放接口 getUserApp 权限过滤用)。
+ * 查询用户有权访问的应用ID集合(历史全局口径:组开通沿祖先 ∪ 启用角色模块所属应用,超管全部)。
* 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:
* - 超级管理员:全部应用;
* - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。
+ *
+ * ⚠️ 2026-10-12 学校单点开通硬闸门后,用户端应用可见性(门户列表 / authorized-codes 判权)一律走学校收窄重载 + * {@link #findUserAuthorizedAppIds(Integer, Integer)};本方法现仅由该重载的超管分支内部委托, + * 不再作为对外应用可见性口径。 * * @param userId 用户ID * @return 有权应用ID列表(无则空列表) */ List findUserAuthorizedAppIds(Integer userId); + + /** + * 按生效学校收窄的用户有权应用集合(2026-10-12 学校单点开通硬闸门,门户应用列表链口径)。
+ * 语义 = 生效学校锚点单点开通的应用集合(sys_group_app_rel 本组,不沿祖先、不并角色模块):
+ * - 超级管理员:保留 bypass,返回全部应用(同无学校版口径);
+ * - schoolId 非空:过 assertSchoolId 锚点校验后按该校自身单点判定;
+ * - schoolId 为空:按「当前激活身份学校 → 主归属组向上最近锚点(学校/区域/根)」兜底, + * 区域/根锚点按该锚点自身单点判定;均解析不到(无学校语境)→ 空列表。 + * + * @param userId 用户ID(必填) + * @param schoolId 学校上下文(可选;null 走身份/主归属兜底) + * @return 生效学校单点开通的应用ID列表(无则空列表) + */ + List findUserAuthorizedAppIds(Integer userId, Integer schoolId); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java index c0b9bf8..825f636 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppCategoryService.java @@ -198,7 +198,10 @@ public class AppCategoryService implements IAppCategoryService { public List listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) { // 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并 Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId); - Set authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId)); + // 权限过滤走学校单点开通收窄口径(2026-10-12 硬闸门):生效锚点自身开通的应用集合, + // 角色授权不再补充「学校未开通」的应用;锚点解析不到(无学校语境)= 空集合。 + Set authorizedAppIds = + new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId, effectiveSchool)); List categories = appCategoryRepository.findAllByOrderBySortNoAsc(); List apps = applicationInfoRepository.findAll(); apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo)) 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 c9abbae..03b8d2b 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -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 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)); } + /** + * 学校单点开通硬闸门判定:应用是否落在「生效锚点」自身的开通集合内。
+ * 闸门查询走 {@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} 同口径的只读版):
+ * ① 显式 schId → assertSchoolId 锚点校验后直接使用(不校验用户归属,越权收窄由闸门自身完成);
+ * ② null → 当前激活身份学校(sys_user_current_role);
+ * ③ 仍无 → 主归属组向上最近学校/区域/根锚点;均无(平台账号无组关系)→ 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集合(角色授权并集专用的"应用过滤版")。
* 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块; @@ -1986,4 +2032,24 @@ public class GroupAuthzService implements IGroupAuthzService { } return new ArrayList<>(appIds); } + + @Override + public List 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); + } } diff --git a/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerAuthorizedCodesTest.java b/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerAuthorizedCodesTest.java index a8c1c11..a07744b 100644 --- a/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerAuthorizedCodesTest.java +++ b/src/test/java/com/yunhai/userbase/controller/oapi/AppControllerAuthorizedCodesTest.java @@ -19,9 +19,9 @@ import static org.mockito.Mockito.when; /** * /oapi/app/authorized-codes 判权端点单测(消息跳转收口 C1)。
- * 控制器薄委托:判权来源 {@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()); } } diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceBaseAppFilterTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceBaseAppFilterTest.java index 933cb1f..b59ba85 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceBaseAppFilterTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceBaseAppFilterTest.java @@ -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 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 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()); } diff --git a/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java index 3bab450..42a1fe2 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/AppCategoryServiceSchoolAliasTest.java @@ -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)); diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java index e9727de..df3eb53 100644 --- a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java +++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java @@ -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) 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) 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 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))); } diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceSchoolGateTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceSchoolGateTest.java new file mode 100644 index 0000000..16f78ef --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceSchoolGateTest.java @@ -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 的服务层语义: + *
    + *
  • V1 角色授权不得绕过学校未开通(模块链返回空、应用集合不含);
  • + *
  • V2 祖先组开通不得顶替学校单点(上级开了、本校没开 → 仍空);
  • + *
  • V3 学校单点开通即恢复(本校开通 → 模块/应用集合放行);
  • + *
  • V4 应用列表收窄口径 = 生效学校单点集合(不并角色/祖先);
  • + *
  • V6 超级管理员保留 bypass(不受学校闸门约束)。
  • + *
+ * 基准用例:云海小学(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) 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 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) 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) 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 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 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; + } +}