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 @@
-
+
+
+
+
已关联
+
+ {{ scope.row.userName }}
+ ({{ scope.row.nickName }})
+
+
+ 未关联
+
+
+
详情
+ 关联账号
+ 解绑账号
@@ -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 }})
+
+
+
+
+
+
+ 搜索
+
+
+
+
+
+ {{ '' }}
+
+
+
+
+
+
+
+ {{ scope.row.status === '0' ? '正常' : '停用' }}
+
+
+
+
+
+ {{ (scope.row.roleKeys || []).join(', ') }}
+
+
+
+
+ 可选
+ {{ scope.row.reason }}
+
+
+
+
+