785 lines
34 KiB
Java
785 lines
34 KiB
Java
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);
|
||
}
|
||
}
|