编写开放接口,组/用户,移动组等功能
ci-userbase-service / build-and-push (push) Successful in 2m17s

This commit is contained in:
chenxing
2026-08-18 21:46:06 +08:00
parent 7a1dc29ff7
commit 52b29c5082
10 changed files with 388 additions and 61 deletions
@@ -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} 表示「根节点(教育云中心)」。
* 集合为空 = 该类型禁止创建任何子组(班级组叶子节点、学段组占位等由空集自然拒绝)。
* <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.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<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;
@@ -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<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
@@ -309,18 +398,28 @@ public class SysGroupService implements ISysGroupService {
@Override
@Transactional
public void batchAdd(SysGroupBatchRequest request) {
public List<BoaGroup> 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<SysGroupBatchRequest.BatchItem> items = request.getItems();
if (items == null || items.isEmpty()) {
throw new DException("批量新增的子组列表不能为空");
}
// 追加到当前父组子节点之后
int base = nextSort(pid);
int base = nextSort(realPid);
List<SysGroup> 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));
@@ -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<String> 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<Integer> batchAdd(List<SysUserRequest> 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<Integer> 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<Integer> schoolScope = sysGroupService.listSelfAndDescendantIds(schoolId);
// ① 用户当前必须已在该学校内(至少一个组归属落在 schoolScope)
List<Integer> 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) {
@@ -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<Integer> roleIds = sysUserRoleRelRepository
.findDistinctRoleIdsByUserIdAndStatus(user.getId(), StatusEnum.NORMAL);
boa.setRoleIds(roleIds);
List<BoaGroup> 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 字段语义=当前激活学校:<br>
* ① 优先读 {@code sys_user_current_role}(用户主动切换后的状态);<br>
* ② 无切换记录时兜底取 {@code sys_user_role_rel.is_main=1} 的整条记录(保证是合法存在的身份,
* 避免「主组+主角色」组合出 B 学校老师的非法身份);<br>
* ③ 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;
}
}