fix(identity): 身份读侧口径统一 + 写口收口(退组/转学/本校移动/追加语义/按校收窄)

读侧(同一根因:sys_user.roles 停写后读侧漏改):
- 组用户管理列表/导出「用户身份」列只取当前组范围解析出的学校集合内的身份行(原为全平台身份并集)
- 推送 roleList 改读 sys_user_role_rel(原读停写列 → 新建/改过身份的用户恒为空,用于 lwj 空推)
- 推送订阅「推送角色」过滤与 /oapi/group/users 角色过滤共用 findUserIdsWithAnyRole(原读停写列会静默漏人)

写口收口:
- 退组补 alignIdentity(退掉某校最后一个组时清出该校身份行与滞留激活身份,消除已离校校领导仍带该校管理范围)
- 激活身份滞留检查由「只比 schoolId」升级为 (roleId, schoolId) 对(同校校领导→老师后不再返回旧角色)
- 跨校转学把源校正常态身份行复制到目标校(原源校清出、目标校不建 → 用户零身份落地)
- moveUserWithinSchool 只重建本校子树内组关系:他校关系与他校 isMain 一律不动,目标组主标记沿用原本校旧关系(不制造双主组)
- 开放接口编辑身份改追加语义(只加不删,学校缺省按 groupId 解析;BschClass 无需改动),管理端保持全量覆盖并加护栏(有 schoolId 无 roleIds 报错)
- 角色授权模块按上下文学校收窄(schId/groupId 非空时只认该校子树内归属组绑定,NULL 全局兜底行仍生效)
- /oapi/group/users 轻量 BoaUser 补 status;SysUserVo.roles 删除(读死列最后一个展示位)

契约与文档:oapi 接口文档 6 处修订 + SDK(yh-openapi-client)三处注释同步;知识库 §3 新增身份写口纪律、§8 记 D3 决议与三项待决策
测试:139 → 158(+19 例)
This commit is contained in:
chenxing
2026-09-18 11:12:56 +08:00
parent ef5849377a
commit 712e94803f
18 changed files with 1224 additions and 94 deletions
@@ -165,7 +165,8 @@ public class SysUserController {
/**
* 查询用户身份(按所在组解析 schoolId):返回 {schoolId, roleIds};同一学校多组返回同一 schoolId 与并集。<br>
* 「用户身份」回显唯一来源(roles 列已停写,仅历史兼容读取);编辑请求提交时原样携带 {schoolId, roleIds}。
* 「用户身份」回显唯一来源(数据源=身份行 sys_user_role_rel;roles 列已停写、不再读取);
* 编辑请求提交时原样携带 {schoolId, roleIds}。
*/
@GetMapping("UserIdentity")
public UserIdentityVo userIdentity(@RequestParam Integer userId, @RequestParam Integer groupId) {
@@ -73,8 +73,9 @@ public class GroupController {
/**
* 获取组内用户(不分页,一次返回全部):<br>
* withSub=true 时含该组全部子孙组(口径同 {@code /oapi/group/byId} 的 withSub);<br>
* roleIds 逗号分隔(复用 SysRoleEnum 键),按 {@code sys_user.roles} 快照过滤,缺省不过滤。<br>
* 返回裸 {@code List<BoaUser>}(轻量字段 id/account/name/type),不套信封、无分页元数据。
* roleIds 逗号分隔(复用 SysRoleEnum 键),按身份行 {@code sys_user_role_rel}(正常态)过滤,缺省不过滤。<br>
* 返回裸 {@code List<BoaUser>}(轻量字段 id/account/name/type),不套信封、无分页元数据。<br>
* 注意:轻量 BoaUser 不填 {@code status}/组内转出标记,需判禁用或转出请走 {@code /oapi/user/byId}。
*
* @param groupId 组ID(必填)
* @param withSub 是否递归子组,缺省 false
@@ -165,12 +165,15 @@ public class UserController {
}
/**
* 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。
* 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。<br>
* 用户身份按<b>追加语义</b>写({@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);
}
@@ -10,7 +10,7 @@ import java.util.List;
* <ul>
* <li>status:0正常 / 2禁用(由 userbase StatusEnum 映射)</li>
* <li>groups:用户所属组,含 groupId / no / schoolId(schoolId = 组所属学校范围 id)</li>
* <li>roleList:用户身份,含 roleId / schId(schId = 用户主组所属学校范围 id)</li>
* <li>roleList:用户身份,含 roleId / schId(schId = 身份行所属学校,即 sys_user_role_rel.schoolId)</li>
* </ul>
*
* @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;
}
}
@@ -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<Integer> 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)。<br>
* 用户身份名(如「老师、校领导」):列表页按页批量带出(无 N+1)。<br>
* 组上下文列表只取「当前组范围解析出的学校集合」内的身份行(与编辑弹窗「所在组」回显同源);
* 未选组(全平台视角)才展示全部身份;范围内无身份行时留空。<br>
* roles 列已停写(冻结历史值),「用户身份」列展示一律切此字段。
*/
private String identityNames;
@@ -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<SysUserRoleRel,
/** 批量取用户在指定状态下的全部身份行(列表页/导出批量主身份解析用,替代已删除的 findByUserIdInAndIsMain) */
List<SysUserRoleRel> findByUserIdInAndStatus(List<Integer> userIds, StatusEnum status);
/**
* 在给定用户集合中筛出「拥有指定身份角色(指定状态)」的 userId(去重)。<br>
* 供推送订阅的「推送角色」过滤(PushDispatcher.filterUsers)与开放接口组内用户角色过滤
* (SysGroupService.listGroupUsers)共用:两处口径必须一致,事实源为身份行——此前两处都读
* sys_user.roles 快照列,该列已停写(新建/改过身份的用户恒为空),会静默漏掉用户。<br>
* 调用方须保证 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<Integer> findUserIdsWithAnyRole(@Param("userIds") Collection<Integer> userIds,
@Param("roleIds") Collection<Integer> roleIds,
@Param("status") StatusEnum status);
/** 用户在某学校下、某状态下的全部角色关联 */
List<SysUserRoleRel> findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status);
@@ -147,7 +147,9 @@ public interface IGroupAuthzService {
* - 后者返回用户在全部已开通应用下的权限聚合(供管理端权限一览使用);<br>
* - 本方法只返回 {@code appId} 对应这一个应用下的模块,所有 SQL/集合构建均限定该应用(数据库层面过滤)。<br>
* <br>
* 上下文组(schId)语义与开放接口一致:schId 非空=只认该组链路;schId 为空=取用户主学校组(若仍空=用户主归属组 id)。<br>
* 上下文组(schId)语义:schId 非空=只取用户「位于该学校子树内」的真实归属组,逐组建链判定
* (子树内无归属组则仅靠角色授权,不用 schId 冒充本组);schId 为空=取用户全部真实归属组
* (与权限一览「用户全局模式」一致)。<br>
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。<br>
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。<br>
* <br>
@@ -166,8 +166,9 @@ public interface ISysGroupService {
* 获取组内用户(开放接口 {@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>
* 事实源为身份行 {@code sys_user_role_rel}(正常态,与推送 roleList / PushDispatcher 的推送角色过滤
* 口径完全一致,共用 {@code SysUserRoleRelRepository#findUserIdsWithAnyRole})。
* 原「读 {@code sys_user.roles} 快照 JSON」口径已废弃:该列停写后会让新建/改过身份的用户被静默漏掉。<br>
* 返回轻量 BoaUser(仅填 id/account/name/type,控制列表体积),不套信封、无分页元数据。
*
* @param groupId 组ID(必填)
@@ -25,9 +25,21 @@ public interface ISysUserService {
/** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。返回创建后的用户ID。 */
Integer add(SysUserRequest request);
/** 编辑用户;密码为空时不修改密码。 */
/**
* 编辑用户(管理端);密码为空时不修改密码。<br>
* 用户身份按「schoolId 该校全量覆盖」写(勾选框语义);带了 schoolId 却不带 roleIds 直接报错,
* 清空该校身份须显式传空数组。
*/
void edit(Integer id, SysUserRequest request);
/**
* 编辑用户(开放接口 {@code /oapi/user/edit/{id}});资料字段同管理端,用户身份按<b>追加语义</b>写。<br>
* 学校范围 = 显式 {@code schoolId},缺省按 {@code groupId} 解析(与新增口同一约定);该范围内已存在的
* 角色不动、不存在的补一行,<b>不删任何身份行</b>(最坏后果是多重一个身份,不会像覆盖语义那样清空)。
* 删除身份仍只在管理端进行。
*/
void editWithIdentityAppend(Integer id, SysUserRequest request);
/**
* 批量新增用户:逐条走完整校验(含密码强度),返回创建后的用户ID列表(顺序与入参一致)。
*/
@@ -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<AppModule> allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
return assembleModuleTree(allModules);
}
// 3. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用)
Set<Integer> roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId);
// 4. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
// 3. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
// - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身),
// 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块,
// 不把 schId 冒充本组,否则会绕过子组继承闸门、丢本组数据);
// - schId 空:取用户全部真实归属组(与权限一览「用户全局模式」一致)。
// 该集合同时作为「角色授权取数范围」:schId 非空时角色绑定也只认该校子树内的组,
// 否则多校用户在 A 校上下文下会拿到 B 校组的角色授权模块(2026-09-18 收窄)。
List<SysUserGroupRel> userRels = sysUserGroupRelRepository.findByUserId(userId);
Map<Integer, SysGroup> allGroupMap = buildAllGroupMap();
Set<Integer> selfGroupIds = new LinkedHashSet<>();
@@ -579,6 +585,9 @@ public class GroupAuthzService implements IGroupAuthzService {
}
}
}
// 4. 收集该用户启用角色下=属于目标应用的模块ID(角色授权并集,不要求组先开通应用)。
// 取数范围随上下文组收窄:schId 非空 → 只认该校子树内的归属组绑定;schId 空 → 全部所在组(现状)
Set<Integer> roleModuleIdsInApp = collectEnabledRoleModuleIdsForApp(userId, appId, selfGroupIds);
// 5. 构建各归属组的祖先链与开通池,并判定:任意归属组开通池里是否包含目标 appId(应用开通闸门)。
// 归属组为空(该学校子树内无组 / 无学校无组)时仍可能因角色授权而返回模块,不能直接返回空。
Map<Integer, List<Integer>> chainMap = new HashMap<>();
@@ -648,9 +657,12 @@ public class GroupAuthzService implements IGroupAuthzService {
* 收集用户启用角色下=且属于目标应用 appId 的模块ID集合(角色授权并集专用的"应用过滤版")。<br>
* 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块;
* 本方法在其之上再按模块.appId == appId 过滤一次(不额外发 SQL,遍历两次即可)。
*
* @param restrictGroupIds 角色绑定取数的限定组集合(上下文学校收窄);null=用户全部所在组
*/
private Set<Integer> collectEnabledRoleModuleIdsForApp(Integer userId, Integer appId) {
Set<Integer> allRoleModuleIds = collectEnabledRoleModuleIds(userId);
private Set<Integer> collectEnabledRoleModuleIdsForApp(Integer userId, Integer appId,
Collection<Integer> restrictGroupIds) {
Set<Integer> 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<Integer> roleModuleIds = collectEnabledRoleModuleIds(userId);
// 角色授权不依赖组归属与组开通应用,故下方不再因无组归属而提前返回。
// 1. 归属组集合:区分「组上下文模式」和「用户全局模式」
// A) 接口显式传了 groupId(= 管理员在"某个组的授权面板"点用户权限一览,是典型的组视角查询):
@@ -970,6 +981,22 @@ public class GroupAuthzService implements IGroupAuthzService {
}
}
}
// 角色授权取数范围(2026-09-18 收窄,口径与 oapi /oapi/user/modules 的 schId 一致):
// 组视角 → 只认「该组所在学校子树内」的用户归属组绑定(不是上面那套单组判定:角色是校维度的岗位,
// 按学校取全校归属组;避免多校用户在此校视图下带出他校岗位角色授权的模块);
// 全局视角 → 不做限制(用户全部所在组,现状)。
Set<Integer> roleScopeGroupIds = null;
if (groupId != null) {
Integer contextSchoolId = sysGroupService.resolveSchoolId(groupId);
List<Integer> contextSchoolScope = sysGroupService.listSelfAndDescendantIds(contextSchoolId);
roleScopeGroupIds = new LinkedHashSet<>();
for (SysUserGroupRel rel : userRels) {
if (rel.getGroupId() != null && contextSchoolScope.contains(rel.getGroupId())) {
roleScopeGroupIds.add(rel.getGroupId());
}
}
}
Set<Integer> roleModuleIds = collectEnabledRoleModuleIds(userId, roleScopeGroupIds);
// 2. 全部组,构建祖先链
Map<Integer, SysGroup> allGroupMap = buildAllGroupMap();
@@ -1867,11 +1894,29 @@ public class GroupAuthzService implements IGroupAuthzService {
* group_id 为 NULL 的历史行视为全局生效(兜底防迁移脚本未跑导致权限丢失)。<br>
* 调整组不删绑定:旧组上的绑定因组关系重建而自然失效,调回该组自动恢复。<br>
* 禁用角色(disabled=true)不参与权限计算;用户无绑定或角色全被禁用时返回空集合。<br>
* 取数范围不做上下文限制(用户全部所在组)——需要按学校收敛请用
* {@link #collectEnabledRoleModuleIds(Integer, Collection)}。
*/
private Set<Integer> collectEnabledRoleModuleIds(Integer userId) {
return collectEnabledRoleModuleIds(userId, null);
}
/**
* {@link #collectEnabledRoleModuleIds(Integer)} 的上下文限定版(2026-09-18):<br>
* {@code restrictGroupIds} 非空时只认该集合内的归属组绑定——用于"以学校返回子组授权模块":
* oapi {@code /oapi/user/modules} 按 schId 子树、管理端权限一览按上下文组所在学校子树收窄,
* 消除多校用户在 A 校上下文下仍拿到 B 校组的角色授权模块(越权面)。<br>
* NULL 全局兜底行在两种模式下都生效(保持迁移兜底语义);该集合内无归属组时只认 NULL 行。
*
* @param restrictGroupIds 限定组集合;{@code null}=用户全部所在组(现状)
*/
private Set<Integer> collectEnabledRoleModuleIds(Integer userId, Collection<Integer> restrictGroupIds) {
List<Integer> 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());
}
}
@@ -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},不分页一次返回全部)。<br>
* 步骤:① 组范围(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<BoaUser> listGroupUsers(Integer groupId, Boolean withSub, String roleIds) {
@@ -594,24 +600,30 @@ public class SysGroupService implements ISysGroupService {
}
}
List<SysUser> users = sysUserRepository.findAllById(userIds);
// ③ 角色过滤(口径与 PushDispatcher.filterUsers 完全一致:roles 快照 contains 任一即命中)
// ③ 角色过滤(口径与 PushDispatcher.filterUsers 完全一致:身份行 sys_user_role_rel 正常态 contains 任一即命中)
Set<Integer> roleSet = parseRoleIds(roleIds);
if (!roleSet.isEmpty()) {
if (!roleSet.isEmpty() && !users.isEmpty()) {
Set<Integer> 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;
}
@@ -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();
}
/**
* 编辑用户(管理端):身份按「该校全量覆盖」写(勾选框语义 = 该校就该有这几个身份)。<br>
* 身份入参成对校验:带了 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);
}
/**
* 开放接口的用户身份写入(追加语义,只加不删):<br>
* 学校范围 = 显式 schoolId,缺省按 request.groupId 解析(与新增口同一约定,调用方不感知解析逻辑);
* 该范围内已存在的 roleId 不动、不存在的补一行(status=正常),**不删任何身份行**。
* 最坏后果是多重一个身份,不会像覆盖语义那样把该校其它身份删光。<br>
* 追加后仍走 {@link #alignIdentity}:若目标学校与该用户的组关系不匹配(异常数据),刚写的行会被
* 孤儿清理清出——这种情况打 warn 暴露,不静默。删除身份仍只在管理端进行。
*/
private void appendSchoolIdentity(Integer userId, SysUserRequest request) {
validateRoleIds(request.getRoleIds());
List<Integer> roleIds = request.getRoleIds();
if (roleIds == null || roleIds.isEmpty()) {
// 追加语义下空集合即无操作(不承载"清空"表达,清空只在管理端)
return;
}
Integer schoolId = request.getSchoolId() != null
? validateIdentitySchoolId(request.getSchoolId())
: sysGroupService.resolveSchoolId(request.getGroupId());
Set<Integer> existingRoleIds = sysUserRoleRelRepository.findByUserId(userId).stream()
.filter(rel -> Objects.equals(rel.getSchoolId(), schoolId) && rel.getRoleId() != null)
.map(SysUserRoleRel::getRoleId)
.collect(Collectors.toSet());
List<Integer> toAppend = roleIds.stream()
.filter(Objects::nonNull)
.distinct()
.filter(roleId -> !existingRoleIds.contains(roleId))
.collect(Collectors.toList());
if (toAppend.isEmpty()) {
return;
}
LocalDateTime now = LocalDateTime.now();
List<SysUserRoleRel> 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<Integer> 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<Integer> batchAdd(List<SysUserRequest> 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<Integer> 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<SysUser> spec = buildSpecification(query);
Page<SysUser> 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 列已停写(冻结历史值),列表「用户身份」列一律切此新字段展示。
* 列表「用户身份」列的展示口径:只看当前列表组范围内学校的身份行。<br>
* 学校集合 = 组范围(选中组;withSub=1 时含全部子孙组)逐组 {@link ISysGroupService#resolveSchoolId} 去重,
* 与编辑弹窗「所在组」下拉的回显同源({@link #getUserIdentity} 按同一个 resolveSchoolId 取该校身份行)——
* 故甲校的组只显示甲校身份、乙校的组只显示乙校身份,不再是把用户全平台身份并起来展示。<br>
* 选中节点不是学校组(区域组 / 根节点)时不会整列为空:其自身解析出的 id 也在集合内
* (resolveSchoolId 的「学校组 → 区域组 → 根节点」回落链)。
*
* @return 未选组(全平台视角)返回 null,表示不做学校过滤
*/
private Page<SysUserVo> attachMainIdentityNames(Page<SysUserVo> page) {
private Set<Integer> resolveScopeSchoolIds(SysUserQuery query) {
return resolveScopeSchoolIds(query.getGroupId(), query.getWithSub());
}
/** {@link #resolveScopeSchoolIds(SysUserQuery)} 的入参版:导出(不分页、无 Query 对象)复用同一口径。 */
private Set<Integer> resolveScopeSchoolIds(Integer groupId, Integer withSub) {
if (groupId == null) {
return null;
}
List<Integer> groupIds = resolveGroupIds(groupId, withSub);
return new HashSet<>(sysGroupService.resolveSchoolIds(groupIds).values());
}
/** 身份行展示顺序:主身份优先 → sortNo → id(与 IdentityService.getUserRoles 同序)。 */
private Comparator<SysUserRoleRel> 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<Integer, SysUserRoleRel> resolveScopeFirstIdentityByUser(List<Integer> userIds, Set<Integer> scopeSchoolIds) {
Map<Integer, SysUserRoleRel> firstByUser = new HashMap<>();
if (userIds == null || userIds.isEmpty()) {
return firstByUser;
}
Map<Integer, SysUserRoleRel> mainRelsByUser = resolveMainRoleRelsByUser(userIds);
Map<Integer, List<SysUserRoleRel>> relsByUser = sysUserRoleRelRepository
.findByUserIdInAndStatus(userIds, StatusEnum.NORMAL)
.stream()
.filter(r -> scopeSchoolIds == null || scopeSchoolIds.contains(r.getSchoolId()))
.collect(Collectors.groupingBy(SysUserRoleRel::getUserId));
for (Map.Entry<Integer, List<SysUserRoleRel>> 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<SysUserVo> attachIdentityNames(Page<SysUserVo> page, Set<Integer> scopeSchoolIds) {
List<SysUserVo> content = page.getContent();
if (content.isEmpty()) {
return page;
@@ -553,10 +723,11 @@ public class SysUserService implements ISysUserService {
.map(SysUserVo::getId)
.collect(Collectors.toList());
Map<Integer, SysUserRoleRel> mainRelsByUser = resolveMainRoleRelsByUser(userIds);
// 全部正常态身份行按用户分组:列表列展示全部身份名(主身份在前),不只展示主身份
// 范围内正常态身份行按用户分组:列表列展示全部身份名(主身份在前),不只展示主身份
Map<Integer, List<SysUserRoleRel>> 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<SysUserRoleRel> 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 共用):<br>
* ① 学校集合推导:全部组关系(含「转出」标记行——软标记期保留原校身份)逐条解析 schoolId 去重;<br>
* ② 删除不在集合内的身份行(孤儿清理:退组/转校后身份行同步清出);<br>
* ③ 当前激活身份滞留检查:切换记录指向的学校已不在集合(退组/转学)→ 删除该记录,
* 避免 getCurrent/boa.school 继续返回旧校(「激活身份不滞留旧校」)。<br>
* ③ 当前激活身份滞留检查:① 指向的学校已不在集合(退组/转学);② 学校还在但 (roleId, schoolId)
* 已不在剩余身份行里(同校改角色,如校领导降为老师)——两者都删除该记录,避免 getCurrent/boa.school
* 继续返回旧校或用户已不具的角色(「激活身份不滞留旧校/旧角色」)。<br>
* 主身份已去字段化(读时计算),此处不再维护 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<SysUserGroupRel> oldRels = sysUserGroupRelRepository.findByUserId(userId);
List<SysUserGroupRel> inSchoolRels = oldRels.stream()
.filter(r -> r.getGroupId() != null && schoolScope.contains(r.getGroupId()))
.collect(Collectors.toList());
// 状态与主标记均沿用「被替换掉的本校旧关系」,他校任何行(含 isMain)一律不动:
// 状态——行级照抄,用户处于转出流程时新增组继承「转出」(与 changeGroups 既有规则一致);
// 主标记——只有原本校关系里有主组时才把主落到目标组,避免造出双主组;他校是主组时目标组保持非主,
// 即「本校移动不改变该用户的主组指向」。
Map<Integer, UserGroupRelStatusEnum> 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),与导入模板的「参考」列保持一致;<br>
* - 密码:数据库存的是 BCrypt 密文、不可逆,导出留空(再导入时退化为默认 Vzuk4Dp3);<br>
* - 用户性质 nature:取 type 的 key(0普通 / 1超级管理员 / 2学校管理员);<br>
* - 用户身份 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<Integer, Integer> userMainGroup = resolveUserMainGroup(userIds);
// 2.1 批量取每个用户的主校身份行(动态解析主身份),用于填充「用户身份」列(roles 列已停写,不再作为真源)
Map<Integer, SysUserRoleRel> mainRelsByUser = resolveMainRoleRelsByUser(userIds);
// 2.1 批量取「用户身份」列要展示的身份行:与列表同口径,限定在导出组范围内的学校上(groupId 为空的全平台导出不过滤)
Map<Integer, SysUserRoleRel> identityRelsByUser = resolveScopeFirstIdentityByUser(userIds, resolveScopeSchoolIds(groupId, withSub));
// 3. 批量取组名
Map<Integer, String> 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());
@@ -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;
* <li>组 type:GroupTypeEnum.key 直接透传(1-8 零映射)</li>
* <li>用户 status:NORMAL(0)→0正常 / DISABLED(1)→2禁用</li>
* <li>groups[].schoolId:组所属学校范围 id(沿 parentId 向上最近 SCHOOL_GROUP)</li>
* <li>roleList[].schId:用户主组所属学校范围 id(userbase 无角色-学校关联,统一取主校)</li>
* <li>roleList[]:用户身份行(sys_user_role_rel 权威源,正常态),schId = 身份行自带学校维度</li>
* </ul>
*
* @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<PushUserDTO> toUserDtoList(List<SysUser> users) {
if (users == null) {
return Collections.emptyList();
}
Map<Integer, List<SysUserRoleRel>> identityByUser = loadIdentityRows(users);
List<PushUserDTO> 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<Integer, List<SysUserRoleRel>> loadIdentityRows(List<SysUser> users) {
List<Integer> userIds = users.stream()
.filter(Objects::nonNull)
.map(SysUser::getId)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
Map<Integer, List<SysUserRoleRel>> identityByUser = new HashMap<>();
for (int from = 0; from < userIds.size(); from += ID_BATCH_SIZE) {
List<Integer> 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<SysUserRoleRel> 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<SysUserGroupRel> rels = sysUserGroupRelRepository.findByUserId(u.getId());
Integer mainSchoolId = resolveMainSchoolId(rels);
// groups
List<PushUserDTO.PushUserGroupRef> 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<PushUserDTO.PushUserRoleRef> 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<PushUserDTO> toUserDtoList(List<SysUser> 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<SysUserGroupRel> rels) {
SysUserGroupRel mainRel = mainGroupResolver.resolve(rels);
if (mainRel == null) {
return null;
}
return groupScopeHelper.resolveSchoolId(mainRel.getGroupId());
}
// ===================== 公共工具 =====================
/** 构建 parentId → 直接子节点列表(null 视为根 0),同层按 sort / id 升序。 */
@@ -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<SysGroup> 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<Integer> 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<Integer> groupIds = inv.getArgument(1);
List<SysUserAppRoleRel> 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<Integer> ids = inv.getArgument(0);
List<SysAppRole> 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<Integer> ids = inv.getArgument(0);
List<SysAppRoleModuleRel> 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<Integer> ids = inv.getArgument(0);
List<AppModule> 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);
@@ -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));
}
/**
@@ -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})。<br>
* 覆盖: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)。<br>
* 用例刻意让 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<BoaUser> 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());
}
}
/** 轻量 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<BoaUser> 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");
}
}
@@ -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<Object[]> idPage = new PageImpl<>(Collections.singletonList(new Object[]{10001, 1, 0}));
when(sysUserRepository.findGroupUserPage(any(), any(), any(), any())).thenReturn(idPage);
SysUser u = new SysUser();
u.setId(10001);
u.setAccount("lwj");
u.setRealName("刘文娟");
when(sysUserRepository.findAllById(any())).thenReturn(Collections.singletonList(u));
}
/**
* 选中甲校的组:用户身份列只显示甲校身份行(老师),不显示乙校的校领导——
* 修复「整个平台身份并集」;即便主身份落在范围外的乙校,也不能把范围外的身份带进来。
*/
@Test
void list_identityColumn_scopedToSelectedGroupSchool() {
SysUserQuery query = new SysUserQuery();
query.setGroupId(100);
query.setWithSub(0);
stubGroupPage();
when(sysGroupService.resolveSchoolIds(Collections.singletonList(100)))
.thenReturn(Collections.singletonMap(100, 10));
SysUserRoleRel jiaTeacher = identityRel(1, 10001, 10, 1);
SysUserRoleRel yiLeader = identityRel(2, 10001, 20, 2);
when(sysUserRoleRelRepository.findByUserIdInAndStatus(any(), any()))
.thenReturn(Arrays.asList(jiaTeacher, yiLeader));
// 主身份在范围外的乙校:只影响排序,不得绕过范围内过滤
when(identityService.resolveMainIdentityByUser(any()))
.thenReturn(Collections.singletonMap(10001, yiLeader));
List<SysUserVo> content = sysUserService.list(query, PageRequest.of(0, 10)).getContent();
assertEquals(1, content.size());
assertEquals("老师", content.get(0).getIdentityNames());
}
/** 组范围跨校(区域组 withSub=1):范围内两校身份都在展示口径内,按主身份优先 → sortNo → id 拼接。 */
@Test
void list_identityColumn_multiSchoolScopeKeepsBothSchools() {
SysUserQuery query = new SysUserQuery();
query.setGroupId(50);
query.setWithSub(1);
stubGroupPage();
when(sysGroupService.listSelfAndDescendantIds(50)).thenReturn(Arrays.asList(50, 100, 200));
when(sysGroupService.resolveSchoolIds(Arrays.asList(50, 100, 200)))
.thenReturn(new HashMap<Integer, Integer>() {{
put(50, 50);
put(100, 10);
put(200, 20);
}});
when(sysUserRoleRelRepository.findByUserIdInAndStatus(any(), any()))
.thenReturn(Arrays.asList(
identityRel(1, 10001, 10, 1),
identityRel(2, 10001, 20, 2)));
when(identityService.resolveMainIdentityByUser(any())).thenReturn(Collections.emptyMap());
List<SysUserVo> content = sysUserService.list(query, PageRequest.of(0, 10)).getContent();
assertEquals("老师、校领导", content.get(0).getIdentityNames());
}
// ===================== groupState 写口事件补发 + loader 读路径 =====================
/** 组边界校验公共桩:学校 16,子树含 29/31 */
@@ -262,4 +353,375 @@ class SysUserServiceTest {
() -> sysUserService.getUserGroupState(1, 31, 16));
assertEquals("该用户不在此分组中", ex.getMessage());
}
// ===================== P0-1 退组身份对齐(2026-09-18) =====================
private SysUser userEntity(Integer id, String account) {
SysUser u = new SysUser();
u.setId(id);
u.setAccount(account);
u.setDisabled(StatusEnum.NORMAL);
return u;
}
private SysUserGroupRel groupRel(Integer id, Integer userId, Integer groupId, Integer isMain,
UserGroupRelStatusEnum status) {
SysUserGroupRel r = new SysUserGroupRel();
r.setId(id);
r.setUserId(userId);
r.setGroupId(groupId);
r.setIsMain(isMain);
r.setStatus(status);
return r;
}
private SysUserCurrentRole currentRole(Integer userId, Integer roleId, Integer schoolId) {
SysUserCurrentRole c = new SysUserCurrentRole();
c.setUserId(userId);
c.setRoleId(roleId);
c.setSchoolId(schoolId);
return c;
}
private SysUserRequest editRequest(String account, Integer schoolId, List<Integer> roleIds) {
SysUserRequest request = new SysUserRequest();
request.setAccount(account);
request.setSchoolId(schoolId);
request.setRoleIds(roleIds);
return request;
}
/** 退组后仍属其它组 → 触发 alignIdentity:清出已离开学校的身份行,保留他校行。 */
@Test
void batchDeleteFromGroup_stillInOtherGroups_cleansLeftSchoolIdentity() {
when(sysUserRepository.findAllById(Collections.singletonList(1)))
.thenReturn(Collections.singletonList(userEntity(1, "u1")));
when(sysUserGroupRelRepository.countByUserId(1)).thenReturn(1L);
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(groupRel(2, 1, 200, 1, UserGroupRelStatusEnum.NORMAL)));
when(sysGroupService.resolveSchoolId(200)).thenReturn(30);
SysUserRoleRel orphan = identityRel(11, 1, 16, 1);
SysUserRoleRel kept = identityRel(12, 1, 30, 1);
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Arrays.asList(orphan, kept));
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty());
sysUserService.batchDeleteFromGroup(Collections.singletonList(1), 100);
ArgumentCaptor<SysUserRoleRel> deleted = ArgumentCaptor.forClass(SysUserRoleRel.class);
verify(sysUserRoleRelRepository).delete(deleted.capture());
assertSame(orphan, deleted.getValue(), "只清出已退出学校(16)的身份行,他校(30)保留");
}
/** 退组后已无任何组关系 → 级联删户,身份行由 bulk 清理(不逐个走孤儿清理)。 */
@Test
void batchDeleteFromGroup_noGroupLeft_cascadesUserDelete() {
when(sysUserRepository.findAllById(Collections.singletonList(1)))
.thenReturn(Collections.singletonList(userEntity(1, "u1")));
when(sysUserGroupRelRepository.countByUserId(1)).thenReturn(0L);
sysUserService.batchDeleteFromGroup(Collections.singletonList(1), 100);
verify(sysUserRoleRelRepository).deleteByUserIdBulk(1);
verify(sysUserCurrentRoleRepository).deleteByUserIdBulk(1);
verify(sysUserRepository).deleteById(1);
verify(sysUserRoleRelRepository, never()).delete(any(SysUserRoleRel.class));
}
/** 同校多组退其一 → 学校仍在集合内,身份行不得被清。 */
@Test
void batchDeleteFromGroup_sameSchoolOtherGroup_keepsIdentity() {
when(sysUserRepository.findAllById(Collections.singletonList(1)))
.thenReturn(Collections.singletonList(userEntity(1, "u1")));
when(sysUserGroupRelRepository.countByUserId(1)).thenReturn(1L);
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(groupRel(2, 1, 101, 1, UserGroupRelStatusEnum.NORMAL)));
when(sysGroupService.resolveSchoolId(101)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(identityRel(11, 1, 16, 1)));
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty());
sysUserService.batchDeleteFromGroup(Collections.singletonList(1), 100);
verify(sysUserRoleRelRepository, never()).delete(any(SysUserRoleRel.class));
}
// ===================== P0-2 激活身份滞留(2026-09-18) =====================
/** 同校把校领导改成老师:激活身份 (校领导, 该校) 已不在身份行里 → 必须清出。 */
@Test
void edit_sameSchoolRoleDowngraded_clearsStaleCurrentIdentity() {
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(sysUserRepository.countByAccountAndIdNot("u1", 1)).thenReturn(0L);
when(sysUserRepository.findById(1)).thenReturn(Optional.of(userEntity(1, "u1")));
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(groupRel(1, 1, 100, 1, UserGroupRelStatusEnum.NORMAL)));
when(sysGroupService.resolveSchoolId(100)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(identityRel(11, 1, 16, 1)));
SysUserCurrentRole current = currentRole(1, 2, 16);
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.of(current));
sysUserService.edit(1, editRequest("u1", 16, Collections.singletonList(1)));
verify(sysUserCurrentRoleRepository).delete(current);
}
/** 激活身份仍有效(角色行仍在)→ 不得误删。 */
@Test
void edit_identityUnchanged_keepsCurrentIdentity() {
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(sysUserRepository.countByAccountAndIdNot("u1", 1)).thenReturn(0L);
when(sysUserRepository.findById(1)).thenReturn(Optional.of(userEntity(1, "u1")));
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(groupRel(1, 1, 100, 1, UserGroupRelStatusEnum.NORMAL)));
when(sysGroupService.resolveSchoolId(100)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(identityRel(11, 1, 16, 1)));
when(sysUserCurrentRoleRepository.findByUserId(1))
.thenReturn(Optional.of(currentRole(1, 1, 16)));
sysUserService.edit(1, editRequest("u1", 16, Collections.singletonList(1)));
verify(sysUserCurrentRoleRepository, never()).delete(any(SysUserCurrentRole.class));
}
/** 激活身份指向的学校已不在组关系集合(退校)→ 清出。 */
@Test
void edit_schoolLeft_clearsCurrentIdentityOfThatSchool() {
when(sysGroupRepository.existsById(16)).thenReturn(true);
when(sysUserRepository.countByAccountAndIdNot("u1", 1)).thenReturn(0L);
when(sysUserRepository.findById(1)).thenReturn(Optional.of(userEntity(1, "u1")));
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(groupRel(1, 1, 200, 1, UserGroupRelStatusEnum.NORMAL)));
when(sysGroupService.resolveSchoolId(200)).thenReturn(30);
when(sysUserRoleRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(identityRel(11, 1, 16, 1)));
SysUserCurrentRole current = currentRole(1, 1, 16);
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.of(current));
sysUserService.edit(1, editRequest("u1", 16, Collections.singletonList(1)));
verify(sysUserCurrentRoleRepository).delete(current);
}
// ===================== P0-3 跨校转学复制身份(2026-09-18) =====================
private SysUserGroupRel transferredOutRel(Integer id, Integer userId, Integer groupId) {
return groupRel(id, userId, groupId, 0, UserGroupRelStatusEnum.TRANSFERRED_OUT);
}
private void stubTransferCommon() {
when(sysUserRepository.existsById(1)).thenReturn(true);
when(sysGroupService.listSelfAndDescendantIds(16)).thenReturn(Arrays.asList(16, 29, 30));
when(sysGroupService.listSelfAndDescendantIds(28)).thenReturn(Arrays.asList(28, 40));
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty());
}
/** 跨校转入:源校正常态身份行复制到目标校(否则用户零身份落地)。 */
@Test
void transferUserToSchool_crossSchool_copiesSourceIdentities() {
stubTransferCommon();
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(transferredOutRel(1, 1, 29)));
when(sysUserRoleRelRepository.findByUserIdAndSchoolIdAndStatus(1, 16, StatusEnum.NORMAL))
.thenReturn(Collections.singletonList(identityRel(11, 1, 16, 1)));
when(sysUserRoleRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(identityRel(12, 1, 28, 1)));
when(sysGroupService.resolveSchoolId(40)).thenReturn(28);
sysUserService.transferUserToSchool(1, 16, 28, 40);
// 目标校旧身份行先清(saveSchoolIdentity 语义),再落源校角色集合
verify(sysUserRoleRelRepository).deleteByUserIdAndSchoolId(1, 28);
ArgumentCaptor<List<SysUserRoleRel>> saved = ArgumentCaptor.forClass(List.class);
verify(sysUserRoleRelRepository, atLeastOnce()).saveAll(saved.capture());
boolean copied = saved.getAllValues().stream().flatMap(List::stream)
.anyMatch(r -> Objects.equals(r.getSchoolId(), 28) && Objects.equals(r.getRoleId(), 1));
assertTrue(copied, "源校角色(1)必须复制到目标校(28)");
}
/** 源校本就没有身份行 → 不凭空造身份(保持与现状一致)。 */
@Test
void transferUserToSchool_crossSchool_noSourceIdentity_doesNotInvent() {
stubTransferCommon();
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(transferredOutRel(1, 1, 29)));
when(sysUserRoleRelRepository.findByUserIdAndSchoolIdAndStatus(1, 16, StatusEnum.NORMAL))
.thenReturn(Collections.emptyList());
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
when(sysGroupService.resolveSchoolId(40)).thenReturn(28);
sysUserService.transferUserToSchool(1, 16, 28, 40);
verify(sysUserRoleRelRepository, never()).deleteByUserIdAndSchoolId(any(), any());
verify(sysUserRoleRelRepository, never()).saveAll(anyList());
}
/** 同校转组:不读源校身份、不复制(学校没变)。 */
@Test
void transferUserToSchool_sameSchool_doesNotCopy() {
when(sysUserRepository.existsById(1)).thenReturn(true);
when(sysGroupService.listSelfAndDescendantIds(16)).thenReturn(Arrays.asList(16, 29, 30));
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(transferredOutRel(1, 1, 29)));
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty());
when(sysGroupService.resolveSchoolId(30)).thenReturn(16);
sysUserService.transferUserToSchool(1, 16, 16, 30);
verify(sysUserRoleRelRepository, never()).findByUserIdAndSchoolIdAndStatus(any(), any(), any());
verify(sysUserRoleRelRepository, never()).deleteByUserIdAndSchoolId(any(), any());
}
// ===================== P0-4 本校范围移动(2026-09-18) =====================
private void stubMoveCommon() {
when(sysUserRepository.existsById(1)).thenReturn(true);
when(sysGroupRepository.existsById(30)).thenReturn(true);
when(sysGroupService.listSelfAndDescendantIds(16)).thenReturn(Arrays.asList(16, 29, 30, 31));
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty());
}
/** 单校用户:本校移动 = 删本校旧关系 + 目标组承接主标记(与调整组现状等价)。 */
@Test
void moveUserWithinSchool_singleSchool_replacesInSchoolRelation() {
stubMoveCommon();
SysUserGroupRel old = groupRel(1, 1, 29, 1, UserGroupRelStatusEnum.NORMAL);
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.singletonList(old));
when(sysGroupService.resolveSchoolId(30)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
sysUserService.moveUserWithinSchool(1, 30, 16);
verify(sysUserGroupRelRepository).deleteAll(Collections.singletonList(old));
ArgumentCaptor<SysUserGroupRel> saved = ArgumentCaptor.forClass(SysUserGroupRel.class);
verify(sysUserGroupRelRepository).save(saved.capture());
assertEquals(Integer.valueOf(30), saved.getValue().getGroupId());
assertEquals(Integer.valueOf(1), saved.getValue().getIsMain());
assertEquals(UserGroupRelStatusEnum.NORMAL, saved.getValue().getStatus());
}
/** 多校用户:只删本校行,他校组关系与他校主标记一律不动,目标组不设主(不制造双主组)。 */
@Test
void moveUserWithinSchool_multiSchool_keepsOtherSchoolIntact() {
stubMoveCommon();
SysUserGroupRel inSchool = groupRel(1, 1, 29, 0, UserGroupRelStatusEnum.NORMAL);
SysUserGroupRel otherSchoolMain = groupRel(2, 1, 200, 1, UserGroupRelStatusEnum.NORMAL);
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Arrays.asList(inSchool, otherSchoolMain));
when(sysGroupService.resolveSchoolId(30)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
sysUserService.moveUserWithinSchool(1, 30, 16);
verify(sysUserGroupRelRepository).deleteAll(Collections.singletonList(inSchool));
ArgumentCaptor<SysUserGroupRel> saved = ArgumentCaptor.forClass(SysUserGroupRel.class);
verify(sysUserGroupRelRepository).save(saved.capture());
assertEquals(0, saved.getValue().getIsMain(), "他校是主组时,目标组不得设主(避免双主组)");
verify(sysUserGroupRelRepository, never()).save(argThat(r -> Objects.equals(r.getGroupId(), 200)));
}
/** 本校旧关系是主组:主标记随目标组承接(主组指向不因移动而丢失)。 */
@Test
void moveUserWithinSchool_inSchoolMain_targetTakesMain() {
stubMoveCommon();
SysUserGroupRel old = groupRel(1, 1, 29, 1, UserGroupRelStatusEnum.NORMAL);
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.singletonList(old));
when(sysGroupService.resolveSchoolId(30)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
sysUserService.moveUserWithinSchool(1, 30, 16);
ArgumentCaptor<SysUserGroupRel> saved = ArgumentCaptor.forClass(SysUserGroupRel.class);
verify(sysUserGroupRelRepository).save(saved.capture());
assertEquals(Integer.valueOf(1), saved.getValue().getIsMain());
}
/** 转出流程中移动:目标组继承「转出」标记(与调整组的既有继承规则一致)。 */
@Test
void moveUserWithinSchool_inTransferFlow_targetInheritsTransferredOut() {
stubMoveCommon();
SysUserGroupRel normal = groupRel(1, 1, 29, 0, UserGroupRelStatusEnum.NORMAL);
SysUserGroupRel out = groupRel(2, 1, 31, 0, UserGroupRelStatusEnum.TRANSFERRED_OUT);
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Arrays.asList(normal, out));
when(sysGroupService.resolveSchoolId(30)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
sysUserService.moveUserWithinSchool(1, 30, 16);
ArgumentCaptor<SysUserGroupRel> saved = ArgumentCaptor.forClass(SysUserGroupRel.class);
verify(sysUserGroupRelRepository).save(saved.capture());
assertEquals(UserGroupRelStatusEnum.TRANSFERRED_OUT, saved.getValue().getStatus());
}
// ===================== P1-1/P1-2 编辑身份入参与追加语义(2026-09-18) =====================
/** 管理端编辑:带了 schoolId 却不带 roleIds → 直接报错,禁止静默清空该校身份。 */
@Test
void edit_schoolIdWithoutRoleIds_throws() {
SysUserRequest request = editRequest("u1", 16, null);
DException ex = assertThrows(DException.class, () -> sysUserService.edit(1, request));
assertTrue(ex.getMessage().contains("roleIds"));
verify(sysUserRoleRelRepository, never()).deleteByUserIdAndSchoolId(any(), any());
}
/** 开放接口编辑(追加模式):缺失角色补一行,学校取 groupId 解析结果,全程不删任何行。 */
@Test
void editWithIdentityAppend_missingRole_appendsInResolvedSchool() {
when(sysUserRepository.countByAccountAndIdNot("u1", 1)).thenReturn(0L);
when(sysUserRepository.findById(1)).thenReturn(Optional.of(userEntity(1, "u1")));
when(sysGroupService.resolveSchoolId(29)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(groupRel(1, 1, 29, 1, UserGroupRelStatusEnum.NORMAL)));
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty());
SysUserRequest request = editRequest("u1", null, Collections.singletonList(1));
request.setGroupId(29);
sysUserService.editWithIdentityAppend(1, request);
ArgumentCaptor<List<SysUserRoleRel>> saved = ArgumentCaptor.forClass(List.class);
verify(sysUserRoleRelRepository, atLeastOnce()).saveAll(saved.capture());
boolean appended = saved.getAllValues().stream().flatMap(List::stream)
.anyMatch(r -> Objects.equals(r.getSchoolId(), 16) && Objects.equals(r.getRoleId(), 1));
assertTrue(appended, "缺失的角色(1)应追加到解析出的学校(16)");
verify(sysUserRoleRelRepository, never()).delete(any(SysUserRoleRel.class));
verify(sysUserRoleRelRepository, never()).deleteByUserIdAndSchoolId(any(), any());
}
/** 追加模式幂等:角色已存在 → 不产生新行(且依然不删行)。 */
@Test
void editWithIdentityAppend_existingRole_isIdempotent() {
when(sysUserRepository.countByAccountAndIdNot("u1", 1)).thenReturn(0L);
when(sysUserRepository.findById(1)).thenReturn(Optional.of(userEntity(1, "u1")));
when(sysGroupService.resolveSchoolId(29)).thenReturn(16);
when(sysUserRoleRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(identityRel(11, 1, 16, 1)));
when(sysUserGroupRelRepository.findByUserId(1))
.thenReturn(Collections.singletonList(groupRel(1, 1, 29, 1, UserGroupRelStatusEnum.NORMAL)));
when(sysUserCurrentRoleRepository.findByUserId(1)).thenReturn(Optional.empty());
SysUserRequest request = editRequest("u1", null, Collections.singletonList(1));
request.setGroupId(29);
sysUserService.editWithIdentityAppend(1, request);
// 幂等:角色已存在 → 一次身份写入都不发生(既不插新行,也不删行)
verify(sysUserRoleRelRepository, never()).saveAll(anyList());
verify(sysUserRoleRelRepository, never()).delete(any(SysUserRoleRel.class));
}
/** 追加模式:roleIds 为空/未传 → 完全不碰身份(不解析学校、不写库)。 */
@Test
void editWithIdentityAppend_emptyRoleIds_noIdentityWrite() {
when(sysUserRepository.countByAccountAndIdNot("u1", 1)).thenReturn(0L);
when(sysUserRepository.findById(1)).thenReturn(Optional.of(userEntity(1, "u1")));
sysUserService.editWithIdentityAppend(1, editRequest("u1", null, Collections.emptyList()));
verify(sysGroupService, never()).resolveSchoolId(any());
verify(sysUserRoleRelRepository, never()).saveAll(anyList());
verify(sysUserRoleRelRepository, never()).delete(any(SysUserRoleRel.class));
}
}
@@ -0,0 +1,145 @@
package com.yunhai.userbase.service.push;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.push.dto.PushUserDTO;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRoleRelRepository;
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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* {@link PushPayloadAssembler} 用户体(user/AddUser、user/UpdateUser、user/SyncUser)roleList 装配测试。<br>
* 事实源为身份行 sys_user_role_rel(正常态)——roles 快照列已停写,曾经导致 roleList 空推(如 lwj/10133)。
*/
class PushPayloadAssemblerRoleListTest {
@InjectMocks
private PushPayloadAssembler assembler;
@Mock
private SysGroupRepository sysGroupRepository;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Mock
private SysUserRoleRelRepository sysUserRoleRelRepository;
@Mock
private GroupScopeHelper groupScopeHelper;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
private SysUser user(Integer id, List<Integer> frozenRoles) {
SysUser u = new SysUser();
u.setId(id);
u.setAccount("u" + id);
u.setRealName("用户" + id);
u.setRoles(frozenRoles);
return u;
}
private SysUserRoleRel identityRel(Integer id, Integer userId, Integer roleId, Integer schoolId, Integer sortNo) {
SysUserRoleRel r = new SysUserRoleRel();
r.setId(id);
r.setUserId(userId);
r.setRoleId(roleId);
r.setSchoolId(schoolId);
r.setSortNo(sortNo);
r.setStatus(StatusEnum.NORMAL);
return r;
}
/** 单用户:roleList 取自身份行,schId = 身份行学校;roles 快照列为空也不再空推。 */
@Test
void toUserDto_roleList_comesFromIdentityRows() {
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
when(groupScopeHelper.resolveSchoolId(any())).thenReturn(null);
when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL))
.thenReturn(Arrays.asList(
identityRel(11, 1, 1, 10, 0),
identityRel(12, 1, 2, 20, 1)));
// roles 快照列为 null(停写后的真实状态):旧实现在此处产出空 roleList
PushUserDTO dto = assembler.toUserDto(user(1, null));
List<PushUserDTO.PushUserRoleRef> roleList = dto.getRoleList();
assertEquals(2, roleList.size());
assertEquals(Integer.valueOf(1), roleList.get(0).getRoleId());
assertEquals(Integer.valueOf(10), roleList.get(0).getSchId());
assertEquals(Integer.valueOf(2), roleList.get(1).getRoleId());
assertEquals(Integer.valueOf(20), roleList.get(1).getSchId());
}
/** 回归护栏:快照列残留旧角色但没有任何身份行时,roleList 必须为空——禁止回退去读 sys_user.roles。 */
@Test
void toUserDto_roleList_ignoresRolesSnapshotColumn() {
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.emptyList());
when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL))
.thenReturn(Collections.emptyList());
PushUserDTO dto = assembler.toUserDto(user(1, Arrays.asList(1, 2, 7)));
assertTrue(dto.getRoleList().isEmpty());
}
/** 批量(SyncUser 全量同步):一次 IN 查询取全部身份行,不逐用户查库;无身份行用户给空列表而非 null。 */
@Test
void toUserDtoList_batchesIdentityRows() {
List<SysUser> users = Arrays.asList(user(1, null), user(2, null));
when(sysUserGroupRelRepository.findByUserId(any())).thenReturn(Collections.emptyList());
when(sysUserRoleRelRepository.findByUserIdInAndStatus(any(), any()))
.thenReturn(Collections.singletonList(identityRel(11, 1, 1, 10, 0)));
List<PushUserDTO> dtos = assembler.toUserDtoList(users);
assertEquals(2, dtos.size());
assertEquals(Collections.singletonList(1),
dtos.get(0).getRoleList().stream().map(PushUserDTO.PushUserRoleRef::getRoleId)
.collect(Collectors.toList()));
assertTrue(dtos.get(1).getRoleList().isEmpty());
// 批量路径只查一次身份行、不查单用户
verify(sysUserRoleRelRepository, times(1)).findByUserIdInAndStatus(any(), any());
verify(sysUserRoleRelRepository, never()).findByUserIdAndStatus(any(), any());
}
/** 组关系仍逐条映射为 groups[](schoolId 由 GroupScopeHelper 解析),与 roleList 互不影响。 */
@Test
void toUserDto_groups_unchanged() {
SysUserGroupRel rel = new SysUserGroupRel();
rel.setUserId(1);
rel.setGroupId(169);
rel.setSortNo(3);
when(sysUserGroupRelRepository.findByUserId(1)).thenReturn(Collections.singletonList(rel));
when(groupScopeHelper.resolveSchoolId(169)).thenReturn(27);
when(sysUserRoleRelRepository.findByUserIdAndStatus(1, StatusEnum.NORMAL))
.thenReturn(Collections.emptyList());
PushUserDTO dto = assembler.toUserDto(user(1, null));
assertEquals(1, dto.getGroups().size());
assertEquals(Integer.valueOf(169), dto.getGroups().get(0).getGroupId());
assertEquals(Integer.valueOf(3), dto.getGroups().get(0).getNo());
assertEquals(Integer.valueOf(27), dto.getGroups().get(0).getSchoolId());
}
}