新增权限:实现管理员账户绑定流程

This commit is contained in:
wangzhibiao committed 2026-09-16 09:59:01 +08:00
1 parent d8a963f611
commit d00d039ef5
27 files changed
+1946 -26

No files matched your search

@@ -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。
*
* <p>为管理员提供受控的系统账号与教师/学员业务档案关联与解绑功能,
* 满足 Wave 2.1 / Wave 3 真实三角色生命周期管理流。</p>
*
* @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<BindableUserVO> 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<BindableUserVO> 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();
}
}
@@ -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<StudentItemVO> list = trainStudentService.selectStudentList(query);
return getDataTable(list);
List<StudentAdminVO> 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')")
@@ -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<TrainTeacher> list = trainTeacherService.selectTrainTeacherList(trainTeacher);
return getDataTable(list);
List<TeacherAdminVO> 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));
}
/**
@@ -79,6 +79,7 @@ public class TrainTeacherMeController extends BaseController
{
throw new ServiceException("未找到对应的教师档案数据", HttpStatus.NOT_FOUND);
}
teacher.setUserId(null);
return success(teacher);
}
@@ -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;
/**
* 管理员端业务身份账号绑定编排服务。
*
* <p>提供受控候选系统用户检索、角色校验、防静默覆盖保护,
* 并复用底层 {@link ITrainingIdentityService} 执行跨表互斥与并发锁绑定。</p>
*
* @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<BindableUserVO> 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<SysUser> userList = userService.selectUserList(queryUser);
List<BindableUserVO> 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<SysRole> roles = roleService.selectRolesByUserId(user.getUserId());
List<String> 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<TeacherAdminVO> toTeacherAdminList(List<TrainTeacher> list)
{
if (list == null || list.isEmpty())
{
return new ArrayList<>();
}
// 1. 批量收集非空 userId
Set<Long> userIds = list.stream()
.map(TrainTeacher::getUserId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
// 2. 单次批量查询系统用户并构建映射 Map
Map<Long, SysUser> userMap;
if (!userIds.isEmpty())
{
List<SysUser> 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<TeacherAdminVO> 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<StudentAdminVO> toStudentAdminList(List<StudentItemVO> list)
{
if (list == null || list.isEmpty())
{
return new ArrayList<>();
}
// 1. 批量收集 sId
Set<String> sIds = list.stream()
.map(StudentItemVO::getsId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
// 2. 单次批量查询学员的关联 userId 映射
Map<String, Long> studentUserMap;
if (!sIds.isEmpty())
{
List<TrainStudent> 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<Long> userIds = new HashSet<>(studentUserMap.values());
Map<Long, SysUser> userMap;
if (!userIds.isEmpty())
{
List<SysUser> 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<StudentAdminVO> 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<SysRole> 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 + "),请先在用户管理中分配对应角色");
}
}
}
@@ -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()
{
@@ -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\""));
}
}
@@ -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\""));
}
}
@@ -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 接口测试。
*
* <p>测试用例覆盖:
* 1. 候选用户查询与状态校验(包含已停用、缺失角色、已被其他教师或学员绑定的拒绝理由)
* 2. 教师绑定:正常绑定、角色不匹配拒绝、已被绑定防静默覆盖拒绝、同账号幂等
* 3. 教师解绑:正常解绑、未绑定幂等解绑
* 4. 学员绑定:正常绑定、角色不匹配拒绝、已被绑定防静默覆盖拒绝、同账号幂等
* 5. 学员解绑:正常解绑、未绑定幂等解绑
* 6. 列表信息装配 (enrich) 校验
* </p>
*
* @author ruoyi
*/
public class TrainAccountBindingAdminTest
{
private TrainAccountBindingAdminService service;
private TrainAccountBindingAdminController controller;
private Map<Long, SysUser> userStore = new HashMap<>();
private Map<Long, List<SysRole>> roleStore = new HashMap<>();
private Map<String, TrainTeacher> teacherStore = new HashMap<>();
private Map<String, TrainStudent> studentStore = new HashMap<>();
private List<String> 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<Long> uids = (java.util.Collection<Long>) args[0];
List<SysUser> 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<String> sIds = (java.util.Collection<String>) args[0];
List<TrainStudent> 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<BindableUserVO> 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<TrainTeacher> teacherList = new ArrayList<>();
teacherList.add(t1);
teacherList.add(tUnbound);
List<TeacherAdminVO> 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<StudentItemVO> studentItemList = new ArrayList<>();
studentItemList.add(sItem1);
studentItemList.add(sItemUnbound);
List<StudentAdminVO> 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());
}
}
@@ -52,6 +52,14 @@ public interface SysUserMapper
*/
public SysUser selectUserById(Long userId);
/**
* 根据用户ID列表批量查询用户
*
* @param userIds 用户ID集合
* @return 用户列表
*/
public List<SysUser> selectUserByUserIds(@Param("userIds") java.util.Collection<Long> userIds);
/**
* 新增用户信息
*
@@ -51,6 +51,14 @@ public interface ISysUserService
*/
public SysUser selectUserById(Long userId);
/**
* 根据用户ID列表批量查询用户
*
* @param userIds 用户ID集合
* @return 用户列表
*/
public List<SysUser> selectUserByUserIds(java.util.Collection<Long> userIds);
/**
* 根据用户ID查询用户所属角色组
*
@@ -129,6 +129,22 @@ public class SysUserServiceImpl implements ISysUserService
return userMapper.selectUserById(userId);
}
/**
* 根据用户ID列表批量查询用户
*
* @param userIds 用户ID集合
* @return 用户列表
*/
@Override
public List<SysUser> selectUserByUserIds(java.util.Collection<Long> userIds)
{
if (userIds == null || userIds.isEmpty())
{
return java.util.Collections.emptyList();
}
return userMapper.selectUserByUserIds(userIds);
}
/**
* 查询用户所属角色组
*
@@ -130,6 +130,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<include refid="selectUserVo"/>
where u.user_id = #{userId}
</select>
<select id="selectUserByUserIds" resultMap="SysUserResult">
select u.user_id, u.dept_id, u.nick_name, u.user_name, u.email, u.phonenumber, u.status, u.del_flag
from public.sys_user u
where u.del_flag = '0' and u.user_id in
<foreach collection="userIds" item="userId" open="(" separator="," close=")">
#{userId}
</foreach>
</select>
<select id="checkUserNameUnique" parameterType="String" resultMap="SysUserResult">
select user_id, user_name from public.sys_user where user_name = #{userName} and del_flag = '0' limit 1
@@ -34,6 +34,7 @@ public class TrainTeacher extends BaseEntity
private Date tBirth;
/** sys_user登录账号逻辑关联ID */
@com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
private Long userId;
/** 所属单位/部门名称 (由旧 t_department 迁移) */
@@ -0,0 +1,27 @@
package com.ruoyi.train.domain.dto;
import java.io.Serializable;
import javax.validation.constraints.NotNull;
/**
* 业务实体关联系统账号请求 DTO。
*
* @author ruoyi
*/
public class UserBindingDTO implements Serializable
{
private static final long serialVersionUID = 1L;
@NotNull(message = "系统用户ID不能为空")
private Long userId;
public Long getUserId()
{
return userId;
}
public void setUserId(Long userId)
{
this.userId = userId;
}
}
@@ -0,0 +1,154 @@
package com.ruoyi.train.domain.vo;
import java.io.Serializable;
import java.util.List;
/**
* 候选系统账号视图对象 (最小受控字段,严禁泄露密码/Salt/安全信息)。
*
* @author ruoyi
*/
public class BindableUserVO implements Serializable
{
private static final long serialVersionUID = 1L;
/** 系统用户ID */
private Long userId;
/** 登录账号 */
private String userName;
/** 用户昵称 */
private String nickName;
/** 账号状态 (0:正常, 1:停用) */
private String status;
/** 拥有角色键列表 */
private List<String> roleKeys;
/** 是否具有目标业务角色 */
private boolean hasRole;
/** 当前已绑定身份类型 (TEACHER / STUDENT / null) */
private String boundType;
/** 当前已绑定的业务ID */
private String boundBusinessId;
/** 是否可被当前业务实体绑定 */
private boolean canBind;
/** 状态与不可绑定原因说明 */
private String reason;
public Long getUserId()
{
return userId;
}
public void setUserId(Long userId)
{
this.userId = userId;
}
public String getUserName()
{
return userName;
}
public void setUserName(String userName)
{
this.userName = userName;
}
public String getNickName()
{
return nickName;
}
public void setNickName(String nickName)
{
this.nickName = nickName;
}
public String getStatus()
{
return status;
}
public void setStatus(String status)
{
this.status = status;
}
public List<String> getRoleKeys()
{
return roleKeys;
}
public void setRoleKeys(List<String> roleKeys)
{
this.roleKeys = roleKeys;
}
public boolean isHasRole()
{
return hasRole;
}
public boolean getHasRole()
{
return hasRole;
}
public void setHasRole(boolean hasRole)
{
this.hasRole = hasRole;
}
public String getBoundType()
{
return boundType;
}
public void setBoundType(String boundType)
{
this.boundType = boundType;
}
public String getBoundBusinessId()
{
return boundBusinessId;
}
public void setBoundBusinessId(String boundBusinessId)
{
this.boundBusinessId = boundBusinessId;
}
public boolean isCanBind()
{
return canBind;
}
public boolean getCanBind()
{
return canBind;
}
public void setCanBind(boolean canBind)
{
this.canBind = canBind;
}
public String getReason()
{
return reason;
}
public void setReason(String reason)
{
this.reason = reason;
}
}
@@ -0,0 +1,63 @@
package com.ruoyi.train.domain.vo;
/**
* 管理端学员信息视图对象 (包含系统账号绑定扩展信息,严禁暴露至自服务接口)。
*
* @author ruoyi
*/
public class StudentAdminVO extends StudentDetailVO
{
private static final long serialVersionUID = 1L;
/** 绑定的系统用户ID */
private Long userId;
/** 绑定的系统登录账号 */
private String userName;
/** 绑定的用户昵称 */
private String nickName;
/** 账号关联状态 (0: 未关联, 1: 已关联) */
private Integer accountBindingStatus;
public Long getUserId()
{
return userId;
}
public void setUserId(Long userId)
{
this.userId = userId;
}
public String getUserName()
{
return userName;
}
public void setUserName(String userName)
{
this.userName = userName;
}
public String getNickName()
{
return nickName;
}
public void setNickName(String nickName)
{
this.nickName = nickName;
}
public Integer getAccountBindingStatus()
{
return accountBindingStatus;
}
public void setAccountBindingStatus(Integer accountBindingStatus)
{
this.accountBindingStatus = accountBindingStatus;
}
}
@@ -0,0 +1,52 @@
package com.ruoyi.train.domain.vo;
import com.ruoyi.train.domain.TrainTeacher;
/**
* 管理端教师信息视图对象 (包含账号绑定扩展信息,严禁暴露至学员/教师自服务接口)。
*
* @author ruoyi
*/
public class TeacherAdminVO extends TrainTeacher
{
private static final long serialVersionUID = 1L;
/** 绑定的系统登录账号 (仅管理端展示) */
private String userName;
/** 绑定的用户昵称 (仅管理端展示) */
private String nickName;
/** 账号关联状态 (0: 未关联, 1: 已关联) */
private Integer accountBindingStatus;
public String getUserName()
{
return userName;
}
public void setUserName(String userName)
{
this.userName = userName;
}
public String getNickName()
{
return nickName;
}
public void setNickName(String nickName)
{
this.nickName = nickName;
}
public Integer getAccountBindingStatus()
{
return accountBindingStatus;
}
public void setAccountBindingStatus(Integer accountBindingStatus)
{
this.accountBindingStatus = accountBindingStatus;
}
}
@@ -9,6 +9,8 @@ public interface TrainStudentMapper
{
TrainStudent selectTrainStudentBySId(String sId);
List<TrainStudent> selectTrainStudentsBySIds(@org.apache.ibatis.annotations.Param("sIds") java.util.Collection<String> sIds);
List<TrainStudent> selectTrainStudentList(StudentQuery query);
/** 使用冻结的 id_card_hash 做精确防重;存在时返回 1,不存在返回 null。 */
@@ -61,6 +61,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where s_id = #{sId} and del_flag = '0'
</select>
<select id="selectTrainStudentsBySIds" resultMap="TrainStudentResult">
<include refid="selectTrainStudentDomain" />
where del_flag = '0' and s_id in
<foreach collection="sIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<select id="selectOneByIdCardHash" parameterType="String" resultType="java.lang.Integer">
select 1 from train_student where id_card_hash = #{idCardHash} limit 1
</select>
@@ -941,6 +941,24 @@ public class EnrollmentAppServiceImplTest
return studentStore.get(sId);
}
@Override
public List<TrainStudent> selectTrainStudentsBySIds(java.util.Collection<String> sIds)
{
List<TrainStudent> 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<TrainStudent> selectTrainStudentList(StudentQuery query)
{
@@ -258,6 +258,24 @@ public class TrainStudentServiceImplTest
return store.get(sId);
}
@Override
public List<TrainStudent> selectTrainStudentsBySIds(java.util.Collection<String> sIds)
{
List<TrainStudent> 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<TrainStudent> selectTrainStudentList(StudentQuery query)
{
@@ -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;
+26
View File
@@ -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'
})
}
+26
View File
@@ -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'
})
}
+158 -3
View File
@@ -82,7 +82,19 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="90">
<el-table-column label="关联系统账号" align="center" min-width="150">
<template slot-scope="scope">
<div v-if="scope.row.accountBindingStatus === 1 || scope.row.userName">
<el-tag type="success" size="mini">已关联</el-tag>
<div style="font-size: 12px; color: #606266; margin-top: 2px;">
{{ scope.row.userName }}
<span v-if="scope.row.nickName">({{ scope.row.nickName }})</span>
</div>
</div>
<el-tag v-else type="info" size="mini">未关联</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:student:query']"
@@ -91,6 +103,23 @@
icon="el-icon-view"
@click="handleDetail(scope.row)"
>详情</el-button>
<el-button
v-if="!scope.row.userName && !scope.row.userId"
v-hasPermi="['train:student:bindUser']"
size="mini"
type="text"
icon="el-icon-link"
@click="handleOpenBinding(scope.row)"
>关联账号</el-button>
<el-button
v-else
v-hasPermi="['train:student:unbindUser']"
size="mini"
type="text"
icon="el-icon-circle-close"
style="color: #F56C6C;"
@click="handleUnbind(scope.row)"
>解绑账号</el-button>
</template>
</el-table-column>
</el-table>
@@ -155,9 +184,13 @@
</el-dialog>
<el-dialog title="学员主档详情" :visible.sync="detailOpen" width="560px" append-to-body>
<el-form :model="detail" label-width="90px" class="student-detail">
<el-form :model="detail" label-width="110px" class="student-detail">
<el-form-item label="学员姓名"><span>{{ detail.sName || '-' }}</span></el-form-item>
<el-form-item label="系统编号"><span>{{ detail.sId || '-' }}</span></el-form-item>
<el-form-item label="关联系统账号">
<span v-if="detail.userName">{{ detail.userName }} <span v-if="detail.nickName">({{ detail.nickName }})</span></span>
<el-tag v-else type="info" size="mini">未关联</el-tag>
</el-form-item>
<el-form-item label="所属单位"><span>{{ detail.orgName || '-' }}</span></el-form-item>
<el-form-item label="联系电话"><span>{{ detail.sPhone || '-' }}</span></el-form-item>
<el-form-item label="邮箱"><span>{{ detail.sEmail || '-' }}</span></el-form-item>
@@ -166,11 +199,71 @@
<el-form-item label="备注"><span>{{ detail.remark || '-' }}</span></el-form-item>
</el-form>
</el-dialog>
<!-- 关联系统账号对话框 -->
<el-dialog title="关联系统账号 (学员业务档案)" :visible.sync="bindingOpen" width="700px" append-to-body>
<el-alert
title="关联说明"
type="info"
description="将学员业务档案与系统用户账号关联后,学员可凭该系统账号登录并使用“学员中心”。系统仅允许选择正常启用且已分配学员角色(train_student)的账号。"
show-icon
:closable="false"
style="margin-bottom: 15px;"
/>
<div style="margin-bottom: 15px; font-weight: bold; color: #303133;">
当前学员:{{ currentStudent.sName }} (学员编号: {{ currentStudent.sId }})
</div>
<el-form size="small" :inline="true" @submit.native.prevent>
<el-form-item label="查找用户">
<el-input
v-model="bindingKeyword"
placeholder="请输入用户名或姓名搜索"
clearable
style="width: 240px;"
@keyup.enter.native="loadBindableUsers"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="loadBindableUsers">搜索</el-button>
</el-form-item>
</el-form>
<el-table v-loading="bindingLoading" :data="bindableUsers" max-height="300" highlight-current-row @current-change="handleSelectedUserChange">
<el-table-column width="55" align="center">
<template slot-scope="scope">
<el-radio v-model="selectedUserId" :label="scope.row.userId" :disabled="!scope.row.canBind">{{ '' }}</el-radio>
</template>
</el-table-column>
<el-table-column label="系统账号" prop="userName" min-width="110" />
<el-table-column label="姓名/昵称" prop="nickName" min-width="110" />
<el-table-column label="状态" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'" size="mini">
{{ scope.row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分配角色" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ (scope.row.roleKeys || []).join(', ') }}</span>
</template>
</el-table-column>
<el-table-column label="可否关联" min-width="150">
<template slot-scope="scope">
<el-tag v-if="scope.row.canBind" type="success" size="mini">可选</el-tag>
<span v-else style="color: #F56C6C; font-size: 12px;">{{ scope.row.reason }}</span>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :disabled="!selectedUserId" :loading="bindingSubmitting" @click="submitBinding">确 定 关 联</el-button>
<el-button @click="bindingOpen = false">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { addStudent, getStudent, listStudent } from '@/api/train/student'
import { addStudent, getStudent, listStudent, getStudentBindableUsers, bindStudentUser, unbindStudentUser } from '@/api/train/student'
export default {
name: 'Student',
@@ -195,6 +288,14 @@ export default {
createOpen: false,
detailOpen: false,
detail: {},
// 账号关联弹窗
bindingOpen: false,
bindingLoading: false,
bindingSubmitting: false,
bindingKeyword: '',
bindableUsers: [],
currentStudent: {},
selectedUserId: null,
queryParams: {
pageNum: 1,
pageSize: 10,
@@ -281,6 +382,60 @@ export default {
this.detailOpen = true
})
},
/** 打开关联系统账号对话框 */
handleOpenBinding(row) {
this.currentStudent = row
this.selectedUserId = null
this.bindingKeyword = ''
this.bindableUsers = []
this.bindingOpen = true
this.loadBindableUsers()
},
/** 加载候选用户列表 */
loadBindableUsers() {
this.bindingLoading = true
getStudentBindableUsers(this.bindingKeyword).then(response => {
this.bindableUsers = response.data || []
this.bindingLoading = false
}).catch(() => {
this.bindingLoading = false
})
},
/** 单击行选中候选账号 */
handleSelectedUserChange(row) {
if (row && row.canBind) {
this.selectedUserId = row.userId
}
},
/** 提交账号关联 */
submitBinding() {
if (!this.selectedUserId) {
this.$modal.msgWarning('请选择要关联的系统账号')
return
}
const selectedUser = this.bindableUsers.find(u => u.userId === this.selectedUserId)
const userName = selectedUser ? selectedUser.userName : this.selectedUserId
this.$modal.confirm('确定要将学员档案【' + this.currentStudent.sName + '】关联到账号【' + userName + '】吗?').then(() => {
this.bindingSubmitting = true
return bindStudentUser(this.currentStudent.sId, this.selectedUserId)
}).then(() => {
this.$modal.msgSuccess('关联成功')
this.bindingOpen = false
this.bindingSubmitting = false
this.getList()
}).catch(() => {
this.bindingSubmitting = false
})
},
/** 解除账号关联 */
handleUnbind(row) {
this.$modal.confirm('确定要解除学员【' + row.sName + '】与系统账号【' + (row.userName || row.userId) + '】的关联吗?').then(() => {
return unbindStudentUser(row.sId)
}).then(() => {
this.$modal.msgSuccess('解除关联成功')
this.getList()
})
},
sourceTypeLabel(sourceType) {
const labels = {
1: '管理员录入',
+153 -2
View File
@@ -90,8 +90,20 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="关联系统账号" align="center" min-width="150">
<template slot-scope="scope">
<div v-if="scope.row.accountBindingStatus === 1 || scope.row.userName">
<el-tag type="success" size="mini">已关联</el-tag>
<div style="font-size: 12px; color: #606266; margin-top: 2px;">
{{ scope.row.userName }}
<span v-if="scope.row.nickName">({{ scope.row.nickName }})</span>
</div>
</div>
<el-tag v-else type="info" size="mini">未关联</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="100">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="160">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:teacher:edit']"
@@ -100,6 +112,23 @@
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>修改</el-button>
<el-button
v-if="!scope.row.userName && !scope.row.userId"
v-hasPermi="['train:teacher:bindUser']"
size="mini"
type="text"
icon="el-icon-link"
@click="handleOpenBinding(scope.row)"
>关联账号</el-button>
<el-button
v-else
v-hasPermi="['train:teacher:unbindUser']"
size="mini"
type="text"
icon="el-icon-circle-close"
style="color: #F56C6C;"
@click="handleUnbind(scope.row)"
>解绑账号</el-button>
</template>
</el-table-column>
</el-table>
@@ -160,11 +189,71 @@
<el-button @click="cancel">取 消</el-button>
</div>
</el-dialog>
<!-- 关联系统账号对话框 -->
<el-dialog title="关联系统账号 (教师业务档案)" :visible.sync="bindingOpen" width="700px" append-to-body>
<el-alert
title="关联说明"
type="info"
description="将教师业务档案与系统用户账号关联后,教师可凭该系统账号登录并使用“教师工作台”。系统仅允许选择正常启用且已分配教师角色(train_teacher)的账号。"
show-icon
:closable="false"
style="margin-bottom: 15px;"
/>
<div style="margin-bottom: 15px; font-weight: bold; color: #303133;">
当前教师:{{ currentTeacher.tName }} (工号: {{ currentTeacher.tId }})
</div>
<el-form size="small" :inline="true" @submit.native.prevent>
<el-form-item label="查找用户">
<el-input
v-model="bindingKeyword"
placeholder="请输入用户名或姓名搜索"
clearable
style="width: 240px;"
@keyup.enter.native="loadBindableUsers"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="loadBindableUsers">搜索</el-button>
</el-form-item>
</el-form>
<el-table v-loading="bindingLoading" :data="bindableUsers" max-height="300" highlight-current-row @current-change="handleSelectedUserChange">
<el-table-column width="55" align="center">
<template slot-scope="scope">
<el-radio v-model="selectedUserId" :label="scope.row.userId" :disabled="!scope.row.canBind">{{ '' }}</el-radio>
</template>
</el-table-column>
<el-table-column label="系统账号" prop="userName" min-width="110" />
<el-table-column label="姓名/昵称" prop="nickName" min-width="110" />
<el-table-column label="状态" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'" size="mini">
{{ scope.row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分配角色" min-width="120" show-overflow-tooltip>
<template slot-scope="scope">
<span>{{ (scope.row.roleKeys || []).join(', ') }}</span>
</template>
</el-table-column>
<el-table-column label="可否关联" min-width="150">
<template slot-scope="scope">
<el-tag v-if="scope.row.canBind" type="success" size="mini">可选</el-tag>
<span v-else style="color: #F56C6C; font-size: 12px;">{{ scope.row.reason }}</span>
</template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :disabled="!selectedUserId" :loading="bindingSubmitting" @click="submitBinding">确 定 关 联</el-button>
<el-button @click="bindingOpen = false">取 消</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listTeacher, getTeacher, addTeacher, updateTeacher } from '@/api/train/teacher'
import { listTeacher, getTeacher, addTeacher, updateTeacher, getTeacherBindableUsers, bindTeacherUser, unbindTeacherUser } from '@/api/train/teacher'
export default {
name: 'Teacher',
@@ -186,6 +275,14 @@ export default {
title: '',
// 是否显示弹出层
open: false,
// 账号关联弹窗
bindingOpen: false,
bindingLoading: false,
bindingSubmitting: false,
bindingKeyword: '',
bindableUsers: [],
currentTeacher: {},
selectedUserId: null,
// 查询参数
queryParams: {
pageNum: 1,
@@ -297,6 +394,60 @@ export default {
}
}
})
},
/** 打开关联系统账号对话框 */
handleOpenBinding(row) {
this.currentTeacher = row
this.selectedUserId = null
this.bindingKeyword = ''
this.bindableUsers = []
this.bindingOpen = true
this.loadBindableUsers()
},
/** 加载候选用户列表 */
loadBindableUsers() {
this.bindingLoading = true
getTeacherBindableUsers(this.bindingKeyword).then(response => {
this.bindableUsers = response.data || []
this.bindingLoading = false
}).catch(() => {
this.bindingLoading = false
})
},
/** 单击行选中候选账号 */
handleSelectedUserChange(row) {
if (row && row.canBind) {
this.selectedUserId = row.userId
}
},
/** 提交账号关联 */
submitBinding() {
if (!this.selectedUserId) {
this.$modal.msgWarning('请选择要关联的系统账号')
return
}
const selectedUser = this.bindableUsers.find(u => u.userId === this.selectedUserId)
const userName = selectedUser ? selectedUser.userName : this.selectedUserId
this.$modal.confirm('确定要将教师档案【' + this.currentTeacher.tName + '】关联到账号【' + userName + '】吗?').then(() => {
this.bindingSubmitting = true
return bindTeacherUser(this.currentTeacher.tId, this.selectedUserId)
}).then(() => {
this.$modal.msgSuccess('关联成功')
this.bindingOpen = false
this.bindingSubmitting = false
this.getList()
}).catch(() => {
this.bindingSubmitting = false
})
},
/** 解除账号关联 */
handleUnbind(row) {
this.$modal.confirm('确定要解除教师【' + row.tName + '】与系统账号【' + (row.userName || row.userId) + '】的关联吗?').then(() => {
return unbindTeacherUser(row.tId)
}).then(() => {
this.$modal.msgSuccess('解除关联成功')
this.getList()
})
}
}
}