feat: oapi group/users 组成员端点(一键拉取教师组数据源)+ SignKeyCacheService 专用库支持 + 组授权存量
build / build-push (push) Successful in 1m17s

- /oapi/group/users?groupId=&withSub=&roleIds=:组内用户列表(flow-center 办理人映射批量导入数据源)
- SignKeyCacheService:sign-redis.database 配置时走专用库模板(sign:app:* 跨应用共享)
- 含组授权/身份切换相关存量(GroupAuthz/GroupController 等 2026-09 前期改动)
This commit is contained in:
chenxing
2026-09-16 01:41:57 +08:00
parent 283ac4ca0a
commit ce131bf399
10 changed files with 501 additions and 12 deletions
@@ -162,7 +162,8 @@ public class GroupAuthzController {
/**
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合。
* 非超管:目标用户归属组与数据范围无交集时返回空集合;且树按数据范围剪枝——
* 学校管理员视角只展示到学校组(区域组/平台根不在其范围内,不再冒头到根节点)。
*/
@GetMapping("/UserGroupTree")
public List<UserGroupNodeVo> 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());
}
/**
* 权限一览:查询<b>组本身</b>的有效已授权应用模块树(点击「所属部门」树节点联动右侧「已授权应用」面板)。<br>
* 判定口径与组授权/权限一览的组侧一致(开通池=本组+祖先并集、就近原则、子组继承闸门、学校截止),
* 不并入用户角色授权;目标组须在当前管理端数据范围内。
*/
@GetMapping("/GroupPermissionTree")
public List<UserPermissionCategoryVo> groupPermissionTree(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupPermissionTree(groupId);
}
// ===================== 新增:树节点式增量授权(左侧树 + 右侧 appAuthzList 勾选交互) =====================
@@ -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);
}
/**
* 获取组内用户(不分页,一次返回全部):<br>
* withSub=true 时含该组全部子孙组(口径同 {@code /oapi/group/byId} 的 withSub);<br>
* roleIds 逗号分隔(复用 SysRoleEnum 键),按 {@code sys_user.roles} 快照过滤,缺省不过滤。<br>
* 返回裸 {@code List<BoaUser>}(轻量字段 id/account/name/type),不套信封、无分页元数据。
*
* @param groupId 组ID(必填)
* @param withSub 是否递归子组,缺省 false
* @param roleIds 角色过滤(逗号分隔),缺省不过滤
* @return 组内用户列表(按用户去重;无匹配返回空列表)
*/
@GetMapping("users")
public List<BoaUser> 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)。<br>
* 层级类型约束、根节点保护等复用后台逻辑;鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
@@ -28,6 +28,9 @@ public interface SysUserGroupRelRepository extends JpaRepository<SysUserGroupRel
List<SysUserGroupRel> findByGroupId(Integer groupId);
/** 按多个组ID批量查关联(组定向展开用,调用方自行对 user_id 去重)。 */
List<SysUserGroupRel> findByGroupIdIn(List<Integer> groupIds);
void deleteByUserId(Integer userId);
void deleteByUserIdAndGroupId(Integer userId, Integer groupId);
@@ -116,11 +116,25 @@ public interface IGroupAuthzService {
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组),
* 多个归属组时合并为一棵树(公共祖先只出现一次)。<br>
* 数据范围剪枝:scopeGroupIds 非空时树节点只保留范围内的组——学校管理员视角
* 区域组/根节点不在其范围内,学校组即成为展示树的根(不再冒头到平台根);null 表示不剪枝(超管)。
*
* @param userId 用户ID
* @param userId 用户ID
* @param scopeGroupIds 当前管理端数据范围组集合(null=不限制)
* @return 所属部门树(根节点列表);用户无组归属时返回空列表
*/
List<UserGroupNodeVo> findUserGroupTree(Integer userId);
List<UserGroupNodeVo> findUserGroupTree(Integer userId, java.util.Set<Integer> scopeGroupIds);
/**
* 权限一览:查询<b>组本身</b>的有效已授权应用模块树(点击「所属部门」树节点联动右侧面板用)。<br>
* 语义与 {@link #findUserPermissionTree(Integer, Integer)} 的组侧判定完全一致(应用开通池=本组+祖先链并集、
* 就近原则、子组继承闸门、学校截止),但不并入任何用户角色授权——展示的是「这个组的组授权」,与用户无关:<br>
* 点击教师组 → 学校已开通的应用 + 教师组有效模块(本组显式勾选,或最近配置祖先勾选且开启子组继承)。
*
* @param groupId 组ID
* @return 按应用类型分组的有效已授权应用模块树
*/
List<UserPermissionCategoryVo> findGroupPermissionTree(Integer groupId);
/**
* 开放接口专用:查询用户在<b>指定调用方应用</b>下的已授权模块(<b>嵌套 BoaModule 树</b>,层级由
@@ -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<BoaGroup> getGroupList(String ids, Boolean withSub);
/**
* 获取组内用户(开放接口 {@code /oapi/group/users},不分页一次返回全部)。<br>
* 组范围:withSub=true 时含该组全部子孙组(口径与 {@link #listSelfAndDescendantIds} 一致),否则仅本组;<br>
* 角色过滤:可选,逗号/空格分隔(复用 {@link com.yunhai.userbase.enums.SysRoleEnum} 键),
* 事实源为 {@code sys_user.roles} 快照 JSON——与 PushDispatcher 的推送角色过滤口径完全一致,
* <b>不要混用身份行 sys_user_role_rel</b>(同用户两种口径易产生漂移)。<br>
* 返回轻量 BoaUser(仅填 id/account/name/type,控制列表体积),不套信封、无分页元数据。
*
* @param groupId 组ID(必填)
* @param withSub 是否递归子组,缺省 false
* @param roleIds 角色过滤(逗号/空格分隔),null/空 表示不过滤
* @return 组内用户列表(多组归属按用户去重一次;无匹配返回空列表)
*/
List<BoaUser> listGroupUsers(Integer groupId, Boolean withSub, String roleIds);
}
@@ -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<ApplicationInfo> 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) {
@@ -1124,6 +1124,140 @@ public class GroupAuthzService implements IGroupAuthzService {
return result;
}
/**
* 组本身的有效已授权应用模块树(权限一览点击「所属部门」树节点联动右侧面板)。<br>
* 判定与 {@link #findUserPermissionTree(Integer, Integer)} 的组侧完全同套
* (应用开通池=本组+祖先链并集、就近原则、子组继承闸门、学校截止),<b>不并入用户角色授权</b>:
* 展示的是「这个组的组授权」,与点哪个用户进来无关。刻意不复用 findUserPermissionTree 的主体
* (同 findUserModulesForApp 先例:该路径经多轮验证,独立实现零回归风险)。
*/
@Override
public List<UserPermissionCategoryVo> findGroupPermissionTree(Integer groupId) {
Objects.requireNonNull(groupId, "组ID不能为空");
if (!sysGroupRepository.existsById(groupId)) {
throw new DException("系统组不存在");
}
// 1. 组上下文:selfGroupIds 只有该组自身,沿其祖先链判定
Set<Integer> selfGroupIds = new LinkedHashSet<>();
selfGroupIds.add(groupId);
Map<Integer, SysGroup> allGroupMap = buildAllGroupMap();
Map<Integer, List<Integer>> chainMap = new HashMap<>();
Set<Integer> allChainIds = new LinkedHashSet<>();
List<Integer> chain = ancestorChain(groupId, allGroupMap);
chainMap.put(groupId, chain);
allChainIds.addAll(chain);
// 2. 应用开通池 = 本组 + 祖先链的已开通应用并集
Set<Integer> openedApps = collectAppIdsForAuthzScope(groupId);
Map<Integer, Set<Integer>> groupToApps = new HashMap<>();
groupToApps.put(groupId, openedApps);
if (openedApps.isEmpty()) {
return Collections.emptyList();
}
// 3. 应用与模块装载
Map<Integer, ApplicationInfo> appMap = new HashMap<>();
for (ApplicationInfo app : applicationInfoRepository.findAllById(openedApps)) {
appMap.put(app.getId(), app);
}
List<AppModule> allModules = appModuleRepository.findByAppIdIn(new ArrayList<>(openedApps));
if (allModules.isEmpty()) {
return Collections.emptyList();
}
Set<Integer> allModuleIds = new LinkedHashSet<>();
Map<Integer, List<AppModule>> appModulesMap = new HashMap<>();
Map<Integer, Integer> 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<Integer, Map<Integer, SysGroupModulePerm>> modulePermMap = new HashMap<>();
List<SysGroupModulePerm> 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<Integer, Set<Integer>> groupConfiguredApps = new HashMap<>();
for (Map.Entry<Integer, Map<Integer, SysGroupModulePerm>> 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<Integer> 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<Integer, List<UserPermissionAppVo>> categoryAppMap = new HashMap<>();
for (Map.Entry<Integer, List<AppModule>> entry : appModulesMap.entrySet()) {
Integer appId = entry.getKey();
ApplicationInfo app = appMap.get(appId);
if (app == null) {
continue;
}
List<UserPermissionModuleVo> 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<UserPermissionCategoryVo> result = new ArrayList<>();
for (AppCategory category : appCategoryRepository.findAllByOrderBySortNoAsc()) {
List<UserPermissionAppVo> 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<UserPermissionAppVo> 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<UserRoleVo> findUserRoles(Integer userId) {
Objects.requireNonNull(userId, "用户ID不能为空");
@@ -1176,7 +1310,7 @@ public class GroupAuthzService implements IGroupAuthzService {
}
@Override
public List<UserGroupNodeVo> findUserGroupTree(Integer userId) {
public List<UserGroupNodeVo> findUserGroupTree(Integer userId, Set<Integer> 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();
}
@@ -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<String> redisKeys = stringRedisTemplate.keys(KEY_PREFIX + "*");
Set<String> 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);
}
}
@@ -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},不分页一次返回全部)。<br>
* 步骤:① 组范围(withSub=true 用 {@link #listSelfAndDescendantIds} 展开全部子孙组,false 仅本组);
* ② 组→用户走关联表 {@link SysUserGroupRelRepository#findByGroupIdIn} 后按 user_id 去重;
* ③ 角色过滤事实源 = {@code sys_user.roles}(快照 JSON),与 PushDispatcher 过滤口径一致;
* ④ 轻量 BoaUser(id/account/name/type)。组不存在或组下无用户返回空列表,不抛异常。
*/
@Override
public List<BoaUser> listGroupUsers(Integer groupId, Boolean withSub, String roleIds) {
if (groupId == null) {
return new ArrayList<>();
}
// ① 组范围(含子组可选)
List<Integer> scopeIds = Boolean.TRUE.equals(withSub)
? listSelfAndDescendantIds(groupId)
: Collections.singletonList(groupId);
if (scopeIds.isEmpty()) {
return new ArrayList<>();
}
// ② 组 -> 用户(多组归属按 user_id 去重一次)
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByGroupIdIn(scopeIds);
if (rels == null || rels.isEmpty()) {
return new ArrayList<>();
}
Set<Integer> userIds = new HashSet<>();
for (SysUserGroupRel rel : rels) {
if (rel.getUserId() != null) {
userIds.add(rel.getUserId());
}
}
List<SysUser> users = sysUserRepository.findAllById(userIds);
// ③ 角色过滤(口径与 PushDispatcher.filterUsers 完全一致:roles 快照 contains 任一即命中)
Set<Integer> 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&lt;Integer&gt; parseIds 一致,
* 避免 List→Set 转换;保序 List 场景走 {@link #parseIds}(批量组接口按入参顺序返回)。 */
private Set<Integer> parseRoleIds(String ids) {
Set<Integer> 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);
@@ -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})。<br>
* 覆盖: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<Integer> 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<Integer> boaIds(List<BoaUser> 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<BoaUser> 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<BoaUser> 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<BoaUser> 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<BoaUser> 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<BoaUser> 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());
}
}