diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java
index 2286c9a..2d2919e 100644
--- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java
+++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java
@@ -165,7 +165,8 @@ public class SysUserController {
/**
* 查询用户身份(按所在组解析 schoolId):返回 {schoolId, roleIds};同一学校多组返回同一 schoolId 与并集。
- * 「用户身份」回显唯一来源(roles 列已停写,仅历史兼容读取);编辑请求提交时原样携带 {schoolId, roleIds}。
+ * 「用户身份」回显唯一来源(数据源=身份行 sys_user_role_rel;roles 列已停写、不再读取);
+ * 编辑请求提交时原样携带 {schoolId, roleIds}。
*/
@GetMapping("UserIdentity")
public UserIdentityVo userIdentity(@RequestParam Integer userId, @RequestParam Integer groupId) {
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 850d0c9..4dd02c4 100644
--- a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java
+++ b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java
@@ -73,8 +73,9 @@ public class GroupController {
/**
* 获取组内用户(不分页,一次返回全部):
* withSub=true 时含该组全部子孙组(口径同 {@code /oapi/group/byId} 的 withSub);
- * roleIds 逗号分隔(复用 SysRoleEnum 键),按 {@code sys_user.roles} 快照过滤,缺省不过滤。
- * 返回裸 {@code List}(轻量字段 id/account/name/type),不套信封、无分页元数据。
+ * roleIds 逗号分隔(复用 SysRoleEnum 键),按身份行 {@code sys_user_role_rel}(正常态)过滤,缺省不过滤。
+ * 返回裸 {@code List}(轻量字段 id/account/name/type),不套信封、无分页元数据。
+ * 注意:轻量 BoaUser 不填 {@code status}/组内转出标记,需判禁用或转出请走 {@code /oapi/user/byId}。
*
* @param groupId 组ID(必填)
* @param withSub 是否递归子组,缺省 false
diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java
index f4b5625..8f32945 100644
--- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java
+++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java
@@ -165,12 +165,15 @@ public class UserController {
}
/**
- * 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。
+ * 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。
+ * 用户身份按追加语义写({@code roleIds} 已存在则不变、不存在则补,绝不删除):开放接口调用方
+ * 只能表达"该用户应有某身份"这层意图,覆盖语义会误删该校其它身份;学校范围取 {@code schoolId},
+ * 缺省按 {@code groupId} 解析。管理端编辑仍是该校全量覆盖(见 {@code /sysuser/Edit/{id}})。
*/
@PostMapping("edit/{id}")
public BoaUser edit(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
- sysUserService.edit(id, request);
+ sysUserService.editWithIdentityAppend(id, request);
return userCenterService.getUserInfoWithDeptById(id);
}
diff --git a/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java b/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java
index 697ca4b..b63299d 100644
--- a/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java
+++ b/src/main/java/com/yunhai/userbase/model/push/dto/PushUserDTO.java
@@ -10,7 +10,7 @@ import java.util.List;
*
* - status:0正常 / 2禁用(由 userbase StatusEnum 映射)
* - groups:用户所属组,含 groupId / no / schoolId(schoolId = 组所属学校范围 id)
- * - roleList:用户身份,含 roleId / schId(schId = 用户主组所属学校范围 id)
+ * - roleList:用户身份,含 roleId / schId(schId = 身份行所属学校,即 sys_user_role_rel.schoolId)
*
*
* @author chenx
@@ -47,7 +47,7 @@ public class PushUserDTO {
@Data
public static class PushUserRoleRef {
private Integer roleId;
- /** 所属学校 id(userbase 无角色-学校关联,统一取用户主组所属学校) */
+ /** 身份行所属学校 id(sys_user_role_rel.schoolId,学校组 / 区域组 / 根直属各自的实际组 id) */
private Integer schId;
}
}
diff --git a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java
index f7a3ef4..bb0ffcf 100644
--- a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java
+++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java
@@ -4,7 +4,6 @@ import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
-import java.util.List;
/**
* @author chenx
@@ -22,7 +21,6 @@ public class SysUserVo {
private String email;
private Integer gender;
private String profilePicture;
- private List roles;
/** 启用状态:StatusEnum 的 key(0=正常/未禁用,1=禁用) */
private Integer disabled;
/** 用户类型:UserTypeEnum 的 key(0=普通用户,1=超级管理员,2=学校管理员) */
@@ -32,7 +30,9 @@ public class SysUserVo {
/** 组内状态:UserGroupRelStatusEnum 的 key(0=正常,1=转出);仅组上下文列表有值,根节点/未选组为 null */
private Integer groupStatus;
/**
- * 主校身份名(如「老师/校领导」):列表页按页批量带出(动态解析主身份,无 N+1)。
+ * 用户身份名(如「老师、校领导」):列表页按页批量带出(无 N+1)。
+ * 组上下文列表只取「当前组范围解析出的学校集合」内的身份行(与编辑弹窗「所在组」回显同源);
+ * 未选组(全平台视角)才展示全部身份;范围内无身份行时留空。
* roles 列已停写(冻结历史值),「用户身份」列展示一律切此字段。
*/
private String identityNames;
diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java
index 7da8e27..2876fd2 100644
--- a/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java
+++ b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java
@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
@@ -27,6 +28,19 @@ public interface SysUserRoleRelRepository extends JpaRepository findByUserIdInAndStatus(List userIds, StatusEnum status);
+ /**
+ * 在给定用户集合中筛出「拥有指定身份角色(指定状态)」的 userId(去重)。
+ * 供推送订阅的「推送角色」过滤(PushDispatcher.filterUsers)与开放接口组内用户角色过滤
+ * (SysGroupService.listGroupUsers)共用:两处口径必须一致,事实源为身份行——此前两处都读
+ * sys_user.roles 快照列,该列已停写(新建/改过身份的用户恒为空),会静默漏掉用户。
+ * 调用方须保证 userIds / roleIds 均非空(空集合的 IN 不保证生成合法 SQL)。
+ */
+ @Query("SELECT DISTINCT r.userId FROM SysUserRoleRel r WHERE r.userId IN :userIds "
+ + "AND r.roleId IN :roleIds AND r.status = :status")
+ List findUserIdsWithAnyRole(@Param("userIds") Collection userIds,
+ @Param("roleIds") Collection roleIds,
+ @Param("status") StatusEnum status);
+
/** 用户在某学校下、某状态下的全部角色关联 */
List findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status);
diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java
index 14ac9f5..f079625 100644
--- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java
+++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java
@@ -147,7 +147,9 @@ public interface IGroupAuthzService {
* - 后者返回用户在全部已开通应用下的权限聚合(供管理端权限一览使用);
* - 本方法只返回 {@code appId} 对应这一个应用下的模块,所有 SQL/集合构建均限定该应用(数据库层面过滤)。
*
- * 上下文组(schId)语义与开放接口一致:schId 非空=只认该组链路;schId 为空=取用户主学校组(若仍空=用户主归属组 id)。
+ * 上下文组(schId)语义:schId 非空=只取用户「位于该学校子树内」的真实归属组,逐组建链判定
+ * (子树内无归属组则仅靠角色授权,不用 schId 冒充本组);schId 为空=取用户全部真实归属组
+ * (与权限一览「用户全局模式」一致)。
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。
*
diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java
index b40751f..52d1032 100644
--- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java
+++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java
@@ -166,8 +166,9 @@ public interface ISysGroupService {
* 获取组内用户(开放接口 {@code /oapi/group/users},不分页一次返回全部)。
* 组范围:withSub=true 时含该组全部子孙组(口径与 {@link #listSelfAndDescendantIds} 一致),否则仅本组;
* 角色过滤:可选,逗号/空格分隔(复用 {@link com.yunhai.userbase.enums.SysRoleEnum} 键),
- * 事实源为 {@code sys_user.roles} 快照 JSON——与 PushDispatcher 的推送角色过滤口径完全一致,
- * 不要混用身份行 sys_user_role_rel(同用户两种口径易产生漂移)。
+ * 事实源为身份行 {@code sys_user_role_rel}(正常态,与推送 roleList / PushDispatcher 的推送角色过滤
+ * 口径完全一致,共用 {@code SysUserRoleRelRepository#findUserIdsWithAnyRole})。
+ * 原「读 {@code sys_user.roles} 快照 JSON」口径已废弃:该列停写后会让新建/改过身份的用户被静默漏掉。
* 返回轻量 BoaUser(仅填 id/account/name/type,控制列表体积),不套信封、无分页元数据。
*
* @param groupId 组ID(必填)
diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java
index d36fc0a..2945227 100644
--- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java
+++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java
@@ -25,9 +25,21 @@ public interface ISysUserService {
/** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。返回创建后的用户ID。 */
Integer add(SysUserRequest request);
- /** 编辑用户;密码为空时不修改密码。 */
+ /**
+ * 编辑用户(管理端);密码为空时不修改密码。
+ * 用户身份按「schoolId 该校全量覆盖」写(勾选框语义);带了 schoolId 却不带 roleIds 直接报错,
+ * 清空该校身份须显式传空数组。
+ */
void edit(Integer id, SysUserRequest request);
+ /**
+ * 编辑用户(开放接口 {@code /oapi/user/edit/{id}});资料字段同管理端,用户身份按追加语义写。
+ * 学校范围 = 显式 {@code schoolId},缺省按 {@code groupId} 解析(与新增口同一约定);该范围内已存在的
+ * 角色不动、不存在的补一行,不删任何身份行(最坏后果是多重一个身份,不会像覆盖语义那样清空)。
+ * 删除身份仍只在管理端进行。
+ */
+ void editWithIdentityAppend(Integer id, SysUserRequest request);
+
/**
* 批量新增用户:逐条走完整校验(含密码强度),返回创建后的用户ID列表(顺序与入参一致)。
*/
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 bbdf60b..c9abbae 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java
@@ -35,6 +35,7 @@ import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService;
+import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.impl.UserCenterService;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.slf4j.Logger;
@@ -48,6 +49,7 @@ import org.springframework.stereotype.Service;
import javax.persistence.criteria.Predicate;
import javax.transaction.Transactional;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
@@ -121,6 +123,10 @@ public class GroupAuthzService implements IGroupAuthzService {
@Autowired
private GroupSnapshotService groupSnapshotService;
+ /** 组上下文学校解析/子树展开(角色授权取数收窄用,复用 resolveSchoolId/listSelfAndDescendantIds 唯一口径)。 */
+ @Autowired
+ private ISysGroupService sysGroupService;
+
@Autowired
private ApplicationEventPublisher eventPublisher;
@@ -552,13 +558,13 @@ public class GroupAuthzService implements IGroupAuthzService {
List allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
return assembleModuleTree(allModules);
}
- // 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用)
- Set roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId);
- // 4. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
+ // 3. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
// - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身),
// 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块,
// 不把 schId 冒充本组,否则会绕过子组继承闸门、丢本组数据);
// - schId 空:取用户全部真实归属组(与权限一览「用户全局模式」一致)。
+ // 该集合同时作为「角色授权取数范围」:schId 非空时角色绑定也只认该校子树内的组,
+ // 否则多校用户在 A 校上下文下会拿到 B 校组的角色授权模块(2026-09-18 收窄)。
List userRels = sysUserGroupRelRepository.findByUserId(userId);
Map allGroupMap = buildAllGroupMap();
Set selfGroupIds = new LinkedHashSet<>();
@@ -579,6 +585,9 @@ public class GroupAuthzService implements IGroupAuthzService {
}
}
}
+ // 4. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用)。
+ // 取数范围随上下文组收窄:schId 非空 → 只认该校子树内的归属组绑定;schId 空 → 全部所在组(现状)
+ Set roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId, selfGroupIds);
// 5. 构建各归属组的祖先链与开通池,并判定:任意归属组开通池里是否包含目标 appId(应用开通闸门)。
// 归属组为空(该学校子树内无组 / 无学校无组)时仍可能因角色授权而返回模块,不能直接返回空。
Map> chainMap = new HashMap<>();
@@ -648,9 +657,12 @@ public class GroupAuthzService implements IGroupAuthzService {
* 收集用户启用角色下=且属于目标应用 appId 的模块ID集合(角色授权并集专用的"应用过滤版")。
* 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块;
* 本方法在其之上再按模块.appId == appId 过滤一次(不额外发 SQL,遍历两次即可)。
+ *
+ * @param restrictGroupIds 角色绑定取数的限定组集合(上下文学校收窄);null=用户全部所在组
*/
- private Set collectEnabledRoleModuleIdsForApp(Integer userId, Integer appId) {
- Set allRoleModuleIds = collectEnabledRoleModuleIds(userId);
+ private Set collectEnabledRoleModuleIdsForApp(Integer userId, Integer appId,
+ Collection restrictGroupIds) {
+ Set allRoleModuleIds = collectEnabledRoleModuleIds(userId, restrictGroupIds);
if (allRoleModuleIds.isEmpty() || appId == null) {
return Collections.emptySet();
}
@@ -945,8 +957,7 @@ public class GroupAuthzService implements IGroupAuthzService {
return buildSuperAdminAllPermissions();
}
// AI-FIX(角色授权): 先取用户经「启用」角色获得的模块ID集合,与组授权做并集;
- // 角色授权不依赖组归属与组开通应用,故下方不再因无组归属而提前返回
- Set roleModuleIds = collectEnabledRoleModuleIds(userId);
+ // 角色授权不依赖组归属与组开通应用,故下方不再因无组归属而提前返回。
// 1. 归属组集合:区分「组上下文模式」和「用户全局模式」
// A) 接口显式传了 groupId(= 管理员在"某个组的授权面板"点用户权限一览,是典型的组视角查询):
@@ -970,6 +981,22 @@ public class GroupAuthzService implements IGroupAuthzService {
}
}
}
+ // 角色授权取数范围(2026-09-18 收窄,口径与 oapi /oapi/user/modules 的 schId 一致):
+ // 组视角 → 只认「该组所在学校子树内」的用户归属组绑定(不是上面那套单组判定:角色是校维度的岗位,
+ // 按学校取全校归属组;避免多校用户在此校视图下带出他校岗位角色授权的模块);
+ // 全局视角 → 不做限制(用户全部所在组,现状)。
+ Set roleScopeGroupIds = null;
+ if (groupId != null) {
+ Integer contextSchoolId = sysGroupService.resolveSchoolId(groupId);
+ List contextSchoolScope = sysGroupService.listSelfAndDescendantIds(contextSchoolId);
+ roleScopeGroupIds = new LinkedHashSet<>();
+ for (SysUserGroupRel rel : userRels) {
+ if (rel.getGroupId() != null && contextSchoolScope.contains(rel.getGroupId())) {
+ roleScopeGroupIds.add(rel.getGroupId());
+ }
+ }
+ }
+ Set roleModuleIds = collectEnabledRoleModuleIds(userId, roleScopeGroupIds);
// 2. 全部组,构建祖先链
Map allGroupMap = buildAllGroupMap();
@@ -1867,11 +1894,29 @@ public class GroupAuthzService implements IGroupAuthzService {
* group_id 为 NULL 的历史行视为全局生效(兜底防迁移脚本未跑导致权限丢失)。
* 调整组不删绑定:旧组上的绑定因组关系重建而自然失效,调回该组自动恢复。
* 禁用角色(disabled=true)不参与权限计算;用户无绑定或角色全被禁用时返回空集合。
+ * 取数范围不做上下文限制(用户全部所在组)——需要按学校收敛请用
+ * {@link #collectEnabledRoleModuleIds(Integer, Collection)}。
*/
private Set collectEnabledRoleModuleIds(Integer userId) {
+ return collectEnabledRoleModuleIds(userId, null);
+ }
+
+ /**
+ * {@link #collectEnabledRoleModuleIds(Integer)} 的上下文限定版(2026-09-18):
+ * {@code restrictGroupIds} 非空时只认该集合内的归属组绑定——用于"以学校返回子组授权模块":
+ * oapi {@code /oapi/user/modules} 按 schId 子树、管理端权限一览按上下文组所在学校子树收窄,
+ * 消除多校用户在 A 校上下文下仍拿到 B 校组的角色授权模块(越权面)。
+ * NULL 全局兜底行在两种模式下都生效(保持迁移兜底语义);该集合内无归属组时只认 NULL 行。
+ *
+ * @param restrictGroupIds 限定组集合;{@code null}=用户全部所在组(现状)
+ */
+ private Set collectEnabledRoleModuleIds(Integer userId, Collection restrictGroupIds) {
List userGroupIds = new ArrayList<>();
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByUserId(userId)) {
- if (rel.getGroupId() != null) {
+ if (rel.getGroupId() == null) {
+ continue;
+ }
+ if (restrictGroupIds == null || restrictGroupIds.contains(rel.getGroupId())) {
userGroupIds.add(rel.getGroupId());
}
}
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 c14fcd7..608a284 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
@@ -8,6 +8,7 @@ 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.userbase.enums.StatusEnum;
import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.userbase.event.DataSyncEvent;
import com.yunhai.userbase.event.GroupChangedEvent;
@@ -27,6 +28,7 @@ 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.repository.SysUserRoleRelRepository;
import com.yunhai.userbase.service.ISysGroupService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
@@ -124,6 +126,9 @@ public class SysGroupService implements ISysGroupService {
@Autowired
private SysUserRepository sysUserRepository;
+ @Autowired
+ private SysUserRoleRelRepository sysUserRoleRelRepository;
+
@Autowired
private SysGroupAppRelRepository sysGroupAppRelRepository;
@@ -567,8 +572,9 @@ public class SysGroupService implements ISysGroupService {
* 获取组内用户(开放接口 {@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)。组不存在或组下无用户返回空列表,不抛异常。
+ * ③ 角色过滤事实源 = 身份行 {@code sys_user_role_rel}(正常态),与 PushDispatcher 过滤口径一致
+ * (共用 {@code SysUserRoleRelRepository#findUserIdsWithAnyRole};旧「sys_user.roles 快照」口径已废);
+ * ④ 轻量 BoaUser(id/account/name/type/status)。组不存在或组下无用户返回空列表,不抛异常。
*/
@Override
public List listGroupUsers(Integer groupId, Boolean withSub, String roleIds) {
@@ -594,24 +600,30 @@ public class SysGroupService implements ISysGroupService {
}
}
List users = sysUserRepository.findAllById(userIds);
- // ③ 角色过滤(口径与 PushDispatcher.filterUsers 完全一致:roles 快照 contains 任一即命中)
+ // ③ 角色过滤(口径与 PushDispatcher.filterUsers 完全一致:身份行 sys_user_role_rel 正常态 contains 任一即命中)
Set roleSet = parseRoleIds(roleIds);
- if (!roleSet.isEmpty()) {
+ if (!roleSet.isEmpty() && !users.isEmpty()) {
+ Set roleMatchedUserIds = new HashSet<>(sysUserRoleRelRepository.findUserIdsWithAnyRole(
+ users.stream().map(SysUser::getId).collect(Collectors.toList()),
+ roleSet, StatusEnum.NORMAL));
users = users.stream()
- .filter(u -> u.getRoles() != null && u.getRoles().stream().anyMatch(roleSet::contains))
+ .filter(u -> roleMatchedUserIds.contains(u.getId()))
.collect(Collectors.toList());
}
// ④ 轻量 BoaUser(控制列表体积;角色/学校等富字段按需由消费方二次获取)
return users.stream().map(this::toLightBoaUser).collect(Collectors.toList());
}
- /** SysUser -> 轻量 BoaUser:仅填 id/account/name/type(type 为 null 时保持 null)。 */
+ /** SysUser -> 轻量 BoaUser:填 id/account/name/type/status(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);
+ // 状态语义对齐(同 BoaUserLoader):本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) → BoaUser(1 正常 / 0 禁用)。
+ // 此前不下发 status,下游选人场景无法区分禁用账号。
+ boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0);
return boa;
}
diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
index a423bc2..a7dd252 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
@@ -36,6 +36,7 @@ import com.yunhai.userbase.event.DataSyncEvent;
import com.yunhai.userbase.event.UserChangedEvent;
import com.yunhai.userbase.common.context.CallerAppContext;
import com.yunhai.userbase.model.push.PushInterfaceType;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import cn.afterturn.easypoi.excel.ExcelExportUtil;
import cn.afterturn.easypoi.excel.entity.ExportParams;
@@ -81,6 +82,7 @@ import javax.servlet.http.HttpServletResponse;
* @date 2026/7/20 16:14
* @description 系统用户服务实现
*/
+@Slf4j
@Service
public class SysUserService implements ISysUserService {
@@ -204,9 +206,37 @@ public class SysUserService implements ISysUserService {
return saved.getId();
}
+ /**
+ * 编辑用户(管理端):身份按「该校全量覆盖」写(勾选框语义 = 该校就该有这几个身份)。
+ * 身份入参成对校验:带了 schoolId 却没带 roleIds 直接报错——此前会先删该校身份行、再因空集合不插入,
+ * 变成静默清空(管理员掉权限);清空身份必须显式传空数组。
+ */
@Override
@Transactional
public void edit(Integer id, SysUserRequest request) {
+ if (request.getSchoolId() != null && request.getRoleIds() == null) {
+ throw new DException("请提交该校的用户身份角色(roleIds);如需清空该校身份请显式传空数组");
+ }
+ editInternal(id, request, IdentityWriteMode.REPLACE);
+ }
+
+ /**
+ * 编辑用户(开放接口):资料字段同管理端,**用户身份按追加语义写**(只加不删,见
+ * {@link #appendSchoolIdentity})——开放接口调用方只能表达"该用户应有教师身份"这类意图,
+ * 用覆盖语义会误删该校其它身份、缺 schoolId 的旧行为则会把整次身份写入静默丢弃。
+ */
+ @Override
+ @Transactional
+ public void editWithIdentityAppend(Integer id, SysUserRequest request) {
+ editInternal(id, request, IdentityWriteMode.APPEND);
+ }
+
+ /** 身份写入语义:REPLACE=管理端全量覆盖(该校就这几个身份);APPEND=开放接口追加(只加不删)。 */
+ private enum IdentityWriteMode {
+ REPLACE, APPEND
+ }
+
+ private void editInternal(Integer id, SysUserRequest request, IdentityWriteMode mode) {
if (sysUserRepository.countByAccountAndIdNot(request.getAccount(), id) > 0) {
throw new DException("账号已被其他用户使用");
}
@@ -246,9 +276,11 @@ public class SysUserService implements ISysUserService {
sysUserRepository.save(entity);
publishUserEvent(PushInterfaceType.UPDATE_USER, id, staleKeyHints);
- // 身份按 schoolId 全量覆盖保存(编辑请求总是携带 {schoolId, roleIds},未修改也提交原值);
- // 未携带 schoolId 的调用(如旧版 oapi edit)跳过身份写库,保持原身份不变
- if (request.getSchoolId() != null) {
+ if (mode == IdentityWriteMode.APPEND) {
+ appendSchoolIdentity(id, request);
+ } else if (request.getSchoolId() != null) {
+ // 身份按 schoolId 全量覆盖保存(管理端编辑总是携带 {schoolId, roleIds},未修改也提交原值);
+ // 未携带 schoolId 的调用跳过身份写库,保持原身份不变
saveSchoolIdentity(id, validateIdentitySchoolId(request.getSchoolId()), request.getRoleIds());
}
@@ -257,6 +289,64 @@ public class SysUserService implements ISysUserService {
sysUserRepository.save(entity);
}
+ /**
+ * 开放接口的用户身份写入(追加语义,只加不删):
+ * 学校范围 = 显式 schoolId,缺省按 request.groupId 解析(与新增口同一约定,调用方不感知解析逻辑);
+ * 该范围内已存在的 roleId 不动、不存在的补一行(status=正常),**不删任何身份行**。
+ * 最坏后果是多重一个身份,不会像覆盖语义那样把该校其它身份删光。
+ * 追加后仍走 {@link #alignIdentity}:若目标学校与该用户的组关系不匹配(异常数据),刚写的行会被
+ * 孤儿清理清出——这种情况打 warn 暴露,不静默。删除身份仍只在管理端进行。
+ */
+ private void appendSchoolIdentity(Integer userId, SysUserRequest request) {
+ validateRoleIds(request.getRoleIds());
+ List roleIds = request.getRoleIds();
+ if (roleIds == null || roleIds.isEmpty()) {
+ // 追加语义下空集合即无操作(不承载"清空"表达,清空只在管理端)
+ return;
+ }
+ Integer schoolId = request.getSchoolId() != null
+ ? validateIdentitySchoolId(request.getSchoolId())
+ : sysGroupService.resolveSchoolId(request.getGroupId());
+ Set existingRoleIds = sysUserRoleRelRepository.findByUserId(userId).stream()
+ .filter(rel -> Objects.equals(rel.getSchoolId(), schoolId) && rel.getRoleId() != null)
+ .map(SysUserRoleRel::getRoleId)
+ .collect(Collectors.toSet());
+ List toAppend = roleIds.stream()
+ .filter(Objects::nonNull)
+ .distinct()
+ .filter(roleId -> !existingRoleIds.contains(roleId))
+ .collect(Collectors.toList());
+ if (toAppend.isEmpty()) {
+ return;
+ }
+ LocalDateTime now = LocalDateTime.now();
+ List rows = new ArrayList<>(toAppend.size());
+ for (Integer roleId : toAppend) {
+ SysUserRoleRel rel = new SysUserRoleRel();
+ rel.setUserId(userId);
+ rel.setRoleId(roleId);
+ rel.setSchoolId(schoolId);
+ rel.setSortNo(0);
+ rel.setStatus(StatusEnum.NORMAL);
+ rel.setCreateTime(now);
+ rel.setUpdateTime(now);
+ rows.add(rel);
+ }
+ sysUserRoleRelRepository.saveAll(rows);
+ alignIdentity(userId);
+ // 追加行是否留存:目标校不在该用户的组关系学校集合内时会被上面的孤儿清理清出,此处告警暴露异常数据
+ Set survivedRoleIds = sysUserRoleRelRepository.findByUserIdAndSchoolIdAndStatus(
+ userId, schoolId, StatusEnum.NORMAL).stream()
+ .map(SysUserRoleRel::getRoleId)
+ .collect(Collectors.toSet());
+ for (Integer roleId : toAppend) {
+ if (!survivedRoleIds.contains(roleId)) {
+ log.warn("[身份追加] 用户 {} 追加的身份未留存(角色 {},学校 {}):该学校不在其组关系学校集合内,"
+ + "请核对调用方传入的 groupId/schoolId", userId, roleId, schoolId);
+ }
+ }
+ }
+
@Override
@Transactional
public List batchAdd(List requests) {
@@ -316,6 +406,11 @@ public class SysUserService implements ISysUserService {
deletedKeyHints.add("phone:" + gone.getPhone());
}
}
+ } else {
+ // 仍属其它组:退掉本校最后一个组时,该校身份行与指向该校的激活身份必须同步清出
+ // —— alignIdentity javadoc 已列明「退组/转校后身份行同步清出」职责,此前退组路径漏调,
+ // 导致已离校用户继续持有该校身份行,而 AccessScopeService 的 SCHOOL 级范围唯一来源就是身份行。
+ alignIdentity(userId);
}
}
// 业务语义:从组移除 = 对子应用即删除(不论是否真删账户),统一推送 DELETE_USER
@@ -521,8 +616,27 @@ public class SysUserService implements ISysUserService {
rel.setStatus(UserGroupRelStatusEnum.NORMAL);
rel.setCreateTime(LocalDateTime.now());
sysUserGroupRelRepository.save(rel);
- // 转入/转组后身份即时对齐:源校组关系已物理删除,旧校身份行清出集合(主身份为读时计算,无物化列需重指向)
- alignIdentity(userId);
+ // 转入/转组后身份即时对齐:源校组关系已物理删除,旧校身份行清出集合(主身份为读时计算,无物化列需重指向)。
+ // 跨校转入额外补一步:把源校正常态身份行复制到目标校——否则源校行被清出集合、目标校无行可留,
+ // 用户会以「零身份」状态落地(getCurrent/roles/type 全空)。复制只带 roleId 集合
+ // (身份行身份键 = 角色×学校,学校维度换成目标校);源校身份行本就为空时不凭空造身份。
+ if (sameSchool) {
+ alignIdentity(userId);
+ } else {
+ List sourceRoleIds = sysUserRoleRelRepository
+ .findByUserIdAndSchoolIdAndStatus(userId, fromSchoolId, StatusEnum.NORMAL)
+ .stream()
+ .map(SysUserRoleRel::getRoleId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .collect(Collectors.toList());
+ if (sourceRoleIds.isEmpty()) {
+ alignIdentity(userId);
+ } else {
+ // saveSchoolIdentity 内部含 alignIdentity(清源校孤儿行 + 清滞留激活身份),无需重复调用
+ saveSchoolIdentity(userId, toSchoolId, sourceRoleIds);
+ }
+ }
// 推送编辑用户订阅
publishUserEvent(PushInterfaceType.UPDATE_USER, userId);
}
@@ -532,19 +646,75 @@ public class SysUserService implements ISysUserService {
// 组上下文(选中具体组):走原生 SQL 按「组内最小 sort_no」排序的专用分页,
// 规避 MySQL 的 DISTINCT+ORDER BY 限制以及 Hibernate 不支持 ORDER BY 标量子查询的限制。
if (query.getGroupId() != null) {
- return attachMainIdentityNames(listByGroup(query, pageable));
+ return attachIdentityNames(listByGroup(query, pageable), resolveScopeSchoolIds(query));
}
// 根节点 / 未选组:用 Specification 过滤,按主键 id 升序(聚簇索引顺序,无 filesort)。
Specification spec = buildSpecification(query);
Page page = sysUserRepository.findAll(spec, pageable);
- return attachMainIdentityNames(page.map(u -> convertToVo(u, null, null)));
+ return attachIdentityNames(page.map(u -> convertToVo(u, null, null)), null);
}
/**
- * 按页批量带出各用户身份名(动态解析主身份,无 N+1)。
- * roles 列已停写(冻结历史值),列表「用户身份」列一律切此新字段展示。
+ * 列表「用户身份」列的展示口径:只看当前列表组范围内学校的身份行。
+ * 学校集合 = 组范围(选中组;withSub=1 时含全部子孙组)逐组 {@link ISysGroupService#resolveSchoolId} 去重,
+ * 与编辑弹窗「所在组」下拉的回显同源({@link #getUserIdentity} 按同一个 resolveSchoolId 取该校身份行)——
+ * 故甲校的组只显示甲校身份、乙校的组只显示乙校身份,不再是把用户全平台身份并起来展示。
+ * 选中节点不是学校组(区域组 / 根节点)时不会整列为空:其自身解析出的 id 也在集合内
+ * (resolveSchoolId 的「学校组 → 区域组 → 根节点」回落链)。
+ *
+ * @return 未选组(全平台视角)返回 null,表示不做学校过滤
*/
- private Page attachMainIdentityNames(Page page) {
+ private Set resolveScopeSchoolIds(SysUserQuery query) {
+ return resolveScopeSchoolIds(query.getGroupId(), query.getWithSub());
+ }
+
+ /** {@link #resolveScopeSchoolIds(SysUserQuery)} 的入参版:导出(不分页、无 Query 对象)复用同一口径。 */
+ private Set resolveScopeSchoolIds(Integer groupId, Integer withSub) {
+ if (groupId == null) {
+ return null;
+ }
+ List groupIds = resolveGroupIds(groupId, withSub);
+ return new HashSet<>(sysGroupService.resolveSchoolIds(groupIds).values());
+ }
+
+ /** 身份行展示顺序:主身份优先 → sortNo → id(与 IdentityService.getUserRoles 同序)。 */
+ private Comparator identityRowOrder(SysUserRoleRel mainRel) {
+ return Comparator.comparingInt((SysUserRoleRel r) ->
+ mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1)
+ .thenComparingInt(SysUserRoleRel::getSortNo)
+ .thenComparingInt(SysUserRoleRel::getId);
+ }
+
+ /**
+ * 批量取「用户身份」单值列(导出)用的首条身份行:仅取 scopeSchoolIds 范围内的正常态身份行,
+ * 按 {@link #identityRowOrder} 取第一行;范围内无身份行(跨校用户在本校无身份)返回空,该列留空。
+ */
+ private Map resolveScopeFirstIdentityByUser(List userIds, Set scopeSchoolIds) {
+ Map firstByUser = new HashMap<>();
+ if (userIds == null || userIds.isEmpty()) {
+ return firstByUser;
+ }
+ Map mainRelsByUser = resolveMainRoleRelsByUser(userIds);
+ Map> relsByUser = sysUserRoleRelRepository
+ .findByUserIdInAndStatus(userIds, StatusEnum.NORMAL)
+ .stream()
+ .filter(r -> scopeSchoolIds == null || scopeSchoolIds.contains(r.getSchoolId()))
+ .collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
+ for (Map.Entry> entry : relsByUser.entrySet()) {
+ entry.getValue().stream()
+ .min(identityRowOrder(mainRelsByUser.get(entry.getKey())))
+ .ifPresent(rel -> firstByUser.put(entry.getKey(), rel));
+ }
+ return firstByUser;
+ }
+
+ /**
+ * 按页批量带出各用户身份名(无 N+1)。
+ * roles 列已停写(冻结历史值),列表「用户身份」列一律切身份行(sys_user_role_rel)展示。
+ *
+ * @param scopeSchoolIds 身份行学校过滤集合;null = 不过滤(全平台视角)
+ */
+ private Page attachIdentityNames(Page page, Set scopeSchoolIds) {
List content = page.getContent();
if (content.isEmpty()) {
return page;
@@ -553,10 +723,11 @@ public class SysUserService implements ISysUserService {
.map(SysUserVo::getId)
.collect(Collectors.toList());
Map mainRelsByUser = resolveMainRoleRelsByUser(userIds);
- // 全部正常态身份行按用户分组:列表列展示全部身份名(主身份在前),不只展示主身份
+ // 范围内正常态身份行按用户分组:列表列展示全部身份名(主身份在前),不只展示主身份
Map> roleRelsByUser = sysUserRoleRelRepository
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL)
.stream()
+ .filter(r -> scopeSchoolIds == null || scopeSchoolIds.contains(r.getSchoolId()))
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
for (SysUserVo vo : content) {
List rels = roleRelsByUser.get(vo.getId());
@@ -565,10 +736,7 @@ public class SysUserService implements ISysUserService {
}
SysUserRoleRel mainRel = mainRelsByUser.get(vo.getId());
String names = rels.stream()
- .sorted(Comparator.comparingInt((SysUserRoleRel r) ->
- mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1)
- .thenComparingInt(SysUserRoleRel::getSortNo)
- .thenComparingInt(SysUserRoleRel::getId))
+ .sorted(identityRowOrder(mainRel))
.map(SysUserRoleRel::getRoleId)
.distinct()
.map(roleId -> {
@@ -732,8 +900,9 @@ public class SysUserService implements ISysUserService {
* 身份统一对齐(保存后与改组三入口 add/edit/changeGroups/transferToSchool 共用):
* ① 学校集合推导:全部组关系(含「转出」标记行——软标记期保留原校身份)逐条解析 schoolId 去重;
* ② 删除不在集合内的身份行(孤儿清理:退组/转校后身份行同步清出);
- * ③ 当前激活身份滞留检查:切换记录指向的学校已不在集合(退组/转学)→ 删除该记录,
- * 避免 getCurrent/boa.school 继续返回旧校(「激活身份不滞留旧校」)。
+ * ③ 当前激活身份滞留检查:① 指向的学校已不在集合(退组/转学);② 学校还在但 (roleId, schoolId)
+ * 已不在剩余身份行里(同校改角色,如校领导降为老师)——两者都删除该记录,避免 getCurrent/boa.school
+ * 继续返回旧校或用户已不具的角色(「激活身份不滞留旧校/旧角色」)。
* 主身份已去字段化(读时计算),此处不再维护 is_main 物化列。
*/
private void alignIdentity(Integer userId) {
@@ -752,9 +921,22 @@ public class SysUserService implements ISysUserService {
sysUserRoleRelRepository.saveAll(remaining);
}
- // 激活身份滞留检查:切换记录指向的学校已不在集合 → 删除(schoolId 为 null 的旧约定行不在此列)
+ // 激活身份滞留检查(schoolId / roleId 为 null 的旧约定行维持原有宽松判定:只按学校集合判):
sysUserCurrentRoleRepository.findByUserId(userId).ifPresent(current -> {
- if (current.getSchoolId() != null && !schoolSet.contains(current.getSchoolId())) {
+ if (current.getSchoolId() == null) {
+ return;
+ }
+ if (!schoolSet.contains(current.getSchoolId())) {
+ sysUserCurrentRoleRepository.delete(current);
+ return;
+ }
+ if (current.getRoleId() == null) {
+ return;
+ }
+ boolean stillValid = remaining.stream().anyMatch(r -> StatusEnum.NORMAL.equals(r.getStatus())
+ && Objects.equals(r.getRoleId(), current.getRoleId())
+ && Objects.equals(r.getSchoolId(), current.getSchoolId()));
+ if (!stillValid) {
sysUserCurrentRoleRepository.delete(current);
}
});
@@ -882,8 +1064,52 @@ public class SysUserService implements ISysUserService {
throw new DException("目标组不属于该学校范围,禁止跨校移动");
}
- // 复用覆盖式改组逻辑(单组场景:目标组即主组)
- changeGroups(Collections.singletonList(userId), Collections.singletonList(groupId), groupId);
+ // 覆盖式重建「本校范围内」的组关系(开放接口契约:同校内移动,只动本校):
+ // 不能复用 changeGroups —— 那是整用户覆盖(deleteByUserId 删全部组关系),会把该用户他校的
+ // 组关系与他校身份行一并清掉(多校用户在本校挪一次组即丢失他校归属)。
+ List oldRels = sysUserGroupRelRepository.findByUserId(userId);
+ List inSchoolRels = oldRels.stream()
+ .filter(r -> r.getGroupId() != null && schoolScope.contains(r.getGroupId()))
+ .collect(Collectors.toList());
+ // 状态与主标记均沿用「被替换掉的本校旧关系」,他校任何行(含 isMain)一律不动:
+ // 状态——行级照抄,用户处于转出流程时新增组继承「转出」(与 changeGroups 既有规则一致);
+ // 主标记——只有原本校关系里有主组时才把主落到目标组,避免造出双主组;他校是主组时目标组保持非主,
+ // 即「本校移动不改变该用户的主组指向」。
+ Map oldStatusByGroup = new HashMap<>();
+ boolean inTransferFlow = false;
+ boolean oldInSchoolMain = false;
+ for (SysUserGroupRel oldRel : inSchoolRels) {
+ if (oldRel.getGroupId() != null) {
+ oldStatusByGroup.put(oldRel.getGroupId(), oldRel.getStatus());
+ }
+ if (oldRel.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT) {
+ inTransferFlow = true;
+ }
+ if (Objects.equals(oldRel.getIsMain(), IS_MAIN)) {
+ oldInSchoolMain = true;
+ }
+ }
+ if (!inSchoolRels.isEmpty()) {
+ sysUserGroupRelRepository.deleteAll(inSchoolRels);
+ // 立即发删除 SQL:否则同事务后续 INSERT 会先于 DELETE 刷新(Hibernate ActionQueue 顺序),
+ // 目标组原本已在关系里(如移动到已有组)时撞 uk(user_id, group_id)
+ sysUserGroupRelRepository.flush();
+ }
+ SysUserGroupRel targetRel = new SysUserGroupRel();
+ targetRel.setUserId(userId);
+ targetRel.setGroupId(groupId);
+ targetRel.setIsMain(oldInSchoolMain ? IS_MAIN : 0);
+ targetRel.setStatus(oldStatusByGroup.containsKey(groupId)
+ ? oldStatusByGroup.get(groupId)
+ : (inTransferFlow ? UserGroupRelStatusEnum.TRANSFERRED_OUT : UserGroupRelStatusEnum.NORMAL));
+ targetRel.setCreateTime(LocalDateTime.now());
+ sysUserGroupRelRepository.save(targetRel);
+
+ // 身份即时对齐:本校关系重建后学校集合不变(该校仍在、他校仍在他校),只会清理退组/转校遗留的
+ // 孤儿身份行与失效激活身份
+ alignIdentity(userId);
+ // 推送编辑用户订阅
+ publishUserEvent(PushInterfaceType.UPDATE_USER, userId);
}
@Override
@@ -1020,7 +1246,8 @@ public class SysUserService implements ISysUserService {
* - 组名 / 组id:取每个用户实际所属的主组(isMain=1),与导入模板的「参考」列保持一致;
* - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);
* - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);
- * - 用户身份 identity:取 roles 数组第一个元素(系统角色枚举 SysRoleEnum key:1老师 2校领导 3学生 4家长 5局领导 6工作号 7管理员)。
+ * - 用户身份 identity:取导出范围内学校下的首条身份行(系统角色枚举 SysRoleEnum key:1老师 2校领导 3学生 4家长 5局领导 6工作号 7管理员),
+ * 口径与列表「用户身份」列一致(见 {@link #resolveScopeSchoolIds})。
*/
@Override
public void exportUsers(Integer groupId, Integer withSub, String name, HttpServletResponse response) throws IOException {
@@ -1038,8 +1265,8 @@ public class SysUserService implements ISysUserService {
// 2. 批量取每个用户的主组(isMain=1),用于填充「组名 / 组id」参考列
Map userMainGroup = resolveUserMainGroup(userIds);
- // 2.1 批量取每个用户的主校身份行(动态解析主身份),用于填充「用户身份」列(roles 列已停写,不再作为真源)
- Map mainRelsByUser = resolveMainRoleRelsByUser(userIds);
+ // 2.1 批量取「用户身份」列要展示的身份行:与列表同口径,限定在导出组范围内的学校上(groupId 为空的全平台导出不过滤)
+ Map identityRelsByUser = resolveScopeFirstIdentityByUser(userIds, resolveScopeSchoolIds(groupId, withSub));
// 3. 批量取组名
Map groupNameMap = sysGroupRepository.findAllById(userMainGroup.values())
@@ -1059,8 +1286,8 @@ public class SysUserService implements ISysUserService {
row.setPhone(u.getPhone());
row.setEmail(u.getEmail());
row.setNature(u.getType() == null ? null : u.getType().getKey());
- SysUserRoleRel mainRel = mainRelsByUser.get(u.getId());
- row.setIdentity(mainRel == null ? null : mainRel.getRoleId());
+ SysUserRoleRel identityRel = identityRelsByUser.get(u.getId());
+ row.setIdentity(identityRel == null ? null : identityRel.getRoleId());
row.setGender(u.getGender());
row.setBirthday(u.getBirthday() == null ? "" : u.getBirthday().toString());
row.setUserId(u.getId());
diff --git a/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java b/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java
index 3cc315f..795b84a 100644
--- a/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java
+++ b/src/main/java/com/yunhai/userbase/service/push/PushPayloadAssembler.java
@@ -3,13 +3,14 @@ package com.yunhai.userbase.service.push;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
+import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.push.dto.PushGroupDTO;
import com.yunhai.userbase.model.push.dto.PushUserDTO;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
-import com.yunhai.userbase.service.resolve.MainGroupResolver;
+import com.yunhai.userbase.repository.SysUserRoleRelRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -24,7 +25,7 @@ import java.util.stream.Collectors;
* 组 type:GroupTypeEnum.key 直接透传(1-8 零映射)
* 用户 status:NORMAL(0)→0正常 / DISABLED(1)→2禁用
* groups[].schoolId:组所属学校范围 id(沿 parentId 向上最近 SCHOOL_GROUP)
- * roleList[].schId:用户主组所属学校范围 id(userbase 无角色-学校关联,统一取主校)
+ * roleList[]:用户身份行(sys_user_role_rel 权威源,正常态),schId = 身份行自带学校维度
*
*
* @author chenx
@@ -35,14 +36,17 @@ public class PushPayloadAssembler {
private static final int ROOT_PARENT_ID = 0;
+ /** 批量身份行 IN 查询分片大小:SYNC_USER 会一次装配全量用户,避免单条 IN 参数过多。 */
+ private static final int ID_BATCH_SIZE = 500;
+
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
- private GroupScopeHelper groupScopeHelper;
+ private SysUserRoleRelRepository sysUserRoleRelRepository;
@Autowired
- private MainGroupResolver mainGroupResolver;
+ private GroupScopeHelper groupScopeHelper;
// ===================== 组 =====================
@@ -88,6 +92,43 @@ public class PushPayloadAssembler {
if (u == null) {
return null;
}
+ return toUserDto(u, sysUserRoleRelRepository.findByUserIdAndStatus(u.getId(), StatusEnum.NORMAL));
+ }
+
+ /** 多个用户(AddUser / UpdateUser / SyncUser)。身份行按批 IN 查询(分片),不逐用户查库。 */
+ public List toUserDtoList(List users) {
+ if (users == null) {
+ return Collections.emptyList();
+ }
+ Map> identityByUser = loadIdentityRows(users);
+ List result = new ArrayList<>(users.size());
+ for (SysUser u : users) {
+ result.add(u == null ? null
+ : toUserDto(u, identityByUser.getOrDefault(u.getId(), Collections.emptyList())));
+ }
+ return result;
+ }
+
+ /** 批量取正常态身份行:userId → 身份行列表(分片 IN 查询)。 */
+ private Map> loadIdentityRows(List users) {
+ List userIds = users.stream()
+ .filter(Objects::nonNull)
+ .map(SysUser::getId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .collect(Collectors.toList());
+ Map> identityByUser = new HashMap<>();
+ for (int from = 0; from < userIds.size(); from += ID_BATCH_SIZE) {
+ List slice = userIds.subList(from, Math.min(from + ID_BATCH_SIZE, userIds.size()));
+ for (SysUserRoleRel rel : sysUserRoleRelRepository.findByUserIdInAndStatus(slice, StatusEnum.NORMAL)) {
+ identityByUser.computeIfAbsent(rel.getUserId(), k -> new ArrayList<>()).add(rel);
+ }
+ }
+ return identityByUser;
+ }
+
+ /** 装配用户 DTO:rel/身份行由调用方提供,批量与单条共用同一实现。 */
+ private PushUserDTO toUserDto(SysUser u, List identityRels) {
PushUserDTO d = new PushUserDTO();
d.setId(u.getId());
d.setAccount(u.getAccount());
@@ -102,7 +143,6 @@ public class PushPayloadAssembler {
d.setProfilePicture(u.getProfilePicture());
List rels = sysUserGroupRelRepository.findByUserId(u.getId());
- Integer mainSchoolId = resolveMainSchoolId(rels);
// groups
List groupRefs = new ArrayList<>();
@@ -115,37 +155,23 @@ public class PushPayloadAssembler {
}
d.setGroups(groupRefs);
- // roleList:每个 roleId 一行,schId 取主组学校(userbase 无角色-学校关联)
+ // roleList:平台身份权威源=sys_user_role_rel(每个 roleId 一行,schId 取身份行自带的学校维度)。
+ // 此前读 sys_user.roles 快照列——该列已停写(见《用户身份-学校维度落地方案》§8),
+ // 新建用户/改过身份的用户该列恒为空,推送 roleList 因此空推。
List roleRefs = new ArrayList<>();
- if (u.getRoles() != null) {
- for (Integer roleId : u.getRoles()) {
- PushUserDTO.PushUserRoleRef ref = new PushUserDTO.PushUserRoleRef();
- ref.setRoleId(roleId);
- ref.setSchId(mainSchoolId);
- roleRefs.add(ref);
- }
+ for (SysUserRoleRel rel : identityRels.stream()
+ .sorted(Comparator.comparingInt(SysUserRoleRel::getSortNo)
+ .thenComparingInt(SysUserRoleRel::getId))
+ .collect(Collectors.toList())) {
+ PushUserDTO.PushUserRoleRef ref = new PushUserDTO.PushUserRoleRef();
+ ref.setRoleId(rel.getRoleId());
+ ref.setSchId(rel.getSchoolId());
+ roleRefs.add(ref);
}
d.setRoleList(roleRefs);
return d;
}
- /** 多个用户(AddUser / UpdateUser / SyncUser)。 */
- public List toUserDtoList(List users) {
- if (users == null) {
- return Collections.emptyList();
- }
- return users.stream().map(this::toUserDto).collect(Collectors.toList());
- }
-
- /** 取用户主组(isMain=1 取 id 最小,无主兜底 id 最小)所属学校范围 id。 */
- private Integer resolveMainSchoolId(List rels) {
- SysUserGroupRel mainRel = mainGroupResolver.resolve(rels);
- if (mainRel == null) {
- return null;
- }
- return groupScopeHelper.resolveSchoolId(mainRel.getGroupId());
- }
-
// ===================== 公共工具 =====================
/** 构建 parentId → 直接子节点列表(null 视为根 0),同层按 sort / id 升序。 */
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 f9ca0af..e9727de 100644
--- a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java
+++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceModuleTreeTest.java
@@ -3,10 +3,13 @@ package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.userbase.entity.AppModule;
import com.yunhai.userbase.entity.ApplicationInfo;
+import com.yunhai.userbase.entity.SysAppRole;
+import com.yunhai.userbase.entity.SysAppRoleModuleRel;
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.SysUserAppRoleRel;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.UserTypeEnum;
@@ -32,6 +35,7 @@ import org.springframework.context.ApplicationEventPublisher;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
@@ -40,7 +44,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.ArgumentMatchers.anyIterable;
import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/**
@@ -89,6 +96,8 @@ class GroupAuthzServiceModuleTreeTest {
private ApplicationEventPublisher eventPublisher;
@Mock
private com.yunhai.userbase.service.resolve.MainGroupResolver mainGroupResolver;
+ @Mock
+ private com.yunhai.userbase.service.ISysGroupService sysGroupService;
@BeforeEach
void setUp() {
@@ -280,6 +289,108 @@ class GroupAuthzServiceModuleTreeTest {
assertEquals("101", only(roots.get(0).getChildren()).getCustomId());
}
+ // ===================== 角色授权取数按学校收窄(2026-09-18,D5) =====================
+
+ /** 两校组树:27 信阳教育局 ─ 16 云海中学(29 英语组) / 28 信阳高级中学(40 教师组)。 */
+ private void initTwoSchoolGroups() {
+ List groups = Arrays.asList(
+ group(5, "教育云中心", 0, GroupTypeEnum.NORMAL_GROUP),
+ group(27, "信阳教育局", 5, GroupTypeEnum.AREA_GROUP),
+ group(16, "云海中学", 27, GroupTypeEnum.SCHOOL_GROUP),
+ group(28, "信阳高级中学", 27, GroupTypeEnum.SCHOOL_GROUP),
+ group(29, "英语组", 16, GroupTypeEnum.TEACHER_GROUP),
+ group(40, "教师组", 28, GroupTypeEnum.TEACHER_GROUP));
+ when(groupSnapshotService.snapshot()).thenReturn(new GroupSnapshot(groups));
+ }
+
+ /**
+ * 多校用户的角色绑定按「上下文学校」收窄:schId=A 只认 A 校子树内归属组的绑定,
+ * 不返回他校岗位角色带来的模块;schId 为空(全局模式)维持现状(两者都返回)。
+ */
+ @Test
+ void findUserModulesForApp_roleBindingScopedToContextSchool() {
+ when(sysUserRepository.findById(4)).thenReturn(Optional.of(user(4, UserTypeEnum.NORMAL)));
+ when(sysUserGroupRelRepository.findByUserId(4))
+ .thenReturn(Arrays.asList(rel(4, 29), rel(4, 40)));
+ initTwoSchoolGroups();
+ // 两校都开通该应用(组授权闸门打开;组侧不配模块,只测角色侧取数范围)
+ when(sysGroupAppRelRepository.findByGroupIdIn(anyList())).thenAnswer(inv -> {
+ List ids = inv.getArgument(0);
+ if (ids.contains(16)) {
+ return Collections.singletonList(appRel(16, APP_ID));
+ }
+ if (ids.contains(28)) {
+ return Collections.singletonList(appRel(28, APP_ID));
+ }
+ return Collections.emptyList();
+ });
+ // 角色绑定:R1 绑在 A 校组 29,R2 绑在 B 校组 40,各带一个模块
+ SysUserAppRoleRel bindA = userAppRoleRel(4, 29, 900);
+ SysUserAppRoleRel bindB = userAppRoleRel(4, 40, 901);
+ when(sysUserAppRoleRelRepository.findEffectiveByUserId(eq(4), anyCollection())).thenAnswer(inv -> {
+ Collection groupIds = inv.getArgument(1);
+ List hit = new ArrayList<>();
+ if (groupIds.contains(29)) {
+ hit.add(bindA);
+ }
+ if (groupIds.contains(40)) {
+ hit.add(bindB);
+ }
+ return hit;
+ });
+ when(sysAppRoleRepository.findAllById(anyIterable())).thenAnswer(inv -> {
+ Collection ids = inv.getArgument(0);
+ List roles = new ArrayList<>();
+ if (ids.contains(900)) {
+ roles.add(appRole(900));
+ }
+ if (ids.contains(901)) {
+ roles.add(appRole(901));
+ }
+ return roles;
+ });
+ when(sysAppRoleModuleRelRepository.findByRoleIdIn(anyList())).thenAnswer(inv -> {
+ List ids = inv.getArgument(0);
+ List rels = new ArrayList<>();
+ if (ids.contains(900)) {
+ rels.add(roleModuleRel(900, 100));
+ }
+ if (ids.contains(901)) {
+ rels.add(roleModuleRel(901, 101));
+ }
+ return rels;
+ });
+ AppModule m100 = module(100, "100", null, 1, 10);
+ AppModule m101 = module(101, "101", null, 1, 20);
+ when(appModuleRepository.findByAppIdIn(anyList()))
+ .thenReturn(new ArrayList<>(Arrays.asList(m100, m101)));
+ // 角色模块归属应用过滤走 findAllById(角色授权并集的 appId 过滤)
+ when(appModuleRepository.findAllById(anyIterable()))
+ .thenAnswer(inv -> {
+ Collection ids = inv.getArgument(0);
+ List hit = new ArrayList<>();
+ if (ids.contains(100)) {
+ hit.add(m100);
+ }
+ if (ids.contains(101)) {
+ hit.add(m101);
+ }
+ return hit;
+ });
+ when(sysGroupModulePermRepository.findByModuleIdInAndGroupIdIn(anyList(), anyList()))
+ .thenReturn(Collections.emptyList());
+
+ // A 校上下文:只带 A 校岗位角色(900 → 模块 100)的模块,B 校绑定被排除
+ assertEquals(Collections.singletonList(100),
+ customIds(groupAuthzService.findUserModulesForApp(APP_ID, 16, 4)));
+ // B 校上下文:只带 B 校岗位角色(901 → 模块 101)的模块
+ assertEquals(Collections.singletonList(101),
+ customIds(groupAuthzService.findUserModulesForApp(APP_ID, 28, 4)));
+ // 无学校上下文(现状):两校绑定都生效
+ assertEquals(Arrays.asList(100, 101),
+ customIds(groupAuthzService.findUserModulesForApp(APP_ID, null, 4)));
+ }
+
// ===================== 夹具 =====================
/** 超管短路:只需 app 存在 + 用户为超管 + 该应用下模块清单。 */
@@ -342,6 +453,29 @@ class GroupAuthzServiceModuleTreeTest {
return r;
}
+ private static SysUserAppRoleRel userAppRoleRel(Integer userId, Integer groupId, Integer roleId) {
+ SysUserAppRoleRel r = new SysUserAppRoleRel();
+ r.setUserId(userId);
+ r.setGroupId(groupId);
+ r.setRoleId(roleId);
+ return r;
+ }
+
+ private static SysAppRole appRole(Integer id) {
+ SysAppRole r = new SysAppRole();
+ r.setId(id);
+ r.setName("角色" + id);
+ r.setDisabled(Boolean.FALSE);
+ return r;
+ }
+
+ private static SysAppRoleModuleRel roleModuleRel(Integer roleId, Integer moduleId) {
+ SysAppRoleModuleRel r = new SysAppRoleModuleRel();
+ r.setRoleId(roleId);
+ r.setModuleId(moduleId);
+ return r;
+ }
+
private static SysGroupAppRel appRel(Integer groupId, Integer appId) {
SysGroupAppRel r = new SysGroupAppRel();
r.setGroupId(groupId);
diff --git a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java
index 4c50aba..26bad8a 100644
--- a/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java
+++ b/src/test/java/com/yunhai/userbase/service/impl/GroupAuthzServiceTest.java
@@ -90,11 +90,26 @@ class GroupAuthzServiceTest {
private SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
+ @Mock
+ private com.yunhai.userbase.service.ISysGroupService sysGroupService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
initGroups();
+ stubGroupService();
+ }
+
+ /**
+ * 组服务桩(2026-09-18 角色授权取数收窄新增依赖):沿用 initGroups 的固定层级,
+ * 上下文组 → 所在学校(16) → 学校子树 [16,29,30,31]。
+ */
+ private void stubGroupService() {
+ when(sysGroupService.resolveSchoolId(29)).thenReturn(16);
+ when(sysGroupService.resolveSchoolId(30)).thenReturn(16);
+ when(sysGroupService.resolveSchoolId(31)).thenReturn(16);
+ when(sysGroupService.resolveSchoolId(16)).thenReturn(16);
+ when(sysGroupService.listSelfAndDescendantIds(16)).thenReturn(Arrays.asList(16, 29, 30, 31));
}
/**
diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceGroupUsersTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceGroupUsersTest.java
index 7bdf069..bdbcb0e 100644
--- a/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceGroupUsersTest.java
+++ b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceGroupUsersTest.java
@@ -4,10 +4,12 @@ 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.StatusEnum;
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 com.yunhai.userbase.repository.SysUserRoleRelRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@@ -27,7 +29,7 @@ import static org.mockito.Mockito.when;
/**
* {@code SysGroupService.listGroupUsers} 单元测试(开放接口 {@code /oapi/group/users})。
- * 覆盖:withSub 展开全部子孙组 / 仅本组、多组归属按用户去重、角色过滤(事实源 sys_user.roles,
+ * 覆盖:withSub 展开全部子孙组 / 仅本组、多组归属按用户去重、角色过滤(事实源身份行 sys_user_role_rel,
* 与 PushDispatcher 口径一致)、空结果与空 groupId 边界。
*/
class SysGroupServiceGroupUsersTest {
@@ -41,6 +43,9 @@ class SysGroupServiceGroupUsersTest {
@Mock
private SysUserRepository sysUserRepository;
+ @Mock
+ private SysUserRoleRelRepository sysUserRoleRelRepository;
+
@Mock
private GroupSnapshotService groupSnapshotService;
@@ -123,26 +128,34 @@ class SysGroupServiceGroupUsersTest {
verify(sysUserGroupRelRepository).findByGroupIdIn(Collections.singletonList(1));
}
- /** 角色过滤:事实源 sys_user.roles 快照,任一命中即保留;roles 为 null 的用户被排除(与 PushDispatcher 口径一致)。 */
+ /**
+ * 角色过滤:事实源 = 身份行 sys_user_role_rel(正常态),任一命中即保留;无命中身份行的用户被排除
+ * (与 PushDispatcher 推送角色过滤口径一致,共用 findUserIdsWithAnyRole)。
+ * 用例刻意让 roles 快照列与身份行口径相反:u1/u3 的 roles 为 null 但身份行命中(旧口径会误排除),
+ * u4 的 roles 快照含 1 但无身份行(旧口径会误保留)——回退到读 sys_user.roles 时本用例必失败。
+ */
@Test
- void listGroupUsers_roleFilter_usesRolesSnapshotSameAsPushDispatcher() {
+ void listGroupUsers_roleFilter_usesIdentityRowsSameAsPushDispatcher() {
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))));
+ user(1, "u1", "教师用户", UserTypeEnum.NORMAL, null),
+ user(2, "u2", "无身份", UserTypeEnum.NORMAL, null),
+ user(3, "u3", "学生用户", UserTypeEnum.NORMAL, null),
+ user(4, "u4", "快照列残留角色", UserTypeEnum.NORMAL, Arrays.asList(1, 3))));
+ // 身份行口径:仅 u1(教师1+学生3)与 u3(学生3)命中,u2/u4 无命中身份行
+ when(sysUserRoleRelRepository.findUserIdsWithAnyRole(any(), any(), any()))
+ .thenReturn(Arrays.asList(1, 3));
- // 过滤 教师(1)+学生(3):命中的是 u1(含1)、u3(含3)
+ // 过滤 教师(1)+学生(3):命中的是 u1、u3
List result = sysGroupService.listGroupUsers(1, false, "1,3");
assertEquals(Arrays.asList(1, 3), boaIds(result));
}
- /** 未传角色过滤:全量返回(roles 为 null 也保留)。 */
+ /** 未传角色过滤:全量返回(不查身份行)。 */
@Test
void listGroupUsers_noRoleFilter_returnsAll() {
stubSnapshot();
@@ -180,4 +193,21 @@ class SysGroupServiceGroupUsersTest {
void listGroupUsers_nullGroupId_returnsEmpty() {
assertTrue(sysGroupService.listGroupUsers(null, false, null).isEmpty());
}
-}
\ No newline at end of file
+ /** 轻量 BoaUser 必须带 status(1=正常/0=禁用,与 BoaUserLoader 同映射):下游选人可判禁用账号。 */
+ @Test
+ void listGroupUsers_lightBoaUser_carriesStatus() {
+ stubSnapshot();
+ when(sysUserGroupRelRepository.findByGroupIdIn(any()))
+ .thenReturn(Arrays.asList(rel(1, 1), rel(2, 1)));
+ SysUser enabled = user(1, "u1", "正常用户", UserTypeEnum.NORMAL, null);
+ enabled.setDisabled(StatusEnum.NORMAL);
+ SysUser disabled = user(2, "u2", "禁用用户", UserTypeEnum.NORMAL, null);
+ disabled.setDisabled(StatusEnum.DISABLED);
+ when(sysUserRepository.findAllById(any())).thenReturn(Arrays.asList(enabled, disabled));
+
+ List result = sysGroupService.listGroupUsers(1, false, null);
+
+ assertEquals(Integer.valueOf(1), result.get(0).getStatus(), "正常账号 → status=1");
+ assertEquals(Integer.valueOf(0), result.get(1).getStatus(), "禁用账号 → status=0");
+ }
+}
diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java
index e325717..86ccee9 100644
--- a/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java
+++ b/src/test/java/com/yunhai/userbase/service/impl/SysUserServiceTest.java
@@ -7,7 +7,12 @@ import com.yunhai.userbase.entity.SysUserCurrentRole;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
+import com.yunhai.userbase.enums.StatusEnum;
+import com.yunhai.userbase.enums.UserGroupRelStatusEnum;
import com.yunhai.userbase.event.UserChangedEvent;
+import com.yunhai.userbase.model.sysuser.SysUserQuery;
+import com.yunhai.userbase.model.sysuser.SysUserRequest;
+import com.yunhai.userbase.model.sysuser.SysUserVo;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
@@ -22,17 +27,30 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Objects;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -54,6 +72,8 @@ class SysUserServiceTest {
@Mock
private SysUserRoleRelRepository sysUserRoleRelRepository;
@Mock
+ private com.yunhai.userbase.repository.SysUserAppRoleRelRepository sysUserAppRoleRelRepository;
+ @Mock
private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
@Mock
private ISysGroupService sysGroupService;
@@ -186,6 +206,77 @@ class SysUserServiceTest {
verify(sysUserCurrentRoleRepository).delete(current);
}
+ // ===================== 列表「用户身份」列的组范围口径 =====================
+
+ /** 身份行工厂:含 roleId(roleRel 仅给 alignIdentity 用,不含角色)。 */
+ private SysUserRoleRel identityRel(Integer id, Integer userId, Integer schoolId, Integer roleId) {
+ SysUserRoleRel r = roleRel(id, userId, schoolId);
+ r.setRoleId(roleId);
+ return r;
+ }
+
+ /** 组内列表公共桩:组 100 下 1 个用户 10001,页面返回该用户。 */
+ private void stubGroupPage() {
+ Page