diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminController.java new file mode 100644 index 000000000..d3f8e8ea9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminController.java @@ -0,0 +1,120 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.train.domain.dto.UserBindingDTO; +import com.ruoyi.train.domain.vo.BindableUserVO; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; + +/** + * 管理员端业务身份系统账号关联 Controller。 + * + *

为管理员提供受控的系统账号与教师/学员业务档案关联与解绑功能, + * 满足 Wave 2.1 / Wave 3 真实三角色生命周期管理流。

+ * + * @author ruoyi + */ +@RestController +@RequestMapping +public class TrainAccountBindingAdminController extends BaseController +{ + @Autowired + private TrainAccountBindingAdminService bindingAdminService; + + // ========================================== + // 教师业务档案账号绑定 + // ========================================== + + /** + * 查询可关联教师身份的系统用户列表 + */ + @PreAuthorize("@ss.hasPermi('train:teacher:bindUser')") + @GetMapping({"/train/teacher/bindable-users", "/train/teachers/bindable-users"}) + public AjaxResult getTeacherBindableUsers(@RequestParam(value = "keyword", required = false) String keyword) + { + List users = bindingAdminService.selectBindableUsers("teacher", keyword); + return success(users); + } + + /** + * 为教师关联系统用户 + */ + @PreAuthorize("@ss.hasPermi('train:teacher:bindUser')") + @Log(title = "教师账号关联", businessType = BusinessType.UPDATE) + @RequestMapping(value = {"/train/teacher/{tId}/user-binding", "/train/teachers/{tId}/user-binding"}, + method = {RequestMethod.PUT, RequestMethod.POST}) + public AjaxResult bindTeacherUser(@PathVariable("tId") String tId, + @Validated @RequestBody UserBindingDTO dto) + { + bindingAdminService.bindTeacherAccount(tId, dto.getUserId()); + return success(); + } + + /** + * 为教师解除系统用户关联 + */ + @PreAuthorize("@ss.hasPermi('train:teacher:unbindUser')") + @Log(title = "教师账号解绑", businessType = BusinessType.UPDATE) + @DeleteMapping({"/train/teacher/{tId}/user-binding", "/train/teachers/{tId}/user-binding"}) + public AjaxResult unbindTeacherUser(@PathVariable("tId") String tId) + { + bindingAdminService.unbindTeacherAccount(tId); + return success(); + } + + // ========================================== + // 学员业务档案账号绑定 + // ========================================== + + /** + * 查询可关联学员身份的系统用户列表 + */ + @PreAuthorize("@ss.hasPermi('train:student:bindUser')") + @GetMapping({"/train/student/bindable-users", "/train/students/bindable-users"}) + public AjaxResult getStudentBindableUsers(@RequestParam(value = "keyword", required = false) String keyword) + { + List users = bindingAdminService.selectBindableUsers("student", keyword); + return success(users); + } + + /** + * 为学员关联系统用户 + */ + @PreAuthorize("@ss.hasPermi('train:student:bindUser')") + @Log(title = "学员账号关联", businessType = BusinessType.UPDATE) + @RequestMapping(value = {"/train/student/{sId}/user-binding", "/train/students/{sId}/user-binding"}, + method = {RequestMethod.PUT, RequestMethod.POST}) + public AjaxResult bindStudentUser(@PathVariable("sId") String sId, + @Validated @RequestBody UserBindingDTO dto) + { + bindingAdminService.bindStudentAccount(sId, dto.getUserId()); + return success(); + } + + /** + * 为学员解除系统用户关联 + */ + @PreAuthorize("@ss.hasPermi('train:student:unbindUser')") + @Log(title = "学员账号解绑", businessType = BusinessType.UPDATE) + @DeleteMapping({"/train/student/{sId}/user-binding", "/train/students/{sId}/user-binding"}) + public AjaxResult unbindStudentUser(@PathVariable("sId") String sId) + { + bindingAdminService.unbindStudentAccount(sId); + return success(); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java index 6534454b3..a94193ea2 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java @@ -2,16 +2,6 @@ package com.ruoyi.web.controller.train; import java.util.List; import javax.validation.Valid; -import com.ruoyi.common.annotation.Log; -import com.ruoyi.common.core.controller.BaseController; -import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.core.page.TableDataInfo; -import com.ruoyi.common.enums.BusinessType; -import com.ruoyi.train.domain.dto.StudentCreateDTO; -import com.ruoyi.train.domain.query.StudentQuery; -import com.ruoyi.train.domain.vo.StudentDetailVO; -import com.ruoyi.train.domain.vo.StudentItemVO; -import com.ruoyi.train.service.ITrainStudentService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,17 +9,34 @@ 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.RestController; +import com.github.pagehelper.PageInfo; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +import com.ruoyi.train.domain.query.StudentQuery; +import com.ruoyi.train.domain.vo.StudentAdminVO; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.service.ITrainStudentService; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; /** ISSUE-01-02 学员主档 P0 API。 */ @RestController -@RequestMapping("/train/student") +@RequestMapping({"/train/student", "/train/students"}) public class TrainStudentController extends BaseController { private final ITrainStudentService trainStudentService; + private final TrainAccountBindingAdminService bindingAdminService; - public TrainStudentController(ITrainStudentService trainStudentService) + public TrainStudentController(ITrainStudentService trainStudentService, + TrainAccountBindingAdminService bindingAdminService) { this.trainStudentService = trainStudentService; + this.bindingAdminService = bindingAdminService; } @PreAuthorize("@ss.hasPermi('train:student:list')") @@ -38,7 +45,13 @@ public class TrainStudentController extends BaseController { startPage(); List list = trainStudentService.selectStudentList(query); - return getDataTable(list); + List adminList = bindingAdminService.toStudentAdminList(list); + TableDataInfo rspData = new TableDataInfo(); + rspData.setCode(HttpStatus.SUCCESS); + rspData.setMsg("查询成功"); + rspData.setRows(adminList); + rspData.setTotal(new PageInfo(list).getTotal()); + return rspData; } @PreAuthorize("@ss.hasPermi('train:student:query')") @@ -46,7 +59,11 @@ public class TrainStudentController extends BaseController public AjaxResult getInfo(@PathVariable("sId") String sId) { StudentDetailVO detail = trainStudentService.selectStudentDetail(sId); - return success(detail); + if (detail == null) + { + return success((Object) null); + } + return success(bindingAdminService.toStudentAdminVO(detail)); } @PreAuthorize("@ss.hasPermi('train:student:add')") diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java index 842f89551..799586378 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java @@ -11,7 +11,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.github.pagehelper.PageInfo; import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.constant.HttpStatus; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.PageDomain; @@ -21,7 +23,9 @@ import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.vo.TeacherAdminVO; import com.ruoyi.train.service.ITrainTeacherService; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; /** * 教师信息Controller @@ -30,7 +34,7 @@ import com.ruoyi.train.service.ITrainTeacherService; * @date 2026-09-14 */ @RestController -@RequestMapping("/train/teacher") +@RequestMapping({"/train/teacher", "/train/teachers"}) public class TrainTeacherController extends BaseController { private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); @@ -38,6 +42,9 @@ public class TrainTeacherController extends BaseController @Autowired private ITrainTeacherService trainTeacherService; + @Autowired + private TrainAccountBindingAdminService bindingAdminService; + /** * 查询教师信息列表 (GET /train/teacher/list) */ @@ -53,7 +60,13 @@ public class TrainTeacherController extends BaseController } startPage(); List list = trainTeacherService.selectTrainTeacherList(trainTeacher); - return getDataTable(list); + List adminList = bindingAdminService.toTeacherAdminList(list); + TableDataInfo rspData = new TableDataInfo(); + rspData.setCode(HttpStatus.SUCCESS); + rspData.setMsg("查询成功"); + rspData.setRows(adminList); + rspData.setTotal(new PageInfo(list).getTotal()); + return rspData; } /** @@ -68,7 +81,7 @@ public class TrainTeacherController extends BaseController { return AjaxResult.error(40401, "教师不存在"); } - return success(teacher); + return success(bindingAdminService.toTeacherAdminVO(teacher)); } /** diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java index 1b0cd9c87..c201a0f66 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java @@ -79,6 +79,7 @@ public class TrainTeacherMeController extends BaseController { throw new ServiceException("未找到对应的教师档案数据", HttpStatus.NOT_FOUND); } + teacher.setUserId(null); return success(teacher); } diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java new file mode 100644 index 000000000..28ad2d124 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java @@ -0,0 +1,460 @@ +package com.ruoyi.web.service.train; + +import java.util.ArrayList; +import java.util.Collections; +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; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.bean.BeanUtils; +import com.ruoyi.system.service.ISysRoleService; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.vo.BindableUserVO; +import com.ruoyi.train.domain.vo.StudentAdminVO; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.domain.vo.TeacherAdminVO; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.service.ITrainingIdentityService; + +/** + * 管理员端业务身份账号绑定编排服务。 + * + *

提供受控候选系统用户检索、角色校验、防静默覆盖保护, + * 并复用底层 {@link ITrainingIdentityService} 执行跨表互斥与并发锁绑定。

+ * + * @author ruoyi + */ +@Service +public class TrainAccountBindingAdminService +{ + private final ITrainingIdentityService identityService; + private final ISysUserService userService; + private final ISysRoleService roleService; + private final TrainTeacherMapper teacherMapper; + private final TrainStudentMapper studentMapper; + + public TrainAccountBindingAdminService(ITrainingIdentityService identityService, + ISysUserService userService, + ISysRoleService roleService, + TrainTeacherMapper teacherMapper, + TrainStudentMapper studentMapper) + { + this.identityService = identityService; + this.userService = userService; + this.roleService = roleService; + this.teacherMapper = teacherMapper; + this.studentMapper = studentMapper; + } + + /** + * 查询候选可绑定系统用户列表 (最小受控字段) + * + * @param targetType 目标身份类型 ("TEACHER" / "STUDENT") + * @param keyword 模糊搜索关键字 (匹配 userName 或 nickName) + * @return 候选用户视图列表 + */ + public List selectBindableUsers(String targetType, String keyword) + { + String expectedRoleKey = "TEACHER".equalsIgnoreCase(targetType) ? "train_teacher" : "train_student"; + SysUser queryUser = new SysUser(); + queryUser.setDelFlag("0"); + if (StringUtils.isNotEmpty(keyword)) + { + queryUser.setUserName(keyword.trim()); + } + List userList = userService.selectUserList(queryUser); + List result = new ArrayList<>(); + + for (SysUser user : userList) + { + // 如果搜索关键字不匹配用户名也不匹配用户昵称,则跳过 + if (StringUtils.isNotEmpty(keyword)) + { + String kw = keyword.trim().toLowerCase(); + boolean matchName = user.getUserName() != null && user.getUserName().toLowerCase().contains(kw); + boolean matchNick = user.getNickName() != null && user.getNickName().toLowerCase().contains(kw); + if (!matchName && !matchNick) + { + continue; + } + } + + BindableUserVO vo = new BindableUserVO(); + vo.setUserId(user.getUserId()); + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + vo.setStatus(user.getStatus()); + + List roles = roleService.selectRolesByUserId(user.getUserId()); + List roleKeys = roles != null + ? roles.stream().map(SysRole::getRoleKey).collect(Collectors.toList()) + : new ArrayList<>(); + vo.setRoleKeys(roleKeys); + + boolean hasTargetRole = roleKeys.contains(expectedRoleKey); + vo.setHasRole(hasTargetRole); + + // 检查当前用户是否已被绑定 + TrainTeacher boundTeacher = teacherMapper.selectTrainTeacherByUserId(user.getUserId()); + TrainStudent boundStudent = studentMapper.selectTrainStudentByUserId(user.getUserId()); + + boolean canBind = true; + String reason = ""; + + if (!"0".equals(user.getStatus())) + { + canBind = false; + reason = "账号已被停用"; + } + else if (!hasTargetRole) + { + canBind = false; + reason = "TEACHER".equalsIgnoreCase(targetType) + ? "未分配教师角色(train_teacher)" + : "未分配学员角色(train_student)"; + } + else if (boundTeacher != null) + { + vo.setBoundType("TEACHER"); + vo.setBoundBusinessId(boundTeacher.gettId()); + canBind = false; + reason = "已被教师(" + boundTeacher.gettName() + " / " + boundTeacher.gettId() + ")绑定"; + } + else if (boundStudent != null) + { + vo.setBoundType("STUDENT"); + vo.setBoundBusinessId(boundStudent.getsId()); + canBind = false; + reason = "已被学员(" + boundStudent.getsName() + " / " + boundStudent.getsId() + ")绑定"; + } + + vo.setCanBind(canBind); + vo.setReason(reason); + result.add(vo); + } + + return result; + } + + /** + * 管理员为教师绑定系统用户 (防静默覆盖、严格校验角色与账号状态) + */ + @Transactional(rollbackFor = Exception.class) + public void bindTeacherAccount(String tId, Long userId) + { + if (userId == null) + { + throw new ServiceException("绑定的系统用户ID不能为空"); + } + TrainTeacher teacher = teacherMapper.selectTrainTeacherByTId(tId); + if (teacher == null) + { + throw new ServiceException("教师业务档案不存在"); + } + if (teacher.getUserId() != null) + { + if (teacher.getUserId().equals(userId)) + { + return; // 幂等支持 + } + throw new ServiceException("该教师档案已关联系统账号(userId=" + teacher.getUserId() + "),请先解除关联后再绑定新账号"); + } + + // 严格校验目标用户有效性与 train_teacher 角色 + validateUserRole(userId, "train_teacher", "教师"); + + // 复用底层原子互斥绑定 + identityService.bindTeacherUser(tId, userId); + } + + /** + * 管理员为教师解绑系统用户 + */ + @Transactional(rollbackFor = Exception.class) + public void unbindTeacherAccount(String tId) + { + TrainTeacher teacher = teacherMapper.selectTrainTeacherByTId(tId); + if (teacher == null) + { + throw new ServiceException("教师业务档案不存在"); + } + if (teacher.getUserId() == null) + { + return; // 幂等支持 + } + identityService.unbindTeacherUser(tId); + } + + /** + * 管理员为学员绑定系统用户 (防静默覆盖、严格校验角色与账号状态) + */ + @Transactional(rollbackFor = Exception.class) + public void bindStudentAccount(String sId, Long userId) + { + if (userId == null) + { + throw new ServiceException("绑定的系统用户ID不能为空"); + } + TrainStudent student = studentMapper.selectTrainStudentBySId(sId); + if (student == null) + { + throw new ServiceException("学员业务档案不存在"); + } + if (student.getUserId() != null) + { + if (student.getUserId().equals(userId)) + { + return; // 幂等支持 + } + throw new ServiceException("该学员档案已关联系统账号(userId=" + student.getUserId() + "),请先解除关联后再绑定新账号"); + } + + // 严格校验目标用户有效性与 train_student 角色 + validateUserRole(userId, "train_student", "学员"); + + // 复用底层原子互斥绑定 + identityService.bindStudentUser(sId, userId); + } + + /** + * 管理员为学员解绑系统用户 + */ + @Transactional(rollbackFor = Exception.class) + public void unbindStudentAccount(String sId) + { + TrainStudent student = studentMapper.selectTrainStudentBySId(sId); + if (student == null) + { + throw new ServiceException("学员业务档案不存在"); + } + if (student.getUserId() == null) + { + return; // 幂等支持 + } + identityService.unbindStudentUser(sId); + } + + /** + * 批量为教师列表装配账号绑定管理视图 (单次批量查询 + 内存映射,杜绝 N+1) + * + * @param list 原始教师列表 + * @return 管理端教师视图列表 + */ + public List toTeacherAdminList(List list) + { + if (list == null || list.isEmpty()) + { + return new ArrayList<>(); + } + + // 1. 批量收集非空 userId + Set userIds = list.stream() + .map(TrainTeacher::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 2. 单次批量查询系统用户并构建映射 Map + Map userMap; + if (!userIds.isEmpty()) + { + List users = userService.selectUserByUserIds(userIds); + userMap = users.stream().collect(Collectors.toMap(SysUser::getUserId, u -> u, (k1, k2) -> k1)); + } + else + { + userMap = Collections.emptyMap(); + } + + // 3. 内存 O(1) 组装 + List result = new ArrayList<>(list.size()); + for (TrainTeacher teacher : list) + { + TeacherAdminVO vo = new TeacherAdminVO(); + BeanUtils.copyBeanProp(vo, teacher); + if (teacher.getUserId() != null) + { + vo.setAccountBindingStatus(1); + SysUser user = userMap.get(teacher.getUserId()); + if (user != null) + { + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + } + } + else + { + vo.setAccountBindingStatus(0); + } + result.add(vo); + } + return result; + } + + /** + * 为单个教师对象装配账号绑定管理视图 + * + * @param teacher 原始教师对象 + * @return 管理端教师视图对象 + */ + public TeacherAdminVO toTeacherAdminVO(TrainTeacher teacher) + { + if (teacher == null) + { + return null; + } + TeacherAdminVO vo = new TeacherAdminVO(); + BeanUtils.copyBeanProp(vo, teacher); + if (teacher.getUserId() != null) + { + vo.setAccountBindingStatus(1); + SysUser user = userService.selectUserById(teacher.getUserId()); + if (user != null) + { + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + } + } + else + { + vo.setAccountBindingStatus(0); + } + return vo; + } + + /** + * 批量为学员列表装配账号绑定管理视图 (单次批量查询 + 内存映射,杜绝 N+1) + * + * @param list 原始学员列表 + * @return 管理端学员视图列表 + */ + public List toStudentAdminList(List list) + { + if (list == null || list.isEmpty()) + { + return new ArrayList<>(); + } + + // 1. 批量收集 sId + Set sIds = list.stream() + .map(StudentItemVO::getsId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 2. 单次批量查询学员的关联 userId 映射 + Map studentUserMap; + if (!sIds.isEmpty()) + { + List students = studentMapper.selectTrainStudentsBySIds(sIds); + studentUserMap = students.stream() + .filter(s -> s.getUserId() != null) + .collect(Collectors.toMap(TrainStudent::getsId, TrainStudent::getUserId, (k1, k2) -> k1)); + } + else + { + studentUserMap = Collections.emptyMap(); + } + + // 3. 批量查询关联的系统用户 + Set userIds = new HashSet<>(studentUserMap.values()); + Map userMap; + if (!userIds.isEmpty()) + { + List users = userService.selectUserByUserIds(userIds); + userMap = users.stream().collect(Collectors.toMap(SysUser::getUserId, u -> u, (k1, k2) -> k1)); + } + else + { + userMap = Collections.emptyMap(); + } + + // 4. 内存 O(1) 组装 + List result = new ArrayList<>(list.size()); + for (StudentItemVO item : list) + { + StudentAdminVO vo = new StudentAdminVO(); + BeanUtils.copyBeanProp(vo, item); + Long uid = studentUserMap.get(item.getsId()); + if (uid != null) + { + vo.setUserId(uid); + vo.setAccountBindingStatus(1); + SysUser user = userMap.get(uid); + if (user != null) + { + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + } + } + else + { + vo.setAccountBindingStatus(0); + } + result.add(vo); + } + return result; + } + + /** + * 为单个学员详情装配账号绑定管理视图 + * + * @param detail 学员详情对象 + * @return 管理端学员视图对象 + */ + public StudentAdminVO toStudentAdminVO(StudentDetailVO detail) + { + if (detail == null) + { + return null; + } + StudentAdminVO vo = new StudentAdminVO(); + BeanUtils.copyBeanProp(vo, detail); + TrainStudent student = studentMapper.selectTrainStudentBySId(detail.getsId()); + if (student != null && student.getUserId() != null) + { + vo.setUserId(student.getUserId()); + vo.setAccountBindingStatus(1); + SysUser user = userService.selectUserById(student.getUserId()); + if (user != null) + { + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + } + } + else + { + vo.setAccountBindingStatus(0); + } + return vo; + } + + private void validateUserRole(Long userId, String requiredRoleKey, String roleLabel) + { + SysUser user = userService.selectUserById(userId); + if (user == null || "2".equals(user.getDelFlag())) + { + throw new ServiceException("指定的系统用户不存在或已删除"); + } + if (!"0".equals(user.getStatus())) + { + throw new ServiceException("指定的系统用户已被停用,禁止关联业务身份"); + } + List roles = roleService.selectRolesByUserId(userId); + boolean hasRole = roles != null && roles.stream().anyMatch(r -> requiredRoleKey.equals(r.getRoleKey())); + if (!hasRole) + { + throw new ServiceException("所选系统账号(" + user.getUserName() + ")未分配" + roleLabel + "角色(" + requiredRoleKey + "),请先在用户管理中分配对应角色"); + } + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java index fcf77b737..88ad7aa43 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java @@ -52,6 +52,10 @@ public class RbacPermissionMigrationContractTest "train:board:export", "train:me:teacher", "train:me:student", + "train:teacher:binduser", + "train:teacher:unbinduser", + "train:student:binduser", + "train:student:unbinduser", "insert into public.sys_role_menu"); // 严禁物理删除系统用户与无谓 DROP @@ -60,6 +64,31 @@ public class RbacPermissionMigrationContractTest assertFalse("must not execute truncate", sql.contains("truncate table ")); } + @Test + public void accountBindingControllersAlignPermissions() + { + // 校验 TrainAccountBindingAdminController 上的教师与学员绑定解绑权限注解 + boolean teacherBindFound = false; + boolean teacherUnbindFound = false; + boolean studentBindFound = false; + boolean studentUnbindFound = false; + for (Method method : TrainAccountBindingAdminController.class.getDeclaredMethods()) + { + if (method.isAnnotationPresent(PreAuthorize.class)) + { + String perm = method.getAnnotation(PreAuthorize.class).value(); + if (perm.contains("train:teacher:bindUser")) teacherBindFound = true; + if (perm.contains("train:teacher:unbindUser")) teacherUnbindFound = true; + if (perm.contains("train:student:bindUser")) studentBindFound = true; + if (perm.contains("train:student:unbindUser")) studentUnbindFound = true; + } + } + assertTrue("TrainAccountBindingAdminController 必须配置 train:teacher:bindUser", teacherBindFound); + assertTrue("TrainAccountBindingAdminController 必须配置 train:teacher:unbindUser", teacherUnbindFound); + assertTrue("TrainAccountBindingAdminController 必须配置 train:student:bindUser", studentBindFound); + assertTrue("TrainAccountBindingAdminController 必须配置 train:student:unbindUser", studentUnbindFound); + } + @Test public void electronicBoardControllerAlignsToTrainNamespace() { diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java index 8ed1a07ac..4834ab501 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java @@ -314,4 +314,33 @@ public class StudentSelfServiceApiTest assertEquals("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", e.getMessage()); } } + + @Test + public void testStudentProfileZeroLeakageOfAdminBindingFields() throws Exception + { + AjaxResult result = controller.getStudentProfile(); + assertEquals(200, result.get("code")); + Object data = result.get("data"); + assertTrue(data instanceof StudentDetailVO); + + Class clazz = data.getClass(); + while (clazz != null && clazz != Object.class) + { + for (java.lang.reflect.Field field : clazz.getDeclaredFields()) + { + assertFalse("学员自服务视图绝不可暴露 userId", "userId".equals(field.getName())); + assertFalse("学员自服务视图绝不可暴露 userName", "userName".equals(field.getName())); + assertFalse("学员自服务视图绝不可暴露 nickName", "nickName".equals(field.getName())); + assertFalse("学员自服务视图绝不可暴露 accountBindingStatus", "accountBindingStatus".equals(field.getName())); + } + clazz = clazz.getSuperclass(); + } + + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + String json = mapper.writeValueAsString(data); + assertFalse("JSON不得包含 userId", json.contains("\"userId\"")); + assertFalse("JSON不得包含 userName", json.contains("\"userName\"")); + assertFalse("JSON不得包含 nickName", json.contains("\"nickName\"")); + assertFalse("JSON不得包含 accountBindingStatus", json.contains("\"accountBindingStatus\"")); + } } diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java index 8d56aef6a..87fc37a1a 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java @@ -277,4 +277,30 @@ public class TeacherSelfServiceApiTest assertEquals(1, list.size()); assertEquals("S001", list.get(0).getStudentId()); } + + @Test + public void testTeacherProfileZeroLeakageOfAdminBindingFields() throws Exception + { + AjaxResult result = controller.getTeacherProfile(); + assertEquals(200, result.get("code")); + TrainTeacher teacher = (TrainTeacher) result.get("data"); + // 验证返回前 userId 已置 null + assertEquals(null, teacher.getUserId()); + + // 验证反射中 TrainTeacher 绝不存在管理端字段 + for (java.lang.reflect.Field field : TrainTeacher.class.getDeclaredFields()) + { + assertFalse("TrainTeacher 绝不可包含 userName", "userName".equals(field.getName())); + assertFalse("TrainTeacher 绝不可包含 nickName", "nickName".equals(field.getName())); + assertFalse("TrainTeacher 绝不可包含 accountBindingStatus", "accountBindingStatus".equals(field.getName())); + } + + // 验证 JSON 序列化零泄露 + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + String json = mapper.writeValueAsString(teacher); + assertFalse("教师自服务响应 JSON 绝不可包含 userId", json.contains("\"userId\"")); + assertFalse("教师自服务响应 JSON 绝不可包含 userName", json.contains("\"userName\"")); + assertFalse("教师自服务响应 JSON 绝不可包含 nickName", json.contains("\"nickName\"")); + assertFalse("教师自服务响应 JSON 绝不可包含 accountBindingStatus", json.contains("\"accountBindingStatus\"")); + } } diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminTest.java new file mode 100644 index 000000000..acba8dad8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminTest.java @@ -0,0 +1,459 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.system.service.ISysRoleService; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.dto.UserBindingDTO; +import com.ruoyi.train.domain.vo.BindableUserVO; +import com.ruoyi.train.domain.vo.StudentAdminVO; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.domain.vo.TeacherAdminVO; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.service.ITrainingIdentityService; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; + +/** + * 管理员端账号绑定业务编排与 Controller 接口测试。 + * + *

测试用例覆盖: + * 1. 候选用户查询与状态校验(包含已停用、缺失角色、已被其他教师或学员绑定的拒绝理由) + * 2. 教师绑定:正常绑定、角色不匹配拒绝、已被绑定防静默覆盖拒绝、同账号幂等 + * 3. 教师解绑:正常解绑、未绑定幂等解绑 + * 4. 学员绑定:正常绑定、角色不匹配拒绝、已被绑定防静默覆盖拒绝、同账号幂等 + * 5. 学员解绑:正常解绑、未绑定幂等解绑 + * 6. 列表信息装配 (enrich) 校验 + *

+ * + * @author ruoyi + */ +public class TrainAccountBindingAdminTest +{ + private TrainAccountBindingAdminService service; + private TrainAccountBindingAdminController controller; + + private Map userStore = new HashMap<>(); + private Map> roleStore = new HashMap<>(); + private Map teacherStore = new HashMap<>(); + private Map studentStore = new HashMap<>(); + + private List identityServiceCallLog = new ArrayList<>(); + + @Before + public void setUp() + { + userStore.clear(); + roleStore.clear(); + teacherStore.clear(); + studentStore.clear(); + identityServiceCallLog.clear(); + + // 构造用户数据 + // 1. teacherUser (具备 train_teacher 角色, 正常) + SysUser u1 = new SysUser(); + u1.setUserId(101L); + u1.setUserName("teacher_zhang"); + u1.setNickName("张老师"); + u1.setStatus("0"); + u1.setDelFlag("0"); + userStore.put(101L, u1); + + SysRole rTeacher = new SysRole(); + rTeacher.setRoleId(4L); + rTeacher.setRoleKey("train_teacher"); + roleStore.put(101L, Collections.singletonList(rTeacher)); + + // 2. studentUser (具备 train_student 角色, 正常) + SysUser u2 = new SysUser(); + u2.setUserId(102L); + u2.setUserName("student_li"); + u2.setNickName("李学员"); + u2.setStatus("0"); + u2.setDelFlag("0"); + userStore.put(102L, u2); + + SysRole rStudent = new SysRole(); + rStudent.setRoleId(5L); + rStudent.setRoleKey("train_student"); + roleStore.put(102L, Collections.singletonList(rStudent)); + + // 3. disabledUser (具备角色但已停用) + SysUser u3 = new SysUser(); + u3.setUserId(103L); + u3.setUserName("disabled_user"); + u3.setNickName("已停用用户"); + u3.setStatus("1"); + u3.setDelFlag("0"); + userStore.put(103L, u3); + roleStore.put(103L, Collections.singletonList(rTeacher)); + + // 4. normalUser (无培训角色) + SysUser u4 = new SysUser(); + u4.setUserId(104L); + u4.setUserName("common_user"); + u4.setNickName("普通用户"); + u4.setStatus("0"); + u4.setDelFlag("0"); + userStore.put(104L, u4); + SysRole rCommon = new SysRole(); + rCommon.setRoleId(2L); + rCommon.setRoleKey("common"); + roleStore.put(104L, Collections.singletonList(rCommon)); + + // 教师业务档案 + TrainTeacher t1 = new TrainTeacher(); + t1.settId("T1001"); + t1.settName("张老师档案"); + t1.setStatus(1); + t1.setDelFlag("0"); + teacherStore.put("T1001", t1); + + // 学员业务档案 + TrainStudent s1 = new TrainStudent(); + s1.setsId("S2001"); + s1.setsName("李学员档案"); + s1.setDelFlag("0"); + studentStore.put("S2001", s1); + + // Dynamic Proxy for ISysUserService + ISysUserService userService = (ISysUserService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ISysUserService.class}, + (proxy, method, args) -> { + if ("selectUserById".equals(method.getName())) { + return userStore.get((Long) args[0]); + } else if ("selectUserList".equals(method.getName())) { + return new ArrayList<>(userStore.values()); + } else if ("selectUserByUserIds".equals(method.getName())) { + @SuppressWarnings("unchecked") + java.util.Collection uids = (java.util.Collection) args[0]; + List res = new ArrayList<>(); + for (Long id : uids) { + SysUser u = userStore.get(id); + if (u != null) res.add(u); + } + return res; + } + return null; + }); + + // Dynamic Proxy for ISysRoleService + ISysRoleService roleService = (ISysRoleService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ISysRoleService.class}, + (proxy, method, args) -> { + if ("selectRolesByUserId".equals(method.getName())) { + return roleStore.getOrDefault((Long) args[0], Collections.emptyList()); + } + return Collections.emptyList(); + }); + + // Dynamic Proxy for TrainTeacherMapper + TrainTeacherMapper teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainTeacherMapper.class}, + (proxy, method, args) -> { + if ("selectTrainTeacherByTId".equals(method.getName())) { + return teacherStore.get((String) args[0]); + } else if ("selectTrainTeacherByUserId".equals(method.getName())) { + Long uid = (Long) args[0]; + return teacherStore.values().stream() + .filter(t -> uid.equals(t.getUserId())) + .findFirst().orElse(null); + } + return null; + }); + + // Dynamic Proxy for TrainStudentMapper + TrainStudentMapper studentMapper = (TrainStudentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainStudentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainStudentBySId".equals(method.getName())) { + return studentStore.get((String) args[0]); + } else if ("selectTrainStudentByUserId".equals(method.getName())) { + Long uid = (Long) args[0]; + return studentStore.values().stream() + .filter(s -> uid.equals(s.getUserId())) + .findFirst().orElse(null); + } else if ("selectTrainStudentsBySIds".equals(method.getName())) { + @SuppressWarnings("unchecked") + java.util.Collection sIds = (java.util.Collection) args[0]; + List res = new ArrayList<>(); + for (String sid : sIds) { + TrainStudent s = studentStore.get(sid); + if (s != null) res.add(s); + } + return res; + } + return null; + }); + + // Dynamic Proxy for ITrainingIdentityService + ITrainingIdentityService identityService = (ITrainingIdentityService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ITrainingIdentityService.class}, + (proxy, method, args) -> { + identityServiceCallLog.add(method.getName() + ":" + args[0] + ":" + (args.length > 1 ? args[1] : "")); + if ("bindTeacherUser".equals(method.getName())) { + TrainTeacher teacher = teacherStore.get((String) args[0]); + if (teacher != null) teacher.setUserId((Long) args[1]); + } else if ("unbindTeacherUser".equals(method.getName())) { + TrainTeacher teacher = teacherStore.get((String) args[0]); + if (teacher != null) teacher.setUserId(null); + } else if ("bindStudentUser".equals(method.getName())) { + TrainStudent student = studentStore.get((String) args[0]); + if (student != null) student.setUserId((Long) args[1]); + } else if ("unbindStudentUser".equals(method.getName())) { + TrainStudent student = studentStore.get((String) args[0]); + if (student != null) student.setUserId(null); + } + return null; + }); + + service = new TrainAccountBindingAdminService(identityService, userService, roleService, teacherMapper, studentMapper); + controller = new TrainAccountBindingAdminController(); + try { + java.lang.reflect.Field field = TrainAccountBindingAdminController.class.getDeclaredField("bindingAdminService"); + field.setAccessible(true); + field.set(controller, service); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void testSelectBindableUsersEvaluatesRolesAndStatus() + { + List teacherCandidates = service.selectBindableUsers("TEACHER", null); + assertNotNull(teacherCandidates); + assertFalse(teacherCandidates.isEmpty()); + + // teacher_zhang (101) 应该可以绑定 + BindableUserVO u101 = teacherCandidates.stream().filter(u -> u.getUserId().equals(101L)).findFirst().orElse(null); + assertNotNull(u101); + assertTrue("具备 train_teacher 角色的账号应当可绑定", u101.getCanBind()); + assertTrue(u101.getHasRole()); + + // student_li (102) 不可作为教师绑定 + BindableUserVO u102 = teacherCandidates.stream().filter(u -> u.getUserId().equals(102L)).findFirst().orElse(null); + assertNotNull(u102); + assertFalse("学员角色不可绑定教师", u102.getCanBind()); + assertTrue(u102.getReason().contains("未分配教师角色")); + + // disabled_user (103) 停用状态不可绑定 + BindableUserVO u103 = teacherCandidates.stream().filter(u -> u.getUserId().equals(103L)).findFirst().orElse(null); + assertNotNull(u103); + assertFalse("已停用账号不可绑定", u103.getCanBind()); + assertTrue(u103.getReason().contains("停用")); + + // common_user (104) 无培训角色不可绑定 + BindableUserVO u104 = teacherCandidates.stream().filter(u -> u.getUserId().equals(104L)).findFirst().orElse(null); + assertNotNull(u104); + assertFalse("普通用户无角色不可绑定", u104.getCanBind()); + } + + @Test + public void testBindTeacherSuccessAndAntiSilentOverwrite() + { + // 1. 正常绑定 + service.bindTeacherAccount("T1001", 101L); + assertEquals(Long.valueOf(101L), teacherStore.get("T1001").getUserId()); + assertTrue(identityServiceCallLog.contains("bindTeacherUser:T1001:101")); + + // 2. 幂等重复绑定相同账号:直接成功且不抛异常 + service.bindTeacherAccount("T1001", 101L); + + // 3. 静默覆盖保护:已有绑定时尝试绑定另一个账号应抛出 ServiceException + try { + service.bindTeacherAccount("T1001", 102L); + fail("已有绑定时应拒绝直接覆盖"); + } catch (ServiceException se) { + assertTrue(se.getMessage().contains("请先解除关联后再绑定新账号")); + } + + // 4. 解绑后可重新绑定 + service.unbindTeacherAccount("T1001"); + assertEquals(null, teacherStore.get("T1001").getUserId()); + + // 5. 再次解绑:幂等不报错 + service.unbindTeacherAccount("T1001"); + } + + @Test + public void testBindTeacherRejectsMismatchedRole() + { + try { + service.bindTeacherAccount("T1001", 102L); // 102 是学员 + fail("应当拒绝绑定未分配教师角色的系统账号"); + } catch (ServiceException se) { + assertTrue(se.getMessage().contains("未分配教师角色")); + } + } + + @Test + public void testBindStudentSuccessAndAntiSilentOverwrite() + { + // 1. 正常绑定 + service.bindStudentAccount("S2001", 102L); + assertEquals(Long.valueOf(102L), studentStore.get("S2001").getUserId()); + assertTrue(identityServiceCallLog.contains("bindStudentUser:S2001:102")); + + // 2. 幂等重复绑定相同账号 + service.bindStudentAccount("S2001", 102L); + + // 3. 静默覆盖保护 + try { + service.bindStudentAccount("S2001", 101L); + fail("已有绑定时应拒绝直接覆盖"); + } catch (ServiceException se) { + assertTrue(se.getMessage().contains("请先解除关联后再绑定新账号")); + } + + // 4. 解绑 + service.unbindStudentAccount("S2001"); + assertEquals(null, studentStore.get("S2001").getUserId()); + + // 5. 幂等解绑 + service.unbindStudentAccount("S2001"); + } + + @Test + public void testBindStudentRejectsMismatchedRole() + { + try { + service.bindStudentAccount("S2001", 101L); // 101 是教师 + fail("应当拒绝绑定未分配学员角色的系统账号"); + } catch (ServiceException se) { + assertTrue(se.getMessage().contains("未分配学员角色")); + } + } + + @Test + public void testControllerEndpointsDelegateToService() + { + // 测试 Controller 教师绑定调用 + UserBindingDTO dto = new UserBindingDTO(); + dto.setUserId(101L); + AjaxResult res = controller.bindTeacherUser("T1001", dto); + assertEquals(200, res.get("code")); + assertEquals(Long.valueOf(101L), teacherStore.get("T1001").getUserId()); + + // 测试 Controller 教师候选人查询 + AjaxResult candidateRes = controller.getTeacherBindableUsers(null); + assertEquals(200, candidateRes.get("code")); + + // 测试 Controller 教师解绑调用 + AjaxResult unbindRes = controller.unbindTeacherUser("T1001"); + assertEquals(200, unbindRes.get("code")); + assertEquals(null, teacherStore.get("T1001").getUserId()); + + // 测试 Controller 学员绑定调用 + UserBindingDTO sDto = new UserBindingDTO(); + sDto.setUserId(102L); + AjaxResult sRes = controller.bindStudentUser("S2001", sDto); + assertEquals(200, sRes.get("code")); + assertEquals(Long.valueOf(102L), studentStore.get("S2001").getUserId()); + + // 测试 Controller 学员解绑调用 + AjaxResult sUnbindRes = controller.unbindStudentUser("S2001"); + assertEquals(200, sUnbindRes.get("code")); + assertEquals(null, studentStore.get("S2001").getUserId()); + } + + @Test + public void testTeacherAndStudentAdminVOAssemblyAndZeroLeakage() + { + // 1. 教师列表批量装配与单体验证 + TrainTeacher t1 = new TrainTeacher(); + t1.settId("T1001"); + t1.setUserId(101L); + t1.settName("张老师"); + + TrainTeacher tUnbound = new TrainTeacher(); + tUnbound.settId("T1002"); + tUnbound.settName("未绑定教师"); + + List teacherList = new ArrayList<>(); + teacherList.add(t1); + teacherList.add(tUnbound); + + List teacherAdminList = service.toTeacherAdminList(teacherList); + assertEquals(2, teacherAdminList.size()); + + TeacherAdminVO vo1 = teacherAdminList.get(0); + assertEquals(Integer.valueOf(1), vo1.getAccountBindingStatus()); + assertEquals("teacher_zhang", vo1.getUserName()); + assertEquals("张老师", vo1.getNickName()); + + TeacherAdminVO voUnbound = teacherAdminList.get(1); + assertEquals(Integer.valueOf(0), voUnbound.getAccountBindingStatus()); + assertEquals(null, voUnbound.getUserName()); + + TeacherAdminVO singleTeacherVO = service.toTeacherAdminVO(t1); + assertEquals(Integer.valueOf(1), singleTeacherVO.getAccountBindingStatus()); + assertEquals("teacher_zhang", singleTeacherVO.getUserName()); + + // 2. 学员列表批量装配与单体验证 + studentStore.get("S2001").setUserId(102L); + + TrainStudent sUnboundStudent = new TrainStudent(); + sUnboundStudent.setsId("S2002"); + sUnboundStudent.setsName("未绑定学员"); + studentStore.put("S2002", sUnboundStudent); + + StudentItemVO sItem1 = new StudentItemVO(); + sItem1.setsId("S2001"); + sItem1.setsName("李学员"); + + StudentItemVO sItemUnbound = new StudentItemVO(); + sItemUnbound.setsId("S2002"); + sItemUnbound.setsName("未绑定学员"); + + List studentItemList = new ArrayList<>(); + studentItemList.add(sItem1); + studentItemList.add(sItemUnbound); + + List studentAdminList = service.toStudentAdminList(studentItemList); + assertEquals(2, studentAdminList.size()); + + StudentAdminVO sAdmin1 = studentAdminList.get(0); + assertEquals(Long.valueOf(102L), sAdmin1.getUserId()); + assertEquals(Integer.valueOf(1), sAdmin1.getAccountBindingStatus()); + assertEquals("student_li", sAdmin1.getUserName()); + assertEquals("李学员", sAdmin1.getNickName()); + + StudentAdminVO sAdminUnbound = studentAdminList.get(1); + assertEquals(null, sAdminUnbound.getUserId()); + assertEquals(Integer.valueOf(0), sAdminUnbound.getAccountBindingStatus()); + assertEquals(null, sAdminUnbound.getUserName()); + + StudentDetailVO sDetail = new StudentDetailVO(); + sDetail.setsId("S2001"); + sDetail.setsName("李学员"); + StudentAdminVO singleStudentVO = service.toStudentAdminVO(sDetail); + assertEquals(Long.valueOf(102L), singleStudentVO.getUserId()); + assertEquals(Integer.valueOf(1), singleStudentVO.getAccountBindingStatus()); + assertEquals("student_li", singleStudentVO.getUserName()); + } +} diff --git a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java index 543c59cb6..8a3f17e54 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java +++ b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java @@ -52,6 +52,14 @@ public interface SysUserMapper */ public SysUser selectUserById(Long userId); + /** + * 根据用户ID列表批量查询用户 + * + * @param userIds 用户ID集合 + * @return 用户列表 + */ + public List selectUserByUserIds(@Param("userIds") java.util.Collection userIds); + /** * 新增用户信息 * diff --git a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java index 067245bd9..592651d3a 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java +++ b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java @@ -51,6 +51,14 @@ public interface ISysUserService */ public SysUser selectUserById(Long userId); + /** + * 根据用户ID列表批量查询用户 + * + * @param userIds 用户ID集合 + * @return 用户列表 + */ + public List selectUserByUserIds(java.util.Collection userIds); + /** * 根据用户ID查询用户所属角色组 * diff --git a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java index 7b83d0422..56e0b681d 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java +++ b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java @@ -129,6 +129,22 @@ public class SysUserServiceImpl implements ISysUserService return userMapper.selectUserById(userId); } + /** + * 根据用户ID列表批量查询用户 + * + * @param userIds 用户ID集合 + * @return 用户列表 + */ + @Override + public List selectUserByUserIds(java.util.Collection userIds) + { + if (userIds == null || userIds.isEmpty()) + { + return java.util.Collections.emptyList(); + } + return userMapper.selectUserByUserIds(userIds); + } + /** * 查询用户所属角色组 * diff --git a/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index f4ecdf714..9e11cab46 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -130,6 +130,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where u.user_id = #{userId} + + + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java index cbdea29e8..a9afa5f09 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java @@ -941,6 +941,24 @@ public class EnrollmentAppServiceImplTest return studentStore.get(sId); } + @Override + public List selectTrainStudentsBySIds(java.util.Collection sIds) + { + List list = new ArrayList<>(); + if (sIds != null) + { + for (String sId : sIds) + { + TrainStudent s = studentStore.get(sId); + if (s != null) + { + list.add(s); + } + } + } + return list; + } + @Override public List selectTrainStudentList(StudentQuery query) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java index d4efac556..7e93937ca 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java @@ -258,6 +258,24 @@ public class TrainStudentServiceImplTest return store.get(sId); } + @Override + public List selectTrainStudentsBySIds(java.util.Collection sIds) + { + List list = new ArrayList<>(); + if (sIds != null) + { + for (String sId : sIds) + { + TrainStudent s = store.get(sId); + if (s != null) + { + list.add(s); + } + } + } + return list; + } + @Override public List selectTrainStudentList(StudentQuery query) { diff --git a/RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql b/RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql index 6151550a3..d08afcebe 100644 --- a/RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql +++ b/RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql @@ -40,13 +40,13 @@ BEGIN END IF; END LOOP; - -- 0.3 检查 menu_id 2201 ~ 2240 的碰撞:若ID已存在,必须属于预期本系统权限,严禁覆盖外来资源 + -- 0.3 检查 menu_id 2201 ~ 2244 的碰撞:若ID已存在,必须属于预期本系统权限,严禁覆盖外来资源 IF EXISTS ( SELECT 1 FROM public.sys_menu - WHERE menu_id BETWEEN 2201 AND 2240 + WHERE menu_id BETWEEN 2201 AND 2244 AND (perms IS NULL OR perms NOT LIKE 'train:%') ) THEN - RAISE EXCEPTION 'Collision detected: sys_menu.menu_id in 2201..2240 is occupied by non-train permissions'; + RAISE EXCEPTION 'Collision detected: sys_menu.menu_id in 2201..2244 is occupied by non-train permissions'; END IF; END $$; @@ -104,6 +104,14 @@ INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, com SELECT 2204, '教师修改', 2155, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:edit', '#', 'admin', CURRENT_TIMESTAMP, '教师修改' WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2204 OR perms = 'train:teacher:edit'); +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2241, '教师账号关联', 2155, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:bindUser', '#', 'admin', CURRENT_TIMESTAMP, '教师系统账号关联' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2241 OR perms = 'train:teacher:bindUser'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2242, '教师账号解绑', 2155, 6, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:unbindUser', '#', 'admin', CURRENT_TIMESTAMP, '教师系统账号解绑' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2242 OR perms = 'train:teacher:unbindUser'); + -- 3.2 学员管理 (parent: 2156) INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) SELECT 2205, '学员查询', 2156, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:list', '#', 'admin', CURRENT_TIMESTAMP, '学员列表查询' @@ -117,6 +125,14 @@ INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, com SELECT 2207, '学员新增', 2156, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:add', '#', 'admin', CURRENT_TIMESTAMP, '学员新增' WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2207 OR perms = 'train:student:add'); +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2243, '学员账号关联', 2156, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:bindUser', '#', 'admin', CURRENT_TIMESTAMP, '学员系统账号关联' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2243 OR perms = 'train:student:bindUser'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2244, '学员账号解绑', 2156, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:unbindUser', '#', 'admin', CURRENT_TIMESTAMP, '学员系统账号解绑' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2244 OR perms = 'train:student:unbindUser'); + -- 3.3 教室管理 (parent: 2157) INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) SELECT 2208, '教室查询', 2157, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:classroom:list', '#', 'admin', CURRENT_TIMESTAMP, '教室列表查询' @@ -266,7 +282,7 @@ WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2240 OR perms = INSERT INTO public.sys_role_menu (role_id, menu_id) SELECT 3, m.menu_id FROM public.sys_menu m -WHERE (m.menu_id BETWEEN 2154 AND 2183 OR m.menu_id BETWEEN 2201 AND 2238) +WHERE (m.menu_id BETWEEN 2154 AND 2183 OR m.menu_id BETWEEN 2201 AND 2238 OR m.menu_id BETWEEN 2241 AND 2244) AND NOT EXISTS ( SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 3 AND rm.menu_id = m.menu_id ); @@ -302,6 +318,14 @@ BEGIN RAISE EXCEPTION 'Student self-service permission missing'; END IF; + IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:teacher:bindUser') THEN + RAISE EXCEPTION 'Teacher binding permission missing'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:student:bindUser') THEN + RAISE EXCEPTION 'Student binding permission missing'; + END IF; + IF EXISTS (SELECT 1 FROM public.sys_menu WHERE perms LIKE 'system:board:%') THEN RAISE EXCEPTION 'Legacy system:board:* permission namespace still exists'; END IF; diff --git a/build_ui/src/api/train/student.js b/build_ui/src/api/train/student.js index d5278cf75..28d9808e7 100644 --- a/build_ui/src/api/train/student.js +++ b/build_ui/src/api/train/student.js @@ -25,3 +25,29 @@ export function addStudent(data) { data: data }) } + +// 查询可关联学员的系统用户候选列表 +export function getStudentBindableUsers(keyword) { + return request({ + url: '/train/student/bindable-users', + method: 'get', + params: { keyword } + }) +} + +// 为学员关联系统用户账号 +export function bindStudentUser(sId, userId) { + return request({ + url: '/train/student/' + sId + '/user-binding', + method: 'put', + data: { userId } + }) +} + +// 为学员解除系统用户账号关联 +export function unbindStudentUser(sId) { + return request({ + url: '/train/student/' + sId + '/user-binding', + method: 'delete' + }) +} diff --git a/build_ui/src/api/train/teacher.js b/build_ui/src/api/train/teacher.js index 885282467..8ce6568f8 100644 --- a/build_ui/src/api/train/teacher.js +++ b/build_ui/src/api/train/teacher.js @@ -34,3 +34,29 @@ export function updateTeacher(data) { data: data }) } + +// 查询可关联教师的系统用户候选列表 +export function getTeacherBindableUsers(keyword) { + return request({ + url: '/train/teacher/bindable-users', + method: 'get', + params: { keyword } + }) +} + +// 为教师关联系统用户账号 +export function bindTeacherUser(tId, userId) { + return request({ + url: '/train/teacher/' + tId + '/user-binding', + method: 'put', + data: { userId } + }) +} + +// 为教师解除系统用户账号关联 +export function unbindTeacherUser(tId) { + return request({ + url: '/train/teacher/' + tId + '/user-binding', + method: 'delete' + }) +} diff --git a/build_ui/src/views/train/student/index.vue b/build_ui/src/views/train/student/index.vue index 5d0bfa11d..49c76d591 100644 --- a/build_ui/src/views/train/student/index.vue +++ b/build_ui/src/views/train/student/index.vue @@ -82,7 +82,19 @@ - + + + + @@ -155,9 +184,13 @@ - + {{ detail.sName || '-' }} {{ detail.sId || '-' }} + + {{ detail.userName }} ({{ detail.nickName }}) + 未关联 + {{ detail.orgName || '-' }} {{ detail.sPhone || '-' }} {{ detail.sEmail || '-' }} @@ -166,11 +199,71 @@ {{ detail.remark || '-' }} + + + + +
+ 当前学员:{{ currentStudent.sName }} (学员编号: {{ currentStudent.sId }}) +
+ + + + + + 搜索 + + + + + + + + + + + + + + + + + + + +