本地工作区快照:恢复 .git 仓库(合并远端 main 前基线)

This commit is contained in:
chenxing
2026-08-21 10:46:47 +08:00
commit f182fe44ce
269 changed files with 22450 additions and 0 deletions
@@ -0,0 +1,784 @@
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);
}
}