From 6675ccc8d57aed509e3c147491caa4529d3ac749 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 26 Aug 2026 12:38:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BD=AC=E5=87=BA=E7=8A=B6=E6=80=812?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/oapi/GroupController.java | 2 +- .../controller/oapi/UserController.java | 42 ++++++ .../userbase/entity/SysUserGroupRel.java | 11 ++ .../userbase/model/sysuser/SysUserVo.java | 2 + .../repository/SysUserRepository.java | 2 +- .../userbase/service/ISysUserService.java | 38 ++++++ .../userbase/service/impl/SysUserService.java | 122 +++++++++++++++++- 7 files changed, 210 insertions(+), 9 deletions(-) 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 62108c9..7a07e9a 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java @@ -79,7 +79,7 @@ public class GroupController { } /** - * 编辑组(不含移动,移动请走 group/move 后台接口):返回更新后的组节点(含 id)。 + * 编辑组(不含移动,移动请走 group/move/{schoolId} 开放接口或后台 Move):返回更新后的组节点(含 id)。 */ @PostMapping("edit/{id}") public BoaGroup edit(@PathVariable Integer id, 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 9623605..6a812ec 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -6,13 +6,16 @@ import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.common.annotation.OapiLog; import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; +import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest; import com.yunhai.userbase.model.sysuser.SysUserMoveRequest; import com.yunhai.userbase.model.sysuser.SysUserPasswordRequest; import com.yunhai.userbase.model.sysuser.SysUserRequest; +import com.yunhai.userbase.model.sysuser.SysUserTransferSchoolRequest; import com.yunhai.userbase.repository.ApplicationInfoRepository; import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.ISysUserService; @@ -230,4 +233,43 @@ public class UserController { public void password(@RequestBody @Validated SysUserPasswordRequest request) { sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword()); } + + /** + * 设置用户在指定组上的状态(转出/恢复)。
+ * state=1 转出、state=0 恢复正常;仅作关系标记,不影响成员查询、组织链与下游推送。 + * 仅允许在同一学校(组)范围内操作——groupId 必须属于 schoolId 子树且不等于 schoolId。
+ * 鉴权由过滤器层强制签名;POST 自动记 OapiLog。 + */ + @PostMapping("groupState") + public void groupState(@RequestBody @Validated SysUserGroupStateRequest request) { + sysUserService.changeUserGroupState(request.getUserId(), request.getGroupId(), + request.getSchoolId(), request.getState()); + } + + /** + * 查询用户在指定组上的状态:0=正常,1=转出。
+ * 边界与设置接口一致(groupId 必须属于 schoolId 子树且不等于 schoolId); + * 用户不在此组时返回业务错误(code=5)。 + */ + @OapiLog(value = "查询用户组状态", saveResponse = false) + @GetMapping("groupState") + public Integer groupState(@RequestParam("userId") Integer userId, + @RequestParam("groupId") Integer groupId, + @RequestParam("schoolId") Integer schoolId) { + return sysUserService.getUserGroupState(userId, groupId, schoolId); + } + + /** + * 跨校转入(转出后迁移到其他学校)。
+ * 前置:用户已在原学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系(先经 groupState 置转出);
+ * 操作成功后:删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系 + * (主归属、状态恢复正常),并推送编辑用户订阅。
+ * 鉴权由过滤器层强制签名;POST 自动记 OapiLog。 + */ + @PostMapping("transferToSchool") + public BoaUser transferToSchool(@RequestBody @Validated SysUserTransferSchoolRequest request) { + sysUserService.transferUserToSchool(request.getUserId(), request.getFromSchoolId(), + request.getSchoolId(), request.getGroupId()); + return userCenterService.getUserInfoWithDeptById(request.getUserId()); + } } diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java b/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java index de68291..b5df70d 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUserGroupRel.java @@ -4,6 +4,9 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import com.yunhai.userbase.common.converter.UserGroupRelStatusEnumConverter; +import com.yunhai.userbase.enums.UserGroupRelStatusEnum; + import javax.persistence.*; import javax.validation.constraints.NotNull; import java.time.LocalDateTime; @@ -36,6 +39,14 @@ public class SysUserGroupRel { @Column(name = "sort_no") private Integer sortNo; + /** + * 组内状态:0=正常 1=转出(存量数据 NULL 视为正常)。
+ * 转出仅作标记,不影响成员查询、组织链与下游推送; + * 改组/移动经 changeGroups 覆盖式重建关系行时状态随用户继承(同组照抄旧状态,新增组在转出流程中继承转出)。 + */ + @Convert(converter = UserGroupRelStatusEnumConverter.class) + private UserGroupRelStatusEnum status; + private LocalDateTime createTime; // 关联用户,仅查询,不维护外键 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 3dee203..efca624 100644 --- a/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java +++ b/src/main/java/com/yunhai/userbase/model/sysuser/SysUserVo.java @@ -29,6 +29,8 @@ public class SysUserVo { private Integer type; /** 当前组维度的排序号(组内 sort_no);根节点/未选组时为 null */ private Integer sortNo; + /** 组内状态:UserGroupRelStatusEnum 的 key(0=正常,1=转出);仅组上下文列表有值,根节点/未选组为 null */ + private Integer groupStatus; private Boolean expired; private LocalDateTime createTime; private LocalDateTime updateTime; diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java index 015d584..651d4fd 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserRepository.java @@ -35,7 +35,7 @@ public interface SysUserRepository extends JpaRepository, JpaS * 入参 name / disabled 为可空:为空(NULL)时该过滤条件整体跳过。 */ @Query(value = - "SELECT u.id AS userId, MIN(r.sort_no) AS minSortNo " + + "SELECT u.id AS userId, MIN(r.sort_no) AS minSortNo, MAX(COALESCE(r.status, 0)) AS groupStatus " + "FROM sys_user u " + "INNER JOIN sys_user_group_rel r ON r.user_id = u.id " + "WHERE r.group_id IN (:groupIds) " + diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index d3bb66f..da543a7 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -179,4 +179,42 @@ public interface ISysUserService { * @param newPassword 新密码(弱密码规则校验后加密写入) */ void changePassword(Integer userId, String oldPassword, String newPassword); + + /** + * 开放接口专用:设置用户在指定组上的状态(转出/恢复)。
+ * 仅作关系标记,不影响成员查询、组织链与下游推送;同值重复调用幂等。
+ * 边界:groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校/学校根组操作)。 + * + * @param userId 用户ID + * @param groupId 组ID + * @param schoolId 学校组ID(限定范围) + * @param state 目标状态:0=恢复正常,1=转出 + */ + void changeUserGroupState(Integer userId, Integer groupId, Integer schoolId, Integer state); + + /** + * 开放接口专用:查询用户在指定组上的状态(0=正常,1=转出)。
+ * 存量数据 status 为 NULL 视为正常返回 0。 + * + * @param userId 用户ID + * @param groupId 组ID + * @param schoolId 学校组ID(限定范围) + * @return 状态值 0/1 + */ + Integer getUserGroupState(Integer userId, Integer groupId, Integer schoolId); + + /** + * 开放接口专用:跨校转入(转出后迁移到其他学校)。
+ * 前置:用户已在源学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系; + * 操作成功后:删除源学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系 + * (主归属、状态恢复正常,转出流程结束),并推送编辑用户订阅(UPDATE_USER)。
+ * 边界:fromSchoolId 与目标 schoolId 均须为学校组且不相同;目标组须属于目标学校子树且不等于学校根组; + * 用户已在目标学校存在组关系时拒绝(转入语义为进入全新学校)。 + * + * @param userId 待迁移用户ID + * @param fromSchoolId 原学校组ID(源学校) + * @param toSchoolId 目标学校组ID + * @param groupId 目标组ID(目标学校内的挂载组,迁移后的主归属) + */ + void transferUserToSchool(Integer userId, Integer fromSchoolId, Integer toSchoolId, Integer groupId); } 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 c9253c5..6adf2fe 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -7,6 +7,7 @@ import com.yunhai.userbase.entity.SysGroup; import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.userbase.enums.StatusEnum; import com.yunhai.userbase.enums.SysRoleEnum; +import com.yunhai.userbase.enums.UserGroupRelStatusEnum; import com.yunhai.userbase.enums.UserTypeEnum; import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUserAppRoleRel; @@ -338,6 +339,93 @@ public class SysUserService implements ISysUserService { sysUserGroupRelRepository.save(rel); } + @Override + @Transactional + public void changeUserGroupState(Integer userId, Integer groupId, Integer schoolId, Integer state) { + if (state == null || (state != 0 && state != 1)) { + throw new DException("state 仅支持 0=恢复正常、1=转出"); + } + // 开放接口边界:groupId 必须属于 schoolId 子树且不能等于 schoolId(禁止跨校/学校根组) + assertGroupWithinSchool(groupId, schoolId); + if (!sysUserRepository.existsById(userId)) { + throw new DException("系统用户不存在"); + } + // 状态按组维度存储,必须落到具体的 (userId, groupId) 关联行;同值重复调用幂等 + SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId) + .orElseThrow(() -> new DException("该用户不在此分组中")); + rel.setStatus(state == 1 ? UserGroupRelStatusEnum.TRANSFERRED_OUT : UserGroupRelStatusEnum.NORMAL); + sysUserGroupRelRepository.save(rel); + } + + @Override + public Integer getUserGroupState(Integer userId, Integer groupId, Integer schoolId) { + assertGroupWithinSchool(groupId, schoolId); + SysUserGroupRel rel = sysUserGroupRelRepository.findByUserIdAndGroupId(userId, groupId) + .orElseThrow(() -> new DException("该用户不在此分组中")); + // 存量数据 status 为 NULL,按正常返回 + return rel.getStatus() == null ? UserGroupRelStatusEnum.NORMAL.getKey() : rel.getStatus().getKey(); + } + + @Override + @Transactional + public void transferUserToSchool(Integer userId, Integer fromSchoolId, Integer toSchoolId, Integer groupId) { + if (userId == null) { + throw new DException("用户ID不能为空"); + } + if (fromSchoolId == null) { + throw new DException("原学校ID不能为空"); + } + if (toSchoolId == null) { + throw new DException("目标学校ID不能为空"); + } + if (groupId == null) { + throw new DException("目标组ID不能为空"); + } + // 源学校必须真实存在且为学校组(目标学校在 assertGroupWithinSchool 内一并校验) + sysGroupService.assertSchoolId(fromSchoolId); + if (fromSchoolId.equals(toSchoolId)) { + throw new DException("原学校与目标学校不能相同"); + } + if (!sysUserRepository.existsById(userId)) { + throw new DException("系统用户不存在"); + } + // 目标组必须属于目标学校子树且不等于学校根组(复用移动/删除的边界校验) + assertGroupWithinSchool(groupId, toSchoolId); + + List fromScope = sysGroupService.listSelfAndDescendantIds(fromSchoolId); + List toScope = sysGroupService.listSelfAndDescendantIds(toSchoolId); + List rels = sysUserGroupRelRepository.findByUserId(userId); + + // ① 前置:用户在源学校范围必须存在至少一条「转出」状态的组关系 + boolean hasTransferOut = rels.stream().anyMatch(r -> + r.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT && fromScope.contains(r.getGroupId())); + if (!hasTransferOut) { + throw new DException("该用户在原学校不存在转出状态的组关系,禁止转入"); + } + // ② 目标学校范围不得已有组关系(转入语义为进入全新学校,避免覆盖歧义) + boolean existsInTarget = rels.stream().anyMatch(r -> toScope.contains(r.getGroupId())); + if (existsInTarget) { + throw new DException("用户已在该学校存在组关系,禁止重复转入"); + } + // ③ 删除源学校范围内全部关系行(仅源学校,其他学校保留) + List toDelete = rels.stream() + .filter(r -> fromScope.contains(r.getGroupId())) + .collect(Collectors.toList()); + if (!toDelete.isEmpty()) { + sysUserGroupRelRepository.deleteAll(toDelete); + } + // ④ 建立目标学校关系:目标组设为主归属,状态恢复正常(转出流程结束) + SysUserGroupRel rel = new SysUserGroupRel(); + rel.setUserId(userId); + rel.setGroupId(groupId); + rel.setIsMain(IS_MAIN); + rel.setStatus(UserGroupRelStatusEnum.NORMAL); + rel.setCreateTime(LocalDateTime.now()); + sysUserGroupRelRepository.save(rel); + // ⑤ 推送编辑用户订阅 + publishUserEvent(PushInterfaceType.UPDATE_USER, userId); + } + @Override public Page list(SysUserQuery query, Pageable pageable) { // 组上下文(选中具体组):走原生 SQL 按「组内最小 sort_no」排序的专用分页, @@ -348,7 +436,7 @@ public class SysUserService implements ISysUserService { // 根节点 / 未选组:用 Specification 过滤,按主键 id 升序(聚簇索引顺序,无 filesort)。 Specification spec = buildSpecification(query); Page page = sysUserRepository.findAll(spec, pageable); - return page.map(u -> convertToVo(u, null)); + return page.map(u -> convertToVo(u, null, null)); } /** @@ -372,15 +460,19 @@ public class SysUserService implements ISysUserService { return new PageImpl<>(Collections.emptyList(), pageable, 0); } - // 保持 DB 返回的排序顺序,并记录每个用户的「组范围内最小 sort_no」 + // 保持 DB 返回的排序顺序,并记录每个用户的「组范围内最小 sort_no」与「组内状态(任一转出即转出)」 List orderedIds = new ArrayList<>(rows.size()); Map sortNoMap = new LinkedHashMap<>(); + Map groupStatusMap = new LinkedHashMap<>(); for (Object[] row : rows) { Integer userId = ((Number) row[0]).intValue(); Object minObj = row[1]; // MIN(sort_no) 可能为空(该用户在组范围内均未设置排序号) Integer sortNo = minObj == null ? null : ((Number) minObj).intValue(); + Object statusObj = row[2]; // MAX(COALESCE(status,0)):组范围内任一条转出即视为转出 + Integer groupStatus = statusObj == null ? null : ((Number) statusObj).intValue(); orderedIds.add(userId); sortNoMap.put(userId, sortNo); + groupStatusMap.put(userId, groupStatus); } // 按有序 ID 取实体(findAllById 返回顺序不可靠,需按 orderedIds 重排) @@ -392,7 +484,7 @@ public class SysUserService implements ISysUserService { for (Integer id : orderedIds) { SysUser u = userMap.get(id); if (u != null) { - voList.add(convertToVo(u, sortNoMap.get(id))); + voList.add(convertToVo(u, sortNoMap.get(id), groupStatusMap.get(id))); } } return new PageImpl<>(voList, pageable, idPage.getTotalElements()); @@ -426,14 +518,29 @@ public class SysUserService implements ISysUserService { LocalDateTime now = LocalDateTime.now(); for (Integer userId : userIds) { + // 覆盖式重建前先读旧关系,用于继承组内状态(转出标记): + // 行级继承——同组照抄旧状态(管理端重设组不误改);用户级继承——新增组在用户处于转出流程中(存在任一转出行)时继承「转出」 + List oldRels = sysUserGroupRelRepository.findByUserId(userId); + Map oldStatusByGroup = new HashMap<>(); + boolean inTransferFlow = false; + for (SysUserGroupRel oldRel : oldRels) { + oldStatusByGroup.put(oldRel.getGroupId(), oldRel.getStatus()); + if (oldRel.getStatus() == UserGroupRelStatusEnum.TRANSFERRED_OUT) { + inTransferFlow = true; + } + } sysUserGroupRelRepository.deleteByUserId(userId); List rels = new ArrayList<>(groupIds.size()); for (int i = 0; i < groupIds.size(); i++) { + Integer groupId = groupIds.get(i); SysUserGroupRel rel = new SysUserGroupRel(); rel.setUserId(userId); - rel.setGroupId(groupIds.get(i)); + rel.setGroupId(groupId); rel.setIsMain(i == 0 ? IS_MAIN : 0); rel.setCreateTime(now); + rel.setStatus(oldStatusByGroup.containsKey(groupId) + ? oldStatusByGroup.get(groupId) + : (inTransferFlow ? UserGroupRelStatusEnum.TRANSFERRED_OUT : UserGroupRelStatusEnum.NORMAL)); rels.add(rel); } sysUserGroupRelRepository.saveAll(rels); @@ -885,18 +992,19 @@ public class SysUserService implements ISysUserService { } private SysUserVo convertToVo(SysUser entity) { - return convertToVo(entity, null); + return convertToVo(entity, null, null); } - private SysUserVo convertToVo(SysUser entity, Integer sortNo) { + private SysUserVo convertToVo(SysUser entity, Integer sortNo, Integer groupStatus) { SysUserVo vo = new SysUserVo(); BeanUtils.copyProperties(entity, vo); // disabled 为枚举,需要单独映射为 key(与 GroupTypeEnum 的 type 处理一致) vo.setDisabled(entity.getDisabled() == null ? null : entity.getDisabled().getKey()); // type 为枚举,同样映射为 key vo.setType(entity.getType() == null ? null : entity.getType().getKey()); - // 当前组维度的排序号;根节点 / 未选组时为 null + // 当前组维度的排序号与组内状态;根节点 / 未选组时为 null vo.setSortNo(sortNo); + vo.setGroupStatus(groupStatus); return vo; }