diff --git a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java index ac44a3e..84c23bc 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/SysUserController.java @@ -53,12 +53,6 @@ public class SysUserController { sysUserService.edit(id, request); } - @OperationLog("删除用户") - @PostMapping("Delete/{id}") - public void delete(@PathVariable Integer id) { - sysUserService.delete(id); - } - /** * 将用户从指定组中移除;若该用户无其它组关联则级联删除用户。 */ diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java index e9bb1e4..5cab5cb 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/GroupController.java @@ -1,10 +1,17 @@ package com.yunhai.userbase.controller.oapi; import com.yunhai.platform.utils.model.BoaGroup; +import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; +import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; +import com.yunhai.userbase.model.sysgroup.SysGroupRequest; import com.yunhai.userbase.service.ISysGroupService; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -60,4 +67,38 @@ public class GroupController { @RequestParam(value = "withSub", required = false) Boolean withSub) { return sysGroupService.getGroupList(ids, withSub); } + + /** + * 新增组:返回创建后的组节点(含 id)。
+ * 层级类型约束、根节点保护等复用后台逻辑;鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。 + */ + @PostMapping("add") + public BoaGroup add(@RequestBody @Validated(ValidationGroups.Create.class) SysGroupRequest request) { + return sysGroupService.add(request); + } + + /** + * 编辑组(不含移动,移动请走 group/move 后台接口):返回更新后的组节点(含 id)。 + */ + @PostMapping("edit/{id}") + public BoaGroup edit(@PathVariable Integer id, + @RequestBody @Validated(ValidationGroups.Update.class) SysGroupRequest request) { + return sysGroupService.edit(id, request); + } + + /** + * 删除组:组下存在子组或用户时拒绝删除(根节点不可删)。无返回值。 + */ + @PostMapping("delete/{id}") + public void delete(@PathVariable Integer id) { + sysGroupService.delete(id); + } + + /** + * 批量新增子组:请求体 { pid, type, items:[{name},...] },返回创建后的组节点列表(均含 id)。 + */ + @PostMapping("batchAdd") + public List batchAdd(@RequestBody @Validated SysGroupBatchRequest request) { + return sysGroupService.batchAdd(request); + } } diff --git a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java index 52e265d..1763a2e 100644 --- a/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java +++ b/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java @@ -5,20 +5,29 @@ import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.entity.ApplicationInfo; +import com.yunhai.userbase.model.sysuser.SysUserMoveRequest; +import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.repository.ApplicationInfoRepository; import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.userbase.service.ISysUserService; import com.yunhai.userbase.service.impl.UserCenterService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -41,6 +50,9 @@ public class UserController { @Autowired private UserCenterService userCenterService; + @Autowired + private ISysUserService sysUserService; + @Autowired private IGroupAuthzService groupAuthzService; @@ -130,28 +142,61 @@ public class UserController { return app.getId(); } + /** - * 按「schId 为空时」解析出用户主学校/主组作为模块查询的上下文组ID。
- * 保留方法(逻辑已下沉到 Service.resolveEffectiveGroupId 复用),不再被 modules() 使用。 + * 新增用户:走完整校验(账号唯一/手机号/身份证/密码强度),BCrypt 加密并写入组关联; + * 返回创建后的用户(含 id 与组织链)。
+ * 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。 */ - @Deprecated - private Integer resolveEffectiveGroupId(Integer userId, Integer schId) { - if (schId != null) { - return schId; + @PostMapping("add") + public BoaUser add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { + Integer id = sysUserService.add(request); + return userCenterService.getUserInfoWithDeptById(id); + } + + /** + * 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。 + */ + @PostMapping("edit/{id}") + public BoaUser edit(@PathVariable Integer id, + @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) { + sysUserService.edit(id, request); + return userCenterService.getUserInfoWithDeptById(id); + } + + /** + * 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;
+ * 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。
+ * 这样多校用户只会被退出指定学校,而不会被整体误删。
+ * 若用户仅归属这一个组,调用后该用户将被真正删除(组关联清空即触发级联删)。 + */ + @PostMapping("delete/{id}/{groupId}") + public void delete(@PathVariable Integer id, @PathVariable Integer groupId) { + sysUserService.deleteFromGroup(id, groupId); + } + + /** + * 批量新增用户:逐条走完整校验(含密码强度,每条需携带 password + confirmPassword); + * 返回创建后的用户列表(顺序与入参一致,均含 id 与组织链)。 + */ + @PostMapping("batchAdd") + public List batchAdd(@RequestBody List requests) { + List ids = sysUserService.batchAdd(requests); + List result = new ArrayList<>(); + for (Integer id : ids) { + result.add(userCenterService.getUserInfoWithDeptById(id)); } - BoaUser boa = userCenterService.getUserInfoWithDeptById(userId); - if (boa == null) { - return null; - } - BoaGroup school = boa.getSchool(); - if (school != null && school.getId() != null) { - return school.getId(); - } - List groups = boa.getGroups(); - if (groups != null && !groups.isEmpty()) { - BoaGroup main = groups.get(0); - return main == null ? null : main.getId(); - } - return null; + return result; + } + + /** + * 移动用户到指定组:仅允许在同一学校(组)范围内移动——用户当前必须已在该学校子树内, + * 且目标组也必须在该学校子树内,否则拒绝(禁止跨校移动)。
+ * 语义同后台「调整组」——覆盖式把用户组归属替换为目标组(目标设为主组)。返回移动后的用户(含新的组织链)。 + */ + @PostMapping("moveToGroup") + public BoaUser moveToGroup(@RequestBody @Validated SysUserMoveRequest request) { + sysUserService.moveUserWithinSchool(request.getUserId(), request.getGroupId(), request.getSchoolId()); + return userCenterService.getUserInfoWithDeptById(request.getUserId()); } } diff --git a/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java b/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java index 8e78664..7935603 100644 --- a/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java +++ b/src/main/java/com/yunhai/userbase/entity/SysUserCurrentRole.java @@ -37,12 +37,24 @@ public class SysUserCurrentRole { private Integer userId; /** - * 用户切换选中的学校ID(根级group) + * 用户切换选中的学校ID(根级group,数值即学校组 sys_group.id;区域/学段/根直属=0或null) */ private Integer schoolId; /** - * 用户切换后生效角色ID + * 用户切换后生效角色ID(SysRoleEnum.key) */ private Integer roleId; + /** + * 最近一次切换时间(服务端 now() 写入) + */ + @Column(name = "switch_time") + private LocalDateTime switchTime; + + /** + * 学校名冗余(避免每次 join sys_group 取 name) + */ + @Column(name = "school_name", length = 100) + private String schoolName; + } diff --git a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java index 09086d7..a69597b 100644 --- a/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/IGroupAuthzService.java @@ -1,5 +1,6 @@ package com.yunhai.userbase.service; +import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.userbase.model.groupauthz.*; import java.util.List; @@ -130,7 +131,7 @@ public interface IGroupAuthzService { * @param userId 用户ID(必填) * @return 该应用下用户有权限的模块(扁平化;无权限时返回空列表) */ - List findUserModulesForApp(Integer appId, Integer schId, Integer userId); + List findUserModulesForApp(Integer appId, Integer schId, Integer userId); // ===================== 新增:树节点式增量授权(供左侧树 + 右侧 appAuthzList 的勾选交互) ===================== diff --git a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java index 3ffc6e0..b148b71 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysGroupService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysGroupService.java @@ -17,9 +17,9 @@ import java.util.List; */ public interface ISysGroupService { - void add(SysGroupRequest request); + BoaGroup add(SysGroupRequest request); - void edit(Integer id, SysGroupRequest request); + BoaGroup edit(Integer id, SysGroupRequest request); void delete(Integer id); @@ -36,7 +36,7 @@ public interface ISysGroupService { List getSchoolTree(); /** 在指定父组下按规则批量生成子组。 */ - void batchAdd(SysGroupBatchRequest request); + List batchAdd(SysGroupBatchRequest request); /** 对 pid 的直接子节点按给定顺序重排并落库(ids 必须全是直接子节点)。 */ void sortChildren(SysGroupSortRequest request); diff --git a/src/main/java/com/yunhai/userbase/service/ISysUserService.java b/src/main/java/com/yunhai/userbase/service/ISysUserService.java index 05c85f8..113f86a 100644 --- a/src/main/java/com/yunhai/userbase/service/ISysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/ISysUserService.java @@ -25,8 +25,10 @@ public interface ISysUserService { /** 编辑用户;密码为空时不修改密码。 */ void edit(Integer id, SysUserRequest request); - /** 删除用户:同时清理其组用户关联。 */ - void delete(Integer id); + /** + * 批量新增用户:逐条走完整校验(含密码强度),返回创建后的用户ID列表(顺序与入参一致)。 + */ + List batchAdd(List requests); /** * 把用户从指定组中移除;若移除后该用户已无其它组关联,则级联删除用户本身。 @@ -87,6 +89,19 @@ public interface ISysUserService { */ void changeGroups(List userIds, List 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; + } }