Files
UserBaseService/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
T

785 lines
34 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.userbase.event.DataSyncEvent;
import com.yunhai.userbase.common.context.CallerAppContext;
import com.yunhai.userbase.model.push.PushInterfaceType;
import org.springframework.context.ApplicationEventPublisher;
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupSortRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupVo;
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.service.ISysGroupService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.transaction.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* @author chenx
* @date 2026/7/20 16:00
* @description 系统组服务实现:组织架构树(仅 parentId 逻辑外键,不使用 path;后续可用 MySQL8 WITH 优化递归查询)。<br>
*/
@Service
public class SysGroupService implements ISysGroupService {
/** 虚拟父节点:根节点(教育云中心)的 parentId,表示「无上级」。 */
private static final int ROOT_PARENT_ID = 0;
/** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */
private static final int SYSTEM_USER_ID = 0;
/**
* 层级类型约束:父类型 -> 允许直接创建的子类型集合。
* key 为 {@code null} 表示「根节点(教育云中心)」。
* 集合为空 = 该类型禁止创建任何子组(班级组叶子节点、学段组占位等由空集自然拒绝)。
* <p>规则来源:用户中心组层级约束(根-区域-学段-学校-教师|学生-年级-班级)。</p>
*/
private static final Map<GroupTypeEnum, Set<GroupTypeEnum>> ALLOWED_CHILDREN = new HashMap<>();
static {
ALLOWED_CHILDREN.put(null, EnumSet.of(
GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.NORMAL_GROUP, GroupTypeEnum.AREA_GROUP));
// 区域组:只能挂在根节点下(由根节点的白名单约束),其下可挂 学段组/学校组/普通组
ALLOWED_CHILDREN.put(GroupTypeEnum.AREA_GROUP, EnumSet.of(
GroupTypeEnum.STAGE_GROUP, GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.NORMAL_GROUP));
// 学段组(高中/初中/小学):学校组的上级,可含 学校组/年级组
ALLOWED_CHILDREN.put(GroupTypeEnum.STAGE_GROUP, EnumSet.of(
GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.GRADE_GROUP));
// 学校组:可含 教师组/学生组/普通组
ALLOWED_CHILDREN.put(GroupTypeEnum.SCHOOL_GROUP, EnumSet.of(
GroupTypeEnum.TEACHER_GROUP, GroupTypeEnum.STUDENT_GROUP, GroupTypeEnum.NORMAL_GROUP));
// 教师组:维持原规范,仅含 普通组
ALLOWED_CHILDREN.put(GroupTypeEnum.TEACHER_GROUP, EnumSet.of(GroupTypeEnum.NORMAL_GROUP));
// 学生组:可含 年级组/学段组/普通组(学段组既可在区域组下、也可在学生组下)
ALLOWED_CHILDREN.put(GroupTypeEnum.STUDENT_GROUP, EnumSet.of(
GroupTypeEnum.GRADE_GROUP, GroupTypeEnum.STAGE_GROUP, GroupTypeEnum.NORMAL_GROUP));
// 年级组:可含 班级组/普通组
ALLOWED_CHILDREN.put(GroupTypeEnum.GRADE_GROUP, EnumSet.of(
GroupTypeEnum.CLASS_GROUP, GroupTypeEnum.NORMAL_GROUP));
// 班级组:叶子节点,禁止创建任何子组(⛔ 强约束,空集自然拒绝)
ALLOWED_CHILDREN.put(GroupTypeEnum.CLASS_GROUP, EnumSet.noneOf(GroupTypeEnum.class));
// 普通组:可含 学校组/教师组/学段组/普通组(年级组/班级组/学生组 受限,区域组受「只能挂根」硬约束排除)
ALLOWED_CHILDREN.put(GroupTypeEnum.NORMAL_GROUP, EnumSet.of(
GroupTypeEnum.SCHOOL_GROUP, GroupTypeEnum.TEACHER_GROUP,
GroupTypeEnum.STAGE_GROUP, GroupTypeEnum.NORMAL_GROUP));
}
/** 层级类型约束校验:parentType 为 null 表示根节点;非法组合抛 {@link DException}。 */
private void validateParentChild(GroupTypeEnum parentType, GroupTypeEnum childType) {
Set<GroupTypeEnum> allowed = ALLOWED_CHILDREN.get(parentType);
if (allowed == null) {
allowed = Collections.emptySet();
}
if (allowed.contains(childType)) {
return;
}
String parentName = (parentType == null) ? "根节点" : parentType.getValue();
if (childType == GroupTypeEnum.CLASS_GROUP) {
throw new DException(parentName + "下不允许创建班级组(班级组为叶子节点,禁止创建子组)");
}
if (childType == GroupTypeEnum.AREA_GROUP) {
throw new DException("区域组只能建立在根节点下");
}
throw new DException(parentName + "下不允许创建" + childType.getValue());
}
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysGroupAppRelRepository sysGroupAppRelRepository;
@Autowired
private SysGroupModulePermRepository sysGroupModulePermRepository;
@Autowired
private ApplicationEventPublisher eventPublisher;
// ===================== 基础增删改(含根节点保护) =====================
@Override
@Transactional
public BoaGroup add(SysGroupRequest request) {
Integer parentId = request.getParentId();
// parentId 为 null 或 0 表示「挂在根节点(教育云中心)下」;
// 新组必须挂到真实根节点的 id 之下,绝不能把 parentId 写成 0(否则会生成第二个伪根节点)。
GroupTypeEnum parentType = null; // null 表示根节点
int realPid;
if (parentId != null && parentId != ROOT_PARENT_ID) {
SysGroup parent = sysGroupRepository.findById(parentId)
.orElseThrow(() -> new DException("父组不存在"));
parentType = parent.getType();
realPid = parentId;
} else {
// 解析唯一真实根节点,取其真实 id 作为挂载点
realPid = resolveRootGroup().getId();
}
GroupTypeEnum typeEnum = resolveType(request.getType());
// 层级类型约束:当前父组是否允许创建该子类型
validateParentChild(parentType, typeEnum);
SysGroup entity = new SysGroup();
entity.setName(request.getName());
entity.setParentId(realPid);
entity.setType(typeEnum);
entity.setSort(nextSort(realPid));
entity.setCreateUser(SYSTEM_USER_ID);
entity.setUpdateUser(SYSTEM_USER_ID);
SysGroup saved = sysGroupRepository.save(entity);
publishGroupEvent(PushInterfaceType.ADD_GROUP, saved.getId());
return convertToBoaGroup(saved);
}
@Override
@Transactional
public BoaGroup edit(Integer id, SysGroupRequest request) {
SysGroup entity = sysGroupRepository.findById(id)
.orElseThrow(() -> new DException("系统组不存在"));
// 根节点不可编辑
if (isRoot(entity)) {
throw new DException("根节点不可编辑");
}
entity.setName(request.getName());
if (request.getType() != null) {
GroupTypeEnum newType = resolveType(request.getType());
if (!Objects.equals(newType, entity.getType())) {
// 1) 新类型须被当前父允许(根节点 parentId=0,parentType 视为 null)
GroupTypeEnum parentType = null;
if (entity.getParentId() != null && entity.getParentId() != ROOT_PARENT_ID) {
SysGroup parent = sysGroupRepository.findById(entity.getParentId()).orElse(null);
parentType = (parent != null) ? parent.getType() : null;
}
validateParentChild(parentType, newType);
// 2) 新类型的允许子类型须覆盖现有直接子类型,避免把「含子组的组」改成叶子/不兼容类型
Set<GroupTypeEnum> newAllowed = ALLOWED_CHILDREN.get(newType);
if (newAllowed == null) {
newAllowed = Collections.emptySet();
}
List<SysGroup> children = sysGroupRepository.findByParentIdOrderBySortAsc(entity.getId());
for (SysGroup child : children) {
GroupTypeEnum ct = child.getType();
if (ct != null && !newAllowed.contains(ct)) {
throw new DException("该组下已存在「" + ct.getValue() + "」,不可变更为「"
+ newType.getValue() + "」(" + newType.getValue() + "不允许包含" + ct.getValue() + ")");
}
}
}
entity.setType(newType);
}
if (request.getSort() != null) {
entity.setSort(request.getSort());
}
// 注:移动请走 move 接口,edit 不改 parentId,避免与移动逻辑混淆
entity.setUpdateUser(SYSTEM_USER_ID);
sysGroupRepository.save(entity);
publishGroupEvent(PushInterfaceType.UPDATE_GROUP, id);
return convertToBoaGroup(entity);
}
@Override
@Transactional
public void delete(Integer id) {
deleteInternal(id);
}
@Override
@Transactional
public void delete(Integer id, Integer schoolId) {
// 开放接口边界:id 必须属于 schoolId 子树且不能等于 schoolId(禁止删学校根组/跨校删组)
assertWithinSchool(id, schoolId);
deleteInternal(id);
}
/**
* 公共学校锚点校验:schoolId 非空、存在、且必须为学校组(SCHOOL_GROUP)。<br>
* 供本服务 delete/move 及 {@code SysUserService} 等带 schoolId 边界的开放接口统一调用。
*/
@Override
public void assertSchoolId(Integer schoolId) {
if (schoolId == null) {
throw new DException("学校ID不能为空");
}
SysGroup school = sysGroupRepository.findById(schoolId)
.orElseThrow(() -> new DException("学校分组不存在"));
if (school.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("学校ID必须为学校分组");
}
}
private void deleteInternal(Integer id) {
SysGroup entity = sysGroupRepository.findById(id)
.orElseThrow(() -> new DException("系统组不存在"));
// 根节点不可删除
if (isRoot(entity)) {
throw new DException("根节点不可删除");
}
if (sysGroupRepository.countByParentId(id) > 0) {
throw new DException("该组下存在子组,无法删除");
}
if (sysUserGroupRelRepository.countByGroupId(id) > 0) {
throw new DException("有用户,不能删除组");
}
// AI-FIX: 删除组时级联清理其应用开通记录与模块权限记录(逻辑外键无物理 FK,避免残留孤儿数据)
sysGroupAppRelRepository.deleteByGroupIdBulk(id);
sysGroupModulePermRepository.deleteByGroupIdBulk(id);
sysGroupRepository.deleteById(id);
publishGroupEvent(PushInterfaceType.DELETE_GROUP, id);
}
/**
* 学校边界校验:确保待删除的组属于指定学校范围,且不是学校根组本身。
*
* @param groupId 待删除组ID
* @param schoolId 学校组ID(限定范围)
*/
private void assertWithinSchool(Integer groupId, Integer schoolId) {
// 公共锚点校验:schoolId 非空、存在、必须为学校组
assertSchoolId(schoolId);
if (groupId == null) {
throw new DException("组ID不能为空");
}
if (groupId.equals(schoolId)) {
throw new DException("禁止删除学校根组,请指定具体子组");
}
List<Integer> schoolScope = listSelfAndDescendantIds(schoolId);
if (!schoolScope.contains(groupId)) {
throw new DException("组不属于该学校范围,禁止跨校操作");
}
}
@Override
public List<SysGroupVo> list(SysGroupQuery query) {
List<SysGroup> list;
if (StringUtils.hasText(query.getName())) {
list = sysGroupRepository.findByNameContainingOrderBySortAsc(query.getName());
} else if (query.getParentId() != null) {
list = sysGroupRepository.findByParentIdOrderBySortAsc(query.getParentId());
} else {
list = sysGroupRepository.findAllByOrderBySortAsc();
}
return list.stream().map(this::convertToVo).collect(Collectors.toList());
}
// ===================== 组织架构树 =====================
@Override
public List<SysGroupVo> getTree() {
// 根节点由 SysGroupRootInitializer 在启动时幂等创建,此处直接读取组装
List<SysGroup> all = sysGroupRepository.findAll();
if (all.isEmpty()) {
return new ArrayList<>();
}
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
// 从根(pid=0)开始组装树
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
return roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList());
}
@Override
public List<SysGroupVo> getSchoolTree() {
List<SysGroup> all = sysGroupRepository.findAll();
if (all.isEmpty()) {
return new ArrayList<>();
}
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
List<SysGroupVo> result = new ArrayList<>();
for (SysGroup root : roots) {
SysGroupVo vo = buildSchoolNode(root, childrenMap);
if (vo == null) {
// 根节点始终保留(即使其下没有任何学校组),children 为空
vo = convertToVo(root);
}
result.add(vo);
}
return result;
}
/**
* 递归组装「学校组」剪枝节点。<br>
* 学校组:直接作为叶子返回(不再向下递归,其下所有组不显示);<br>
* 非学校组:递归剪枝后仍有子节点才保留,否则返回 null(整枝剪掉)。<br>
*/
private SysGroupVo buildSchoolNode(SysGroup node, Map<Integer, List<SysGroup>> childrenMap) {
if (node.getType() == GroupTypeEnum.SCHOOL_GROUP) {
return convertToVo(node);
}
List<SysGroup> children = childrenMap.get(node.getId());
if (children == null) {
return null;
}
SysGroupVo vo = convertToVo(node);
for (SysGroup child : children) {
SysGroupVo childVo = buildSchoolNode(child, childrenMap);
if (childVo != null) {
vo.getChildren().add(childVo);
}
}
return vo.getChildren().isEmpty() ? null : vo;
}
/**
* 构建 parentId -> 直接子节点列表 的映射(null 视为 0),同层级按 sort 升序、再按 id 升序。<br>
*/
private Map<Integer, List<SysGroup>> buildSortedChildrenMap(List<SysGroup> all) {
Map<Integer, List<SysGroup>> childrenMap = new HashMap<>();
for (SysGroup g : all) {
Integer key = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
childrenMap.computeIfAbsent(key, k -> new ArrayList<>()).add(g);
}
Comparator<SysGroup> siblingOrder = (a, b) -> {
int sa = a.getSort() == null ? 999 : a.getSort();
int sb = b.getSort() == null ? 999 : b.getSort();
if (sa != sb) {
return Integer.compare(sa, sb);
}
return Integer.compare(a.getId(), b.getId());
};
for (List<SysGroup> siblings : childrenMap.values()) {
siblings.sort(siblingOrder);
}
return childrenMap;
}
private SysGroupVo buildNode(SysGroup node, Map<Integer, List<SysGroup>> childrenMap) {
SysGroupVo vo = convertToVo(node);
List<SysGroup> children = childrenMap.get(node.getId());
if (children != null) {
for (SysGroup child : children) {
vo.getChildren().add(buildNode(child, childrenMap));
}
}
return vo;
}
// ===================== 开放接口:组树(BoaGroup) =====================
/**
* 获取整个平台的组树:从根节点(pid=0)开始,children 递归嵌套全部后代。<br>
* 根节点唯一,取最小 id 作为树入口。表为空或无根时返回 null。
*/
@Override
public BoaGroup getGroups() {
List<SysGroup> all = sysGroupRepository.findAll();
if (all.isEmpty()) {
return null;
}
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
if (roots.isEmpty()) {
return null;
}
// 根节点唯一,取最小 id 作为整棵树入口
SysGroup root = roots.stream()
.min(Comparator.comparingInt(SysGroup::getId))
.orElse(roots.get(0));
return buildBoaNode(root, childrenMap, true);
}
/**
* 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树),false/不传时只返回节点本身(children 为 null)。<br>
* 组不存在时返回 null。
*/
@Override
public BoaGroup getGroupById(Integer groupId, Boolean withSub) {
if (groupId == null) {
return null;
}
SysGroup entity = sysGroupRepository.findById(groupId).orElse(null);
if (entity == null) {
return null;
}
if (!Boolean.TRUE.equals(withSub)) {
return convertToBoaGroup(entity);
}
List<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
return buildBoaNode(entity, childrenMap, true);
}
/**
* 批量获取组;ids 逗号/空格分隔,按入参顺序返回,不存在的 id 跳过。<br>
* withSub 语义同 {@link #getGroupById(Integer, Boolean)}。
*/
@Override
public List<BoaGroup> getGroupList(String ids, Boolean withSub) {
List<Integer> idList = parseIds(ids);
if (idList.isEmpty()) {
return new ArrayList<>();
}
boolean expand = Boolean.TRUE.equals(withSub);
List<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, SysGroup> allMap = all.stream()
.collect(Collectors.toMap(SysGroup::getId, g -> g, (a, b) -> a));
Map<Integer, List<SysGroup>> childrenMap = buildSortedChildrenMap(all);
List<BoaGroup> result = new ArrayList<>();
for (Integer id : idList) {
SysGroup g = allMap.get(id);
if (g == null) {
continue;
}
result.add(expand ? buildBoaNode(g, childrenMap, true) : convertToBoaGroup(g));
}
return result;
}
// ===================== 批量新增 =====================
@Override
@Transactional
public List<BoaGroup> batchAdd(SysGroupBatchRequest request) {
Integer pid = request.getPid();
GroupTypeEnum parentType = null; // null 表示根节点
int realPid;
if (pid != null && pid != ROOT_PARENT_ID) {
SysGroup parent = sysGroupRepository.findById(pid)
.orElseThrow(() -> new DException("父组不存在"));
parentType = parent.getType();
realPid = pid;
} else {
// pid 为 null 或 0 表示「挂在根节点(教育云中心)下」:解析真实根节点 id 作为挂载点,
// 绝不能写成 ROOT_PARENT_ID(0),否则批量建出的组都会变成伪根节点。
realPid = resolveRootGroup().getId();
}
GroupTypeEnum typeEnum = resolveType(request.getType());
// 层级类型约束
validateParentChild(parentType, typeEnum);
List<SysGroupBatchRequest.BatchItem> items = request.getItems();
if (items == null || items.isEmpty()) {
throw new DException("批量新增的子组列表不能为空");
}
// 追加到当前父组子节点之后
int base = nextSort(realPid);
List<SysGroup> batch = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
String name = items.get(i).getName();
if (name == null || name.trim().isEmpty()) {
throw new DException("第 " + (i + 1) + " 个子组名称为空");
}
SysGroup g = new SysGroup();
g.setName(name.trim());
g.setParentId(realPid);
g.setType(typeEnum);
g.setSort(base + i);
g.setCreateUser(SYSTEM_USER_ID);
g.setUpdateUser(SYSTEM_USER_ID);
batch.add(g);
}
sysGroupRepository.saveAll(batch);
publishGroupEvent(PushInterfaceType.ADD_GROUP,
batch.stream().map(SysGroup::getId).collect(Collectors.toList()));
return batch.stream().map(this::convertToBoaGroup).collect(Collectors.toList());
}
// ===================== 子组排序 =====================
@Override
@Transactional
public void sortChildren(SysGroupSortRequest request) {
Integer pid = request.getPid();
List<Integer> ids = request.getIds();
if (pid == null || !sysGroupRepository.existsById(pid)) {
throw new DException("父组不存在");
}
if (ids == null || ids.isEmpty()) {
throw new DException("子节点ID列表不能为空");
}
if (new HashSet<>(ids).size() != ids.size()) {
throw new DException("子节点ID列表存在重复");
}
// 取出 pid 的直接子节点,校验 ids 全是其直接子节点
List<SysGroup> children = sysGroupRepository.findByParentIdOrderBySortAsc(pid);
Set<Integer> childIdSet = children.stream().map(SysGroup::getId).collect(Collectors.toSet());
for (Integer id : ids) {
if (!childIdSet.contains(id)) {
throw new DException("ids 中存在不属于该父组的直接子节点");
}
}
// 按传入顺序落库排序(1 起)
Map<Integer, SysGroup> childMap = children.stream()
.collect(Collectors.toMap(SysGroup::getId, c -> c, (a, b) -> a));
for (int i = 0; i < ids.size(); i++) {
childMap.get(ids.get(i)).setSort(i + 1);
}
sysGroupRepository.saveAll(children);
}
// ===================== 移动组 =====================
@Override
@Transactional
public void move(SysGroupMoveRequest request) {
Integer moveId = request.getMoveId();
Integer pid = request.getPid();
if (moveId == null || !sysGroupRepository.existsById(moveId)) {
throw new DException("待移动节点不存在");
}
if (pid == null) {
throw new DException("目标父节点不存在");
}
// 目标父为根节点时 pid=0;其余情况校验目标父存在并取其类型
GroupTypeEnum parentType = null; // null 表示根节点
if (pid != ROOT_PARENT_ID) {
SysGroup target = sysGroupRepository.findById(pid)
.orElseThrow(() -> new DException("目标父节点不存在"));
parentType = target.getType();
}
SysGroup moveEntity = sysGroupRepository.findById(moveId).get();
// 根节点不可移动
if (isRoot(moveEntity)) {
throw new DException("根节点不可移动");
}
if (Objects.equals(pid, moveId)) {
throw new DException("不能移动到自身下");
}
// 环检测:目标父节点不能是待移动节点的后代(含自身)
Map<Integer, SysGroup> allMap = sysGroupRepository.findAll().stream()
.collect(Collectors.toMap(SysGroup::getId, g -> g, (a, b) -> a));
if (isDescendantOf(pid, moveId, allMap)) {
throw new DException("不能移动到自身子节点下");
}
// 层级类型约束:移动后目标父类型须允许该节点类型
validateParentChild(parentType, moveEntity.getType());
// 移动后追加到目标父节点的子节点末尾
moveEntity.setParentId(pid);
moveEntity.setSort(nextSort(pid));
sysGroupRepository.save(moveEntity);
publishGroupEvent(PushInterfaceType.UPDATE_GROUP, moveId);
}
@Override
@Transactional
public void move(SysGroupMoveRequest request, Integer schoolId) {
// 开放接口边界:moveId/pid 必须落在 schoolId 子树内(禁止跨校/移学校根组/移出平台根)
assertMoveWithinSchool(request.getMoveId(), request.getPid(), schoolId);
move(request);
}
/**
* 学校边界校验:确保待移动节点与目标父节点都属于指定学校范围,且不越界。
*
* @param moveId 待移动节点ID
* @param pid 目标父节点ID
* @param schoolId 学校组ID(限定移动范围)
*/
private void assertMoveWithinSchool(Integer moveId, Integer pid, Integer schoolId) {
// 公共锚点校验:schoolId 非空、存在、必须为学校组
assertSchoolId(schoolId);
if (moveId == null) {
throw new DException("待移动节点ID不能为空");
}
if (pid == null) {
throw new DException("目标父节点ID不能为空");
}
if (moveId.equals(schoolId)) {
throw new DException("禁止移动学校根组,请指定具体子组");
}
if (pid == ROOT_PARENT_ID) {
throw new DException("禁止移动到学校范围之外(根节点)");
}
List<Integer> schoolScope = listSelfAndDescendantIds(schoolId);
if (!schoolScope.contains(moveId)) {
throw new DException("待移动节点不属于该学校范围,禁止跨校移动");
}
if (!schoolScope.contains(pid)) {
throw new DException("目标父节点不属于该学校范围,禁止跨校移动");
}
}
/**
* 判断 ancestorCandidate 是否为 nodeId 的后代(沿 parentId 向上回溯)。<br>
*/
private boolean isDescendantOf(Integer ancestorCandidate, Integer nodeId, Map<Integer, SysGroup> allMap) {
Integer cursor = ancestorCandidate;
while (cursor != null && cursor != ROOT_PARENT_ID) {
if (Objects.equals(cursor, nodeId)) {
return true;
}
SysGroup g = allMap.get(cursor);
if (g == null) {
return false;
}
cursor = g.getParentId();
}
return false;
}
// ===================== 子树聚合 =====================
@Override
public List<Integer> listSelfAndDescendantIds(Integer groupId) {
List<Integer> result = new ArrayList<>();
if (groupId == null) {
return result;
}
result.add(groupId);
// parentId -> 直接子节点ID列表
Map<Integer, List<Integer>> childrenMap = new HashMap<>();
for (SysGroup g : sysGroupRepository.findAll()) {
Integer parent = (g.getParentId() == null) ? ROOT_PARENT_ID : g.getParentId();
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(g.getId());
}
// 广度优先向下收集所有子孙
List<Integer> frontier = new ArrayList<>();
frontier.add(groupId);
while (!frontier.isEmpty()) {
List<Integer> next = new ArrayList<>();
for (Integer id : frontier) {
List<Integer> children = childrenMap.get(id);
if (children != null) {
result.addAll(children);
next.addAll(children);
}
}
frontier = next;
}
return result;
}
// ===================== 私有工具 =====================
/** SysGroup -> BoaGroup(id/name/parentId/type/sort);children 默认 null(未展开)。 */
private BoaGroup convertToBoaGroup(SysGroup entity) {
BoaGroup bg = new BoaGroup();
bg.setId(entity.getId());
bg.setName(entity.getName());
bg.setParentId(entity.getParentId());
bg.setType(entity.getType() == null ? null : entity.getType().getKey());
bg.setSort(entity.getSort());
return bg;
}
/**
* 递归组装 BoaGroup 树节点。withSub=true 时挂上全部子孙(子树),children 为列表(无子则空列表);
* withSub=false 时直接返回节点,children 保持 null(表示未展开)。
*/
private BoaGroup buildBoaNode(SysGroup node, Map<Integer, List<SysGroup>> childrenMap, boolean withSub) {
BoaGroup bg = convertToBoaGroup(node);
if (!withSub) {
return bg;
}
List<BoaGroup> childBgs = new ArrayList<>();
List<SysGroup> children = childrenMap.get(node.getId());
if (children != null) {
for (SysGroup child : children) {
childBgs.add(buildBoaNode(child, childrenMap, true));
}
}
bg.setChildren(childBgs);
return bg;
}
/** 解析逗号/空格分隔的 id 串为 Integer 列表;非数字片段忽略。 */
private List<Integer> parseIds(String ids) {
List<Integer> result = new ArrayList<>();
if (ids == null || ids.trim().isEmpty()) {
return result;
}
for (String s : ids.split("[,\\s]+")) {
if (s == null || s.trim().isEmpty()) {
continue;
}
try {
result.add(Integer.parseInt(s.trim()));
} catch (NumberFormatException ignored) {
// 跳过非数字片段
}
}
return result;
}
/** pid 下已有子节点的最大 sort +1;无子节点返回 1。 */
private int nextSort(Integer pid) {
Integer max = sysGroupRepository.findMaxSortByParentId(pid);
return (max == null ? 0 : max) + 1;
}
/** 校验并返回组类型枚举;非法类型抛异常。 */
private GroupTypeEnum resolveType(Integer type) {
GroupTypeEnum typeEnum = IEnum.getByKey(GroupTypeEnum.class, type);
if (typeEnum == null) {
throw new DException("组类型不存在");
}
return typeEnum;
}
/** 是否为系统根节点(唯一的 pid=0 节点)。 */
private boolean isRoot(SysGroup entity) {
return entity.getParentId() != null && entity.getParentId() == ROOT_PARENT_ID;
}
/**
* 解析唯一真实根节点(parentId=0 中 id 最小者,与 {@link #getGroups()} 约定一致)。<br>
* 用于「parentId/ pid 为 0 或 null 表示挂根下」场景:必须把新组挂到该根节点的真实 id 下,
* 而非把 parentId 写成 0(0 仅属于根本身,写成 0 会生成第二个伪根节点)。
*
* @return 根节点实体
* @throws DException 当不存在任何根节点时
*/
private SysGroup resolveRootGroup() {
List<SysGroup> roots = sysGroupRepository.findByParentIdOrderBySortAsc(ROOT_PARENT_ID);
if (roots.isEmpty()) {
throw new DException("根节点不存在,无法在根下建组");
}
// 与 getGroups 约定一致:pid=0 中 id 最小者为唯一真实根节点
return roots.stream()
.min(Comparator.comparingInt(SysGroup::getId))
.orElse(roots.get(0));
}
private SysGroupVo convertToVo(SysGroup entity) {
SysGroupVo vo = new SysGroupVo();
BeanUtils.copyProperties(entity, vo);
vo.setType(entity.getType() == null ? null : entity.getType().getKey());
return vo;
}
// ===================== 推送事件(AFTER_COMMIT 由 PushDispatcher 消费,push_enabled 默认 0 不触发) =====================
private void publishGroupEvent(PushInterfaceType type, Integer groupId) {
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setGroupIds(Collections.singletonList(groupId));
e.setSourceAppKey(CallerAppContext.getAppKey());
eventPublisher.publishEvent(e);
}
private void publishGroupEvent(PushInterfaceType type, List<Integer> groupIds) {
if (groupIds == null || groupIds.isEmpty()) {
return;
}
DataSyncEvent e = new DataSyncEvent();
e.setType(type);
e.setGroupIds(groupIds);
e.setSourceAppKey(CallerAppContext.getAppKey());
eventPublisher.publishEvent(e);
}
}