groupIds);
+ /**
+ * 开放接口专用:在学校(组)范围内移动用户。
+ * 语义同 {@link #changeGroups}(覆盖式把用户组归属替换为目标组,目标设为主组),但额外强制:
+ * ① 用户当前必须已在该学校子树内(至少一个组归属落在 schoolId 子树);
+ * ② 目标 groupId 必须也在该学校子树内。
+ * 任意一条不满足即抛异常,禁止跨校移动。
+ *
+ * @param userId 待移动用户ID
+ * @param groupId 目标组ID(移动后的主归属组)
+ * @param schoolId 学校组ID(限定移动范围,仅允许同校内移动)
+ */
+ void moveUserWithinSchool(Integer userId, Integer groupId, Integer schoolId);
+
/**
* 调整用户在某组内的排序号(sort_no)。
* sortNo 为 null 时清除排序号,列表回退到 user_id 升序兜底。
diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
index 33044a0..41e499a 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/SysGroupService.java
@@ -28,6 +28,7 @@ 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;
@@ -47,6 +48,63 @@ 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} 表示「根节点(教育云中心)」。
+ * 集合为空 = 该类型禁止创建任何子组(班级组叶子节点、学段组占位等由空集自然拒绝)。
+ * 规则来源:用户中心组层级约束(根-区域-学段-学校-教师|学生-年级-班级)。
+ */
+ private static final Map> 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.NORMAL_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 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;
@@ -66,29 +124,35 @@ public class SysGroupService implements ISysGroupService {
@Override
@Transactional
- public void add(SysGroupRequest request) {
+ public BoaGroup add(SysGroupRequest request) {
Integer parentId = request.getParentId();
- // 根节点由系统自动维护,不允许手动新增一个 pid=0 的组
- if (parentId == null || parentId == ROOT_PARENT_ID) {
- throw new DException("根节点由系统自动创建,不可手动添加");
- }
- if (!sysGroupRepository.existsById(parentId)) {
- throw new DException("父组不存在");
+ // 允许在根节点(教育云中心)下建组:parentId 为 null 或 0 视为根
+ GroupTypeEnum parentType = null; // null 表示根节点
+ if (parentId != null && parentId != ROOT_PARENT_ID) {
+ SysGroup parent = sysGroupRepository.findById(parentId)
+ .orElseThrow(() -> new DException("父组不存在"));
+ parentType = parent.getType();
}
GroupTypeEnum typeEnum = resolveType(request.getType());
+ // 层级类型约束:当前父组是否允许创建该子类型
+ validateParentChild(parentType, typeEnum);
+ int realPid = (parentId == null) ? ROOT_PARENT_ID : parentId;
SysGroup entity = new SysGroup();
entity.setName(request.getName());
- entity.setParentId(parentId);
+ entity.setParentId(realPid);
entity.setType(typeEnum);
- entity.setSort(nextSort(parentId));
+ 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 void edit(Integer id, SysGroupRequest request) {
+ public BoaGroup edit(Integer id, SysGroupRequest request) {
SysGroup entity = sysGroupRepository.findById(id)
.orElseThrow(() -> new DException("系统组不存在"));
// 根节点不可编辑
@@ -97,14 +161,39 @@ public class SysGroupService implements ISysGroupService {
}
entity.setName(request.getName());
if (request.getType() != null) {
- entity.setType(resolveType(request.getType()));
+ 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 newAllowed = ALLOWED_CHILDREN.get(newType);
+ if (newAllowed == null) {
+ newAllowed = Collections.emptySet();
+ }
+ List 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
@@ -309,18 +398,28 @@ public class SysGroupService implements ISysGroupService {
@Override
@Transactional
- public void batchAdd(SysGroupBatchRequest request) {
+ public List batchAdd(SysGroupBatchRequest request) {
Integer pid = request.getPid();
- if (pid == null || !sysGroupRepository.existsById(pid)) {
- throw new DException("父组不存在");
+ 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 {
+ // 允许在根节点下批量建组
+ realPid = ROOT_PARENT_ID;
}
GroupTypeEnum typeEnum = resolveType(request.getType());
+ // 层级类型约束
+ validateParentChild(parentType, typeEnum);
List items = request.getItems();
if (items == null || items.isEmpty()) {
throw new DException("批量新增的子组列表不能为空");
}
// 追加到当前父组子节点之后
- int base = nextSort(pid);
+ int base = nextSort(realPid);
List batch = new ArrayList<>();
for (int i = 0; i < items.size(); i++) {
@@ -330,14 +429,17 @@ public class SysGroupService implements ISysGroupService {
}
SysGroup g = new SysGroup();
g.setName(name.trim());
- g.setParentId(pid);
+ 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());
}
// ===================== 子组排序 =====================
@@ -383,9 +485,16 @@ public class SysGroupService implements ISysGroupService {
if (moveId == null || !sysGroupRepository.existsById(moveId)) {
throw new DException("待移动节点不存在");
}
- if (pid == null || pid == ROOT_PARENT_ID || !sysGroupRepository.existsById(pid)) {
+ 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)) {
@@ -400,6 +509,8 @@ public class SysGroupService implements ISysGroupService {
if (isDescendantOf(pid, moveId, allMap)) {
throw new DException("不能移动到自身子节点下");
}
+ // 层级类型约束:移动后目标父类型须允许该节点类型
+ validateParentChild(parentType, moveEntity.getType());
// 移动后追加到目标父节点的子节点末尾
moveEntity.setParentId(pid);
moveEntity.setSort(nextSort(pid));
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 374512b..f493621 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java
@@ -69,6 +69,9 @@ public class SysUserService implements ISysUserService {
/** 主归属标识:1=本职主学校/部门 */
private static final int IS_MAIN = 1;
+ /** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */
+ private static final int SYSTEM_USER_ID = 0;
+
/** 系统内置超级管理员账号(与 sys_user_init.sql 种子账号保持一致) */
private static final Set SUPER_ADMIN_ACCOUNTS =
new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3"));
@@ -136,6 +139,9 @@ public class SysUserService implements ISysUserService {
// disabled 用枚举存储:不传时默认 NORMAL(正常/未禁用)
entity.setDisabled(request.getDisabled() != null ? resolveStatus(request.getDisabled()) : StatusEnum.NORMAL);
entity.setExpired(request.getExpired() != null ? request.getExpired() : false);
+ // oapi 写操作无登录态时,用系统哨兵值兜底 create_user/update_user(NOT NULL 约束)
+ entity.setCreateUser(SYSTEM_USER_ID);
+ entity.setUpdateUser(SYSTEM_USER_ID);
SysUser saved = sysUserRepository.save(entity);
publishUserEvent(PushInterfaceType.ADD_USER, saved.getId());
@@ -183,22 +189,23 @@ public class SysUserService implements ISysUserService {
if (request.getExpired() != null) {
entity.setExpired(request.getExpired());
}
+ entity.setUpdateUser(SYSTEM_USER_ID);
sysUserRepository.save(entity);
publishUserEvent(PushInterfaceType.UPDATE_USER, id);
}
@Override
@Transactional
- public void delete(Integer id) {
- if (!sysUserRepository.existsById(id)) {
- throw new DException("系统用户不存在");
+ public List batchAdd(List requests) {
+ if (requests == null || requests.isEmpty()) {
+ throw new DException("批量新增用户列表不能为空");
}
- // 先清理组用户关联、用户-角色绑定,再删除用户
- sysUserGroupRelRepository.deleteByUserId(id);
- // AI-FIX(角色授权): 删除用户时级联清理用户-业务角色绑定(逻辑外键无物理 FK,避免残留孤儿数据)
- sysUserAppRoleRelRepository.deleteByUserIdBulk(id);
- sysUserRepository.deleteById(id);
- publishUserEvent(PushInterfaceType.DELETE_USER, id);
+ // 批量新增与单条一致:强制密码强度校验(每条需携带 password + confirmPassword)
+ List ids = new ArrayList<>();
+ for (SysUserRequest request : requests) {
+ ids.add(createUser(request, true));
+ }
+ return ids;
}
@Override
@@ -384,6 +391,46 @@ public class SysUserService implements ISysUserService {
publishUserEvent(PushInterfaceType.UPDATE_USER, userIds);
}
+ @Override
+ @Transactional
+ public void moveUserWithinSchool(Integer userId, Integer groupId, Integer schoolId) {
+ if (userId == null) {
+ throw new DException("用户ID不能为空");
+ }
+ if (groupId == null) {
+ throw new DException("目标组ID不能为空");
+ }
+ if (schoolId == null) {
+ throw new DException("学校ID不能为空");
+ }
+ if (!sysUserRepository.existsById(userId)) {
+ throw new DException("系统用户不存在");
+ }
+ if (!sysGroupRepository.existsById(groupId)) {
+ throw new DException("目标分组不存在");
+ }
+ if (!sysGroupRepository.existsById(schoolId)) {
+ throw new DException("学校分组不存在");
+ }
+
+ // 学校子树范围(含学校自身):用户与目标组都必须落在此范围内,否则视为跨校移动
+ List schoolScope = sysGroupService.listSelfAndDescendantIds(schoolId);
+
+ // ① 用户当前必须已在该学校内(至少一个组归属落在 schoolScope)
+ List userGroupIds = findGroupIdsByUserId(userId);
+ boolean userInSchool = userGroupIds.stream().anyMatch(schoolScope::contains);
+ if (!userInSchool) {
+ throw new DException("用户不在该学校范围内,禁止跨校移动");
+ }
+ // ② 目标组必须也在该学校内
+ if (!schoolScope.contains(groupId)) {
+ throw new DException("目标组不属于该学校范围,禁止跨校移动");
+ }
+
+ // 复用覆盖式改组逻辑(目标设为主组)
+ changeGroups(Collections.singletonList(userId), Collections.singletonList(groupId));
+ }
+
@Override
@Transactional(rollbackFor = Exception.class)
public BatchImportResult batchImport(MultipartFile file) {
diff --git a/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java
index cea7c2a..f6c5949 100644
--- a/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java
+++ b/src/main/java/com/yunhai/userbase/service/impl/UserCenterService.java
@@ -4,17 +4,22 @@ import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
+import com.yunhai.userbase.entity.SysUserCurrentRole;
import com.yunhai.userbase.entity.SysUserGroupRel;
+import com.yunhai.userbase.entity.SysUserRoleRel;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
+import com.yunhai.userbase.repository.SysUserRoleRelRepository;
+import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -38,6 +43,10 @@ public class UserCenterService {
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
+ @Autowired
+ private SysUserRoleRelRepository sysUserRoleRelRepository;
+ @Autowired
+ private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
public BoaUser getUserInfoWithDeptByAccount(String account) {
if (account == null) {
@@ -77,11 +86,17 @@ public class UserCenterService {
boa.setType(user.getType() != null ? user.getType().getKey() : null);
// 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用)
boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0);
- boa.setRoleIds(user.getRoles());
+ // 角色列表:从 sys_user_role_rel(平台身份角色权威源)取正常态 roleId 去重,不再读 sys_user.roles
+ List roleIds = sysUserRoleRelRepository
+ .findDistinctRoleIdsByUserIdAndStatus(user.getId(), StatusEnum.NORMAL);
+ boa.setRoleIds(roleIds);
List groups = buildGroups(user.getId());
boa.setGroups(groups);
+ // 部门/教育局:仍从组织链推导(与主组/区域组语义绑定)
deriveOrg(groups, boa);
+ // 学校/当前激活身份/可选学校:从身份关联表推导(school 语义=当前激活学校)
+ deriveIdentity(user.getId(), boa);
return boa;
}
@@ -142,4 +157,50 @@ public class UserCenterService {
// 部门:主组(链的近端第一个);若主组本身为学校,则部门即学校
boa.setDept(groups.get(0));
}
+
+ /**
+ * 从身份关联推导当前激活身份与可选学校。school 字段语义=当前激活学校:
+ * ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);
+ * ② 无切换记录时兜底取 {@code sys_user_role_rel.is_main=1} 的整条记录(保证是合法存在的身份,
+ * 避免「主组+主角色」组合出 B 学校老师的非法身份);
+ * ③ schools:用户在全部学校下的可选身份去重 schoolId(>0)集合。
+ */
+ private void deriveIdentity(Integer userId, BoaUser boa) {
+ Integer currentSchoolId = null;
+ String currentSchoolName = null;
+
+ SysUserCurrentRole current = sysUserCurrentRoleRepository.findByUserId(userId).orElse(null);
+ if (current != null && current.getRoleId() != null) {
+ currentSchoolId = current.getSchoolId();
+ currentSchoolName = current.getSchoolName();
+ } else {
+ // 兜底:直接取主身份整条记录(isMain=1),而不是「主组+主角色」组合
+ SysUserRoleRel mainRel = sysUserRoleRelRepository
+ .findByUserIdAndIsMain(userId, 1).orElse(null);
+ if (mainRel != null) {
+ currentSchoolId = mainRel.getSchoolId();
+ if (currentSchoolId != null && currentSchoolId > 0) {
+ SysGroup sch = sysGroupRepository.findById(currentSchoolId).orElse(null);
+ currentSchoolName = sch != null ? sch.getName() : null;
+ }
+ }
+ }
+
+ if (currentSchoolId != null && currentSchoolId > 0) {
+ boa.setSchool(toBoaGroup(currentSchoolId, currentSchoolName));
+ }
+ }
+
+ /** 组装一个 BoaGroup(仅填充 id 与 name;name 为 null 时回源查 sys_group) */
+ private BoaGroup toBoaGroup(Integer groupId, String fallbackName) {
+ BoaGroup bg = new BoaGroup();
+ bg.setId(groupId);
+ if (fallbackName != null) {
+ bg.setName(fallbackName);
+ } else {
+ SysGroup g = sysGroupRepository.findById(groupId).orElse(null);
+ bg.setName(g != null ? g.getName() : null);
+ }
+ return bg;
+ }
}