diff --git a/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java b/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java index a2dbaa7..f45e472 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/GroupAuthzController.java @@ -162,7 +162,8 @@ public class GroupAuthzController { /** * 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。
- * 非超管:目标用户归属组与数据范围无交集时返回空集合。 + * 非超管:目标用户归属组与数据范围无交集时返回空集合;且树按数据范围剪枝—— + * 学校管理员视角只展示到学校组(区域组/平台根不在其范围内,不再冒头到根节点)。 */ @GetMapping("/UserGroupTree") public List userGroupTree(@RequestParam Integer userId) { @@ -170,7 +171,18 @@ public class GroupAuthzController { && !accessScopeService.userIntersectsScope(userId)) { return Collections.emptyList(); } - return groupAuthzService.findUserGroupTree(userId); + return groupAuthzService.findUserGroupTree(userId, accessScopeService.scopeGroupIds()); + } + + /** + * 权限一览:查询组本身的有效已授权应用模块树(点击「所属部门」树节点联动右侧「已授权应用」面板)。
+ * 判定口径与组授权/权限一览的组侧一致(开通池=本组+祖先并集、就近原则、子组继承闸门、学校截止), + * 不并入用户角色授权;目标组须在当前管理端数据范围内。 + */ + @GetMapping("/GroupPermissionTree") + public List groupPermissionTree(@RequestParam Integer groupId) { + accessScopeService.requireGroupInScope(groupId); + return groupAuthzService.findGroupPermissionTree(groupId); } // ===================== 新增:树节点式增量授权(左侧树 + 右侧 appAuthzList 勾选交互) ===================== diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java index 7a07e9a..850d0c9 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java @@ -1,6 +1,7 @@ package com.yunhai.userbase.controller.oapi; import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; @@ -47,7 +48,7 @@ public class GroupController { * 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树)。 * * @param groupId 组ID(必填) - * @param withSub 是否展开子节点,缺省 false + * @param withSub 是否递归加上子节点,缺省 false * @return 组节点;不存在返回 null */ @GetMapping("byId") @@ -69,6 +70,24 @@ public class GroupController { return sysGroupService.getGroupList(ids, withSub); } + /** + * 获取组内用户(不分页,一次返回全部):
+ * withSub=true 时含该组全部子孙组(口径同 {@code /oapi/group/byId} 的 withSub);
+ * roleIds 逗号分隔(复用 SysRoleEnum 键),按 {@code sys_user.roles} 快照过滤,缺省不过滤。
+ * 返回裸 {@code List}(轻量字段 id/account/name/type),不套信封、无分页元数据。 + * + * @param groupId 组ID(必填) + * @param withSub 是否递归子组,缺省 false + * @param roleIds 角色过滤(逗号分隔),缺省不过滤 + * @return 组内用户列表(按用户去重;无匹配返回空列表) + */ + @GetMapping("users") + public List groupUsers(@RequestParam("groupId") Integer groupId, + @RequestParam(value = "withSub", required = false) Boolean withSub, + @RequestParam(value = "roleIds", required = false) String roleIds) { + return sysGroupService.listGroupUsers(groupId, withSub, roleIds); + } + /** * 新增组:返回创建后的组节点(含 id)。
* 层级类型约束、根节点保护等复用后台逻辑;鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。 diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java index 0a53aff..2c19c75 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserGroupRelRepository.java @@ -28,6 +28,9 @@ public interface SysUserGroupRelRepository extends JpaRepository findByGroupId(Integer groupId); + /** 按多个组ID批量查关联(组定向展开用,调用方自行对 user_id 去重)。 */ + List findByGroupIdIn(List groupIds); + void deleteByUserId(Integer userId); void deleteByUserIdAndGroupId(Integer userId, Integer groupId); diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java index 42a1aeb..cf1daae 100644 --- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -116,11 +116,25 @@ public interface IGroupAuthzService { * 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组), * 多个归属组时合并为一棵树(公共祖先只出现一次)。
+ * 数据范围剪枝:scopeGroupIds 非空时树节点只保留范围内的组——学校管理员视角 + * 区域组/根节点不在其范围内,学校组即成为展示树的根(不再冒头到平台根);null 表示不剪枝(超管)。 * - * @param userId 用户ID + * @param userId 用户ID + * @param scopeGroupIds 当前管理端数据范围组集合(null=不限制) * @return 所属部门树(根节点列表);用户无组归属时返回空列表 */ - List findUserGroupTree(Integer userId); + List findUserGroupTree(Integer userId, java.util.Set scopeGroupIds); + + /** + * 权限一览:查询组本身的有效已授权应用模块树(点击「所属部门」树节点联动右侧面板用)。
+ * 语义与 {@link #findUserPermissionTree(Integer, Integer)} 的组侧判定完全一致(应用开通池=本组+祖先链并集、 + * 就近原则、子组继承闸门、学校截止),但不并入任何用户角色授权——展示的是「这个组的组授权」,与用户无关:
+ * 点击教师组 → 学校已开通的应用 + 教师组有效模块(本组显式勾选,或最近配置祖先勾选且开启子组继承)。 + * + * @param groupId 组ID + * @return 按应用类型分组的有效已授权应用模块树 + */ + List findGroupPermissionTree(Integer groupId); /** * 开放接口专用:查询用户在指定调用方应用下的已授权模块(嵌套 BoaModule 树,层级由 diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java index 9013b4c..b40751f 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -6,6 +6,7 @@ import com.yunhai.userbase.model.sysgroup.SysGroupQuery; import com.yunhai.userbase.model.sysgroup.SysGroupRequest; import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest; import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.model.sysgroup.SysGroupVo; import com.yunhai.userbase.service.resolve.AccessScope; @@ -160,4 +161,19 @@ public interface ISysGroupService { * @return 组节点列表(按入参顺序;不存在的跳过) */ List getGroupList(String ids, Boolean withSub); + + /** + * 获取组内用户(开放接口 {@code /oapi/group/users},不分页一次返回全部)。
+ * 组范围:withSub=true 时含该组全部子孙组(口径与 {@link #listSelfAndDescendantIds} 一致),否则仅本组;
+ * 角色过滤:可选,逗号/空格分隔(复用 {@link com.yunhai.userbase.enums.SysRoleEnum} 键), + * 事实源为 {@code sys_user.roles} 快照 JSON——与 PushDispatcher 的推送角色过滤口径完全一致, + * 不要混用身份行 sys_user_role_rel(同用户两种口径易产生漂移)。
+ * 返回轻量 BoaUser(仅填 id/account/name/type,控制列表体积),不套信封、无分页元数据。 + * + * @param groupId 组ID(必填) + * @param withSub 是否递归子组,缺省 false + * @param roleIds 角色过滤(逗号/空格分隔),null/空 表示不过滤 + * @return 组内用户列表(多组归属按用户去重一次;无匹配返回空列表) + */ + List listGroupUsers(Integer groupId, Boolean withSub, String roleIds); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java index a95fa00..9fd8d01 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/AppRoleService.java @@ -61,6 +61,10 @@ import java.util.Set; @Service public class AppRoleService implements IAppRoleService { + /** 用户中心自身基石应用的 appKey(application_info.appKey 从 100000 起自增,100000 即用户中心平台, + * 无应用类型;全应用授权范围须排除它——与 ApplicationInfoRepository「appKey > 100000」口径一致。 */ + private static final int USER_CENTER_APP_KEY = 100000; + @Autowired private SysAppRoleRepository sysAppRoleRepository; @@ -183,10 +187,16 @@ public class AppRoleService implements IAppRoleService { Objects.requireNonNull(roleId, "角色ID不能为空"); SysAppRole role = sysAppRoleRepository.findById(roleId) .orElseThrow(() -> new RuntimeException("角色不存在: " + roleId)); - // 授权范围:普通角色仅本应用;全应用角色为系统全部应用 + // 授权范围:普通角色仅本应用;全应用角色为系统全部应用(排除用户中心自身基石应用, + // 它无应用类型,前端按分类分组时会落进「未分类」组展示,不属于可授权业务应用) List apps; if (Boolean.TRUE.equals(role.getAllAppFlag())) { - apps = applicationInfoRepository.findAll(); + apps = new ArrayList<>(); + for (ApplicationInfo app : applicationInfoRepository.findAll()) { + if (app.getAppKey() == null || app.getAppKey() > USER_CENTER_APP_KEY) { + apps.add(app); + } + } } else { ApplicationInfo app = applicationInfoRepository.findById(role.getAppId()).orElse(null); if (app == null) { 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 d08c60c..504ac94 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -1124,6 +1124,140 @@ public class GroupAuthzService implements IGroupAuthzService { return result; } + /** + * 组本身的有效已授权应用模块树(权限一览点击「所属部门」树节点联动右侧面板)。
+ * 判定与 {@link #findUserPermissionTree(Integer, Integer)} 的组侧完全同套 + * (应用开通池=本组+祖先链并集、就近原则、子组继承闸门、学校截止),不并入用户角色授权: + * 展示的是「这个组的组授权」,与点哪个用户进来无关。刻意不复用 findUserPermissionTree 的主体 + * (同 findUserModulesForApp 先例:该路径经多轮验证,独立实现零回归风险)。 + */ + @Override + public List findGroupPermissionTree(Integer groupId) { + Objects.requireNonNull(groupId, "组ID不能为空"); + if (!sysGroupRepository.existsById(groupId)) { + throw new DException("系统组不存在"); + } + // 1. 组上下文:selfGroupIds 只有该组自身,沿其祖先链判定 + Set selfGroupIds = new LinkedHashSet<>(); + selfGroupIds.add(groupId); + Map allGroupMap = buildAllGroupMap(); + Map> chainMap = new HashMap<>(); + Set allChainIds = new LinkedHashSet<>(); + List chain = ancestorChain(groupId, allGroupMap); + chainMap.put(groupId, chain); + allChainIds.addAll(chain); + + // 2. 应用开通池 = 本组 + 祖先链的已开通应用并集 + Set openedApps = collectAppIdsForAuthzScope(groupId); + Map> groupToApps = new HashMap<>(); + groupToApps.put(groupId, openedApps); + if (openedApps.isEmpty()) { + return Collections.emptyList(); + } + + // 3. 应用与模块装载 + Map appMap = new HashMap<>(); + for (ApplicationInfo app : applicationInfoRepository.findAllById(openedApps)) { + appMap.put(app.getId(), app); + } + List allModules = appModuleRepository.findByAppIdIn(new ArrayList<>(openedApps)); + if (allModules.isEmpty()) { + return Collections.emptyList(); + } + Set allModuleIds = new LinkedHashSet<>(); + Map> appModulesMap = new HashMap<>(); + Map moduleToAppMap = new HashMap<>(); + for (AppModule m : allModules) { + allModuleIds.add(m.getId()); + appModulesMap.computeIfAbsent(m.getAppId(), k -> new ArrayList<>()).add(m); + moduleToAppMap.put(m.getId(), m.getAppId()); + } + + // 4. 链上权限记录 + 应用级覆盖索引 + Map> modulePermMap = new HashMap<>(); + List perms = sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn( + new ArrayList<>(allModuleIds), new ArrayList<>(allChainIds)); + for (SysGroupModulePerm perm : perms) { + if (perm.getModuleId() == null || perm.getGroupId() == null) { + continue; + } + modulePermMap.computeIfAbsent(perm.getModuleId(), k -> new HashMap<>()).put(perm.getGroupId(), perm); + } + Map> groupConfiguredApps = new HashMap<>(); + for (Map.Entry> entry : modulePermMap.entrySet()) { + Integer appId = moduleToAppMap.get(entry.getKey()); + if (appId == null) { + continue; + } + for (Integer gid : entry.getValue().keySet()) { + groupConfiguredApps.computeIfAbsent(gid, k -> new HashSet<>()).add(appId); + } + } + + // 5. 逐模块判定有效权限(就近来源 + 子组继承闸门 + 学校截止) + Set permittedModuleIds = new LinkedHashSet<>(); + for (AppModule m : allModules) { + if (hasUserModulePermission(m.getId(), m.getAppId(), selfGroupIds, chainMap, groupToApps, + modulePermMap, allGroupMap, groupConfiguredApps)) { + permittedModuleIds.add(m.getId()); + } + } + if (permittedModuleIds.isEmpty()) { + return Collections.emptyList(); + } + + // 6. 按应用过滤、分类组装(口径与 findUserPermissionTree 第8/9步一致) + Map> categoryAppMap = new HashMap<>(); + for (Map.Entry> entry : appModulesMap.entrySet()) { + Integer appId = entry.getKey(); + ApplicationInfo app = appMap.get(appId); + if (app == null) { + continue; + } + List moduleTree = buildUserPermissionModuleTree(entry.getValue(), permittedModuleIds); + if (moduleTree.isEmpty()) { + continue; + } + UserPermissionAppVo appVo = new UserPermissionAppVo(); + appVo.setId(app.getId()); + appVo.setName(app.getName()); + appVo.setAppKey(app.getAppKey()); + appVo.setPcIcon(app.getPcIcon()); + appVo.setSortNo(app.getSortNo()); + appVo.setModules(moduleTree); + Integer categoryId = app.getCategoryId() == null ? 0 : app.getCategoryId(); + categoryAppMap.computeIfAbsent(categoryId, k -> new ArrayList<>()).add(appVo); + } + if (categoryAppMap.isEmpty()) { + return Collections.emptyList(); + } + List result = new ArrayList<>(); + for (AppCategory category : appCategoryRepository.findAllByOrderBySortNoAsc()) { + List appVos = categoryAppMap.remove(category.getId()); + if (appVos == null || appVos.isEmpty()) { + continue; + } + appVos.sort(userPermissionAppOrder()); + UserPermissionCategoryVo vo = new UserPermissionCategoryVo(); + vo.setId(category.getId()); + vo.setName(category.getName()); + vo.setSortNo(category.getSortNo()); + vo.getApps().addAll(appVos); + result.add(vo); + } + List uncategorized = categoryAppMap.remove(0); + if (uncategorized != null && !uncategorized.isEmpty()) { + uncategorized.sort(userPermissionAppOrder()); + UserPermissionCategoryVo vo = new UserPermissionCategoryVo(); + vo.setId(0); + vo.setName("未分类"); + vo.setSortNo(999); + vo.getApps().addAll(uncategorized); + result.add(vo); + } + return result; + } + @Override public List findUserRoles(Integer userId) { Objects.requireNonNull(userId, "用户ID不能为空"); @@ -1176,7 +1310,7 @@ public class GroupAuthzService implements IGroupAuthzService { } @Override - public List findUserGroupTree(Integer userId) { + public List findUserGroupTree(Integer userId, Set scopeGroupIds) { Objects.requireNonNull(userId, "用户ID不能为空"); if (!sysUserRepository.existsById(userId)) { throw new DException("用户不存在"); @@ -1201,6 +1335,11 @@ public class GroupAuthzService implements IGroupAuthzService { for (Integer gid : selfGroupIds) { nodeIds.addAll(ancestorChain(gid, allGroupMap)); } + // 2.5 数据范围剪枝:非超管只保留范围内的节点——学校管理员视角学校组即展示根, + // 不再冒出区域组/平台根(局端自然剪到区域组为止);范围内的组若父被剪掉则提升为展示根 + if (scopeGroupIds != null) { + nodeIds.retainAll(scopeGroupIds); + } if (nodeIds.isEmpty()) { return Collections.emptyList(); } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java b/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java index 4b65330..421584a 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SignKeyCacheService.java @@ -57,6 +57,20 @@ public class SignKeyCacheService implements ApplicationRunner, ApplicationContex @Autowired private StringRedisTemplate stringRedisTemplate; + /** + * 密钥链专用模板(yh.app-auth.sign-redis.database 配置时由 yh-app-auth starter 注册)。 + * 存在则密钥写入走专用库——sign:app:* 是全平台共享数据,读取方(各提供方 SignKeyResolver) + * 配置同一库号才能看到;未配置(bean 不存在)= 现状,跟应用主库。 + */ + @Autowired(required = false) + @org.springframework.beans.factory.annotation.Qualifier("signKeyStringRedisTemplate") + private StringRedisTemplate signKeyRedisTemplate; + + /** 密钥写入目标:专用库模板优先 */ + private StringRedisTemplate writeTemplate() { + return signKeyRedisTemplate != null ? signKeyRedisTemplate : stringRedisTemplate; + } + @Autowired private ApplicationInfoRepository applicationInfoRepository; @@ -91,7 +105,7 @@ public class SignKeyCacheService implements ApplicationRunner, ApplicationContex String key = KEY_PREFIX + info.getAppKey(); SignAppKeyPayload payload = new SignAppKeyPayload( String.valueOf(info.getAppKey()), info.getAppSecret(), true); - stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(payload)); + writeTemplate().opsForValue().set(key, JSON.toJSONString(payload)); invalidateLocal(info.getAppKey()); } @@ -103,7 +117,7 @@ public class SignKeyCacheService implements ApplicationRunner, ApplicationContex if (appKey == null) { return; } - stringRedisTemplate.delete(KEY_PREFIX + appKey); + writeTemplate().delete(KEY_PREFIX + appKey); invalidateLocal(appKey); log.info("[SignKeyCache] remove appKey={} from redis", appKey); } @@ -125,11 +139,11 @@ public class SignKeyCacheService implements ApplicationRunner, ApplicationContex dbKeys.add(KEY_PREFIX + info.getAppKey()); } // 清理残留:DB 已删除但 Redis 仍存在的 key(防止被删应用仍可验签) - Set redisKeys = stringRedisTemplate.keys(KEY_PREFIX + "*"); + Set redisKeys = writeTemplate().keys(KEY_PREFIX + "*"); if (redisKeys != null) { for (String k : redisKeys) { if (!dbKeys.contains(k)) { - stringRedisTemplate.delete(k); + writeTemplate().delete(k); log.info("[SignKeyCache] prune stale key={}", k); } } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java index 2f469d7..c14fcd7 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java @@ -1,8 +1,11 @@ package com.yunhai.userbase.service.impl; import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.userbase.enums.AccessLevelEnum; import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.platform.utils.enums.IEnum; @@ -23,6 +26,7 @@ import com.yunhai.userbase.repository.SysGroupAppRelRepository; import com.yunhai.userbase.repository.SysGroupModulePermRepository; import com.yunhai.userbase.repository.SysGroupRepository; import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; import com.yunhai.userbase.service.ISysGroupService; import org.springframework.beans.BeanUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -117,6 +121,9 @@ public class SysGroupService implements ISysGroupService { @Autowired private SysUserGroupRelRepository sysUserGroupRelRepository; + @Autowired + private SysUserRepository sysUserRepository; + @Autowired private SysGroupAppRelRepository sysGroupAppRelRepository; @@ -556,6 +563,58 @@ public class SysGroupService implements ISysGroupService { return result; } + /** + * 获取组内用户(开放接口 {@code /oapi/group/users},不分页一次返回全部)。
+ * 步骤:① 组范围(withSub=true 用 {@link #listSelfAndDescendantIds} 展开全部子孙组,false 仅本组); + * ② 组→用户走关联表 {@link SysUserGroupRelRepository#findByGroupIdIn} 后按 user_id 去重; + * ③ 角色过滤事实源 = {@code sys_user.roles}(快照 JSON),与 PushDispatcher 过滤口径一致; + * ④ 轻量 BoaUser(id/account/name/type)。组不存在或组下无用户返回空列表,不抛异常。 + */ + @Override + public List listGroupUsers(Integer groupId, Boolean withSub, String roleIds) { + if (groupId == null) { + return new ArrayList<>(); + } + // ① 组范围(含子组可选) + List scopeIds = Boolean.TRUE.equals(withSub) + ? listSelfAndDescendantIds(groupId) + : Collections.singletonList(groupId); + if (scopeIds.isEmpty()) { + return new ArrayList<>(); + } + // ② 组 -> 用户(多组归属按 user_id 去重一次) + List rels = sysUserGroupRelRepository.findByGroupIdIn(scopeIds); + if (rels == null || rels.isEmpty()) { + return new ArrayList<>(); + } + Set userIds = new HashSet<>(); + for (SysUserGroupRel rel : rels) { + if (rel.getUserId() != null) { + userIds.add(rel.getUserId()); + } + } + List users = sysUserRepository.findAllById(userIds); + // ③ 角色过滤(口径与 PushDispatcher.filterUsers 完全一致:roles 快照 contains 任一即命中) + Set roleSet = parseRoleIds(roleIds); + if (!roleSet.isEmpty()) { + users = users.stream() + .filter(u -> u.getRoles() != null && u.getRoles().stream().anyMatch(roleSet::contains)) + .collect(Collectors.toList()); + } + // ④ 轻量 BoaUser(控制列表体积;角色/学校等富字段按需由消费方二次获取) + return users.stream().map(this::toLightBoaUser).collect(Collectors.toList()); + } + + /** SysUser -> 轻量 BoaUser:仅填 id/account/name/type(type 为 null 时保持 null)。 */ + private BoaUser toLightBoaUser(SysUser user) { + BoaUser boa = new BoaUser(); + boa.setId(user.getId()); + boa.setAccount(user.getAccount()); + boa.setName(user.getRealName()); + boa.setType(user.getType() != null ? user.getType().getKey() : null); + return boa; + } + // ===================== 批量新增 ===================== @Override @@ -829,6 +888,26 @@ public class SysGroupService implements ISysGroupService { return result; } + /** 角色过滤解析(逗号/空格分隔,天然去重为 Set):语义与 PushDispatcher 的 Set<Integer> parseIds 一致, + * 避免 List→Set 转换;保序 List 场景走 {@link #parseIds}(批量组接口按入参顺序返回)。 */ + private Set parseRoleIds(String ids) { + Set result = new HashSet<>(); + if (ids == null || ids.trim().isEmpty()) { + return result; + } + for (String s : ids.split("[,\\s]+")) { + if (s == null || s.trim().isEmpty()) { + continue; + } + try { + result.add(Integer.parseInt(s.trim())); + } catch (NumberFormatException ignored) { + // 跳过非数字片段 + } + } + return result; + } + /** pid 下已有子节点的最大 sort +1;无子节点返回 1。 */ private int nextSort(Integer pid) { Integer max = sysGroupRepository.findMaxSortByParentId(pid); diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceGroupUsersTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceGroupUsersTest.java new file mode 100644 index 0000000..7bdf069 --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceGroupUsersTest.java @@ -0,0 +1,183 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUser; +import com.yunhai.userbase.entity.SysUserGroupRel; +import com.yunhai.userbase.enums.UserTypeEnum; +import com.yunhai.userbase.model.GroupSnapshot; +import com.yunhai.userbase.repository.SysUserGroupRelRepository; +import com.yunhai.userbase.repository.SysUserRepository; +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.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@code SysGroupService.listGroupUsers} 单元测试(开放接口 {@code /oapi/group/users})。
+ * 覆盖:withSub 展开全部子孙组 / 仅本组、多组归属按用户去重、角色过滤(事实源 sys_user.roles, + * 与 PushDispatcher 口径一致)、空结果与空 groupId 边界。 + */ +class SysGroupServiceGroupUsersTest { + + @InjectMocks + private SysGroupService sysGroupService; + + @Mock + private SysUserGroupRelRepository sysUserGroupRelRepository; + + @Mock + private SysUserRepository sysUserRepository; + + @Mock + private GroupSnapshotService groupSnapshotService; + + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + } + + // ===================== 测试数据工厂 ===================== + + private SysGroup group(Integer id, Integer parentId) { + SysGroup g = new SysGroup(); + g.setId(id); + g.setParentId(parentId); + return g; + } + + private SysUserGroupRel rel(Integer userId, Integer groupId) { + SysUserGroupRel r = new SysUserGroupRel(); + r.setUserId(userId); + r.setGroupId(groupId); + return r; + } + + private SysUser user(Integer id, String account, String name, UserTypeEnum type, List roles) { + SysUser u = new SysUser(); + u.setId(id); + u.setAccount(account); + u.setRealName(name); + u.setType(type); + u.setRoles(roles); + return u; + } + + /** 组树:1(根) ── 10 ── 11;不用 stub 快照之外的真实组结构。 */ + private void stubSnapshot() { + when(groupSnapshotService.snapshot()) + .thenReturn(new GroupSnapshot(Arrays.asList(group(1, 0), group(10, 1), group(11, 10)))); + } + + private static List boaIds(List boas) { + return boas.stream().map(BoaUser::getId).collect(Collectors.toList()); + } + + // ===================== 用例 ===================== + + /** withSub=true:组范围 = 本组 + 全部子孙组,多组归属按 user_id 去重一次。 */ + @Test + void listGroupUsers_withSub_collectsAllDescendantsAndDedupes() { + stubSnapshot(); + // 用户 1 挂在根组,用户 2 挂在组10,用户 3 挂在组11;用户 2 同时挂在组10与组11(跨组应去重) + when(sysUserGroupRelRepository.findByGroupIdIn(any())) + .thenReturn(Arrays.asList(rel(1, 1), rel(2, 10), rel(3, 11), rel(2, 11))); + when(sysUserRepository.findAllById(any())) + .thenReturn(Arrays.asList( + user(1, "u1", "用户1", UserTypeEnum.NORMAL, Collections.singletonList(1)), + user(2, "u2", "用户2", UserTypeEnum.NORMAL, Collections.singletonList(2)), + user(3, "u3", "用户3", UserTypeEnum.NORMAL, Collections.singletonList(3)))); + + List result = sysGroupService.listGroupUsers(1, true, null); + + assertNotNull(result); + // 去重后仅 3 人(用户2跨组不重复) + assertEquals(Arrays.asList(1, 2, 3), boaIds(result)); + } + + /** withSub=false/缺省:组范围仅本组,不含子孙组用户。 */ + @Test + void listGroupUsers_withoutSub_onlySelfGroup() { + stubSnapshot(); + when(sysUserGroupRelRepository.findByGroupIdIn(Collections.singletonList(1))) + .thenReturn(Collections.singletonList(rel(1, 1))); + when(sysUserRepository.findAllById(any())) + .thenReturn(Collections.singletonList(user(1, "u1", "用户1", UserTypeEnum.NORMAL, null))); + + List result = sysGroupService.listGroupUsers(1, false, null); + + assertEquals(Collections.singletonList(1), boaIds(result)); + // 仅调一次 findByGroupIdIn,且只传本组 id + verify(sysUserGroupRelRepository).findByGroupIdIn(Collections.singletonList(1)); + } + + /** 角色过滤:事实源 sys_user.roles 快照,任一命中即保留;roles 为 null 的用户被排除(与 PushDispatcher 口径一致)。 */ + @Test + void listGroupUsers_roleFilter_usesRolesSnapshotSameAsPushDispatcher() { + stubSnapshot(); + when(sysUserGroupRelRepository.findByGroupIdIn(any())) + .thenReturn(Arrays.asList(rel(1, 1), rel(2, 1), rel(3, 1), rel(4, 1))); + when(sysUserRepository.findAllById(any())) + .thenReturn(Arrays.asList( + user(1, "u1", "教师用户", UserTypeEnum.NORMAL, Arrays.asList(1, 3)), + user(2, "u2", "无角色", UserTypeEnum.NORMAL, null), + user(3, "u3", "学生用户", UserTypeEnum.NORMAL, Collections.singletonList(3)), + user(4, "u4", "其他角色", UserTypeEnum.NORMAL, Collections.singletonList(2)))); + + // 过滤 教师(1)+学生(3):命中的是 u1(含1)、u3(含3) + List result = sysGroupService.listGroupUsers(1, false, "1,3"); + + assertEquals(Arrays.asList(1, 3), boaIds(result)); + } + + /** 未传角色过滤:全量返回(roles 为 null 也保留)。 */ + @Test + void listGroupUsers_noRoleFilter_returnsAll() { + stubSnapshot(); + when(sysUserGroupRelRepository.findByGroupIdIn(any())) + .thenReturn(Arrays.asList(rel(1, 1), rel(2, 1))); + when(sysUserRepository.findAllById(any())) + .thenReturn(Arrays.asList( + user(1, "u1", "用户1", UserTypeEnum.NORMAL, null), + user(2, "u2", "用户2", UserTypeEnum.SCHOOL_ADMIN, Collections.singletonList(7)))); + + List result = sysGroupService.listGroupUsers(1, false, (String) null); + + assertEquals(Arrays.asList(1, 2), boaIds(result)); + // 轻量字段:id/account/name/type 已填充 + BoaUser first = result.get(0); + assertEquals("u1", first.getAccount()); + assertEquals("用户1", first.getName()); + assertEquals(Integer.valueOf(0), first.getType()); + } + + /** 组下无用户:返回空列表,不查用户表。 */ + @Test + void listGroupUsers_emptyRels_returnsEmpty() { + stubSnapshot(); + when(sysUserGroupRelRepository.findByGroupIdIn(any())).thenReturn(Collections.emptyList()); + + List result = sysGroupService.listGroupUsers(1, false, null); + + assertTrue(result.isEmpty()); + verify(sysUserRepository, never()).findAllById(any()); + } + + /** groupId 为 null:直接返回空列表。 */ + @Test + void listGroupUsers_nullGroupId_returnsEmpty() { + assertTrue(sysGroupService.listGroupUsers(null, false, null).isEmpty()); + } +} \ No newline at end of file