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