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:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user