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