合并权限:集成R2 RBAC权限发布版本

This commit is contained in:
wangzhibiao committed 2026-09-16 11:09:31 +08:00
commit 8fa4f57da0
108 files changed
+8295 -70

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();
}
}
@@ -37,7 +37,7 @@ public class TrainElectronicBoardController extends BaseController
/**
* 查询电子班牌设备列表
*/
@PreAuthorize("@ss.hasPermi('system:board:list')")
@PreAuthorize("@ss.hasPermi('train:board:list')")
@GetMapping("/list")
public TableDataInfo list(TrainElectronicBoard trainElectronicBoard)
{
@@ -49,7 +49,7 @@ public class TrainElectronicBoardController extends BaseController
/**
* 导出电子班牌设备列表
*/
@PreAuthorize("@ss.hasPermi('system:board:export')")
@PreAuthorize("@ss.hasPermi('train:board:export')")
@Log(title = "电子班牌设备", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, TrainElectronicBoard trainElectronicBoard)
@@ -62,7 +62,7 @@ public class TrainElectronicBoardController extends BaseController
/**
* 获取电子班牌设备详细信息
*/
@PreAuthorize("@ss.hasPermi('system:board:query')")
@PreAuthorize("@ss.hasPermi('train:board:query')")
@GetMapping(value = "/{boardId}")
public AjaxResult getInfo(@PathVariable("boardId") String boardId)
{
@@ -72,7 +72,7 @@ public class TrainElectronicBoardController extends BaseController
/**
* 新增电子班牌设备
*/
@PreAuthorize("@ss.hasPermi('system:board:add')")
@PreAuthorize("@ss.hasPermi('train:board:add')")
@Log(title = "电子班牌设备", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@RequestBody TrainElectronicBoard trainElectronicBoard)
@@ -84,7 +84,7 @@ public class TrainElectronicBoardController extends BaseController
/**
* 修改电子班牌设备
*/
@PreAuthorize("@ss.hasPermi('system:board:edit')")
@PreAuthorize("@ss.hasPermi('train:board:edit')")
@Log(title = "电子班牌设备", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody TrainElectronicBoard trainElectronicBoard)
@@ -96,7 +96,7 @@ public class TrainElectronicBoardController extends BaseController
/**
* 删除电子班牌设备
*/
@PreAuthorize("@ss.hasPermi('system:board:remove')")
@PreAuthorize("@ss.hasPermi('train:board:remove')")
@Log(title = "电子班牌设备", businessType = BusinessType.DELETE)
@DeleteMapping("/{boardIds}")
public AjaxResult remove(@PathVariable String[] boardIds)
@@ -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')")
@@ -0,0 +1,173 @@
package com.ruoyi.web.controller.train;
import java.util.Date;
import java.util.List;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
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.exception.ServiceException;
import com.ruoyi.train.domain.TrainClassReport;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.TrainStudentPortrait;
import com.ruoyi.train.domain.TrainStudyDuration;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* 学员专属个人中心控制器 (/train/me/student/**)。
*
* <p>服务端严格基于当前会话与登录人关联的学员身份推导数据范围,杜绝任何外部主键传入与 IDOR 越权。</p>
*
* @author ruoyi
*/
@RestController
@RequestMapping("/train/me/student")
public class TrainStudentMeController extends BaseController
{
private final ITrainingIdentityService identityService;
private final ITrainStudentService studentService;
private final TrainEnrollmentMapper enrollmentMapper;
private final TrainLessonMapper lessonMapper;
private final TrainClassReportMapper reportMapper;
private final TrainStudentCheckinMapper checkinMapper;
private final TrainStudyDurationMapper durationMapper;
private final TrainStudentPortraitMapper portraitMapper;
public TrainStudentMeController(ITrainingIdentityService identityService,
ITrainStudentService studentService,
TrainEnrollmentMapper enrollmentMapper,
TrainLessonMapper lessonMapper,
TrainClassReportMapper reportMapper,
TrainStudentCheckinMapper checkinMapper,
TrainStudyDurationMapper durationMapper,
TrainStudentPortraitMapper portraitMapper)
{
this.identityService = identityService;
this.studentService = studentService;
this.enrollmentMapper = enrollmentMapper;
this.lessonMapper = lessonMapper;
this.reportMapper = reportMapper;
this.checkinMapper = checkinMapper;
this.durationMapper = durationMapper;
this.portraitMapper = portraitMapper;
}
@Override
protected void startPage()
{
if (com.ruoyi.common.utils.ServletUtils.getRequestAttributes() != null)
{
super.startPage();
}
}
/**
* 获取学员个人档案 (敏感信息已脱敏)
*/
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
@GetMapping
public AjaxResult getStudentProfile()
{
String sId = identityService.assertStudentIdentity();
StudentDetailVO detail = studentService.selectStudentDetail(sId);
if (detail == null)
{
throw new ServiceException("未找到对应的学员档案数据", HttpStatus.NOT_FOUND);
}
return success(detail);
}
/**
* 获取我的参训班级与状态列表
*/
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
@GetMapping("/enrollments")
public TableDataInfo getMyEnrollments()
{
String sId = identityService.assertStudentIdentity();
startPage();
List<EnrollmentItemVO> list = enrollmentMapper.selectEnrollmentsByStudentId(sId);
return getDataTable(list);
}
/**
* 获取我的参训课表日程
*/
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
@GetMapping("/timetable")
public AjaxResult getMyTimetable(
@RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate,
@RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate)
{
String sId = identityService.assertStudentIdentity();
List<TimetableLessonItemVO> list = lessonMapper.selectScheduleLessonsByStudentId(sId, startDate, endDate);
return success(list);
}
/**
* 获取我的最新开班报到凭据与状态
*/
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
@GetMapping("/report")
public AjaxResult getMyReport()
{
String sId = identityService.assertStudentIdentity();
TrainClassReport report = reportMapper.selectLatestReportByStudentId(sId);
return success(report);
}
/**
* 获取我的考勤打卡流水
*/
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
@GetMapping("/attendance")
public TableDataInfo getMyAttendance(
@RequestParam(value = "planId", required = false) String planId,
@RequestParam(value = "month", required = false) String month)
{
String sId = identityService.assertStudentIdentity();
startPage();
List<TrainStudentCheckin> list = checkinMapper.selectCheckinsByStudentId(sId, planId, month);
return getDataTable(list);
}
/**
* 获取我的累计学习时长
*/
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
@GetMapping("/duration")
public AjaxResult getMyDuration()
{
String sId = identityService.assertStudentIdentity();
List<TrainStudyDuration> list = durationMapper.selectDurationsByStudentId(sId);
return success(list);
}
/**
* 获取我的多维成长画像
*/
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
@GetMapping("/portrait")
public AjaxResult getMyPortrait()
{
String sId = identityService.assertStudentIdentity();
TrainStudentPortrait portrait = portraitMapper.selectPortraitByStudentId(sId);
return success(portrait);
}
}
@@ -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));
}
/**
@@ -0,0 +1,177 @@
package com.ruoyi.web.controller.train;
import java.util.Date;
import java.util.List;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
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.exception.ServiceException;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* 教师专属自服务控制器 (/train/me/teacher/**)。
*
* <p>服务端严格基于当前会话与登录人关联的教师身份推导数据范围,杜绝任何外部主键传入与 IDOR 越权。</p>
*
* @author ruoyi
*/
@RestController
@RequestMapping("/train/me/teacher")
public class TrainTeacherMeController extends BaseController
{
private final ITrainingIdentityService identityService;
private final TrainTeacherMapper teacherMapper;
private final TrainLessonMapper lessonMapper;
private final TrainEnrollmentMapper enrollmentMapper;
private final TrainStudentCheckinMapper checkinMapper;
public TrainTeacherMeController(ITrainingIdentityService identityService,
TrainTeacherMapper teacherMapper,
TrainLessonMapper lessonMapper,
TrainEnrollmentMapper enrollmentMapper,
TrainStudentCheckinMapper checkinMapper)
{
this.identityService = identityService;
this.teacherMapper = teacherMapper;
this.lessonMapper = lessonMapper;
this.enrollmentMapper = enrollmentMapper;
this.checkinMapper = checkinMapper;
}
@Override
protected void startPage()
{
if (com.ruoyi.common.utils.ServletUtils.getRequestAttributes() != null)
{
super.startPage();
}
}
/**
* 获取教师本人档案信息
*/
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
@GetMapping
public AjaxResult getTeacherProfile()
{
String tId = identityService.assertTeacherIdentity();
TrainTeacher teacher = teacherMapper.selectTrainTeacherByTId(tId);
if (teacher == null)
{
throw new ServiceException("未找到对应的教师档案数据", HttpStatus.NOT_FOUND);
}
teacher.setUserId(null);
return success(teacher);
}
/**
* 获取我参与任教的培训班列表
*/
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
@GetMapping("/plans")
public TableDataInfo getMyPlans(@RequestParam(value = "planStatus", required = false) Integer planStatus)
{
String tId = identityService.assertTeacherIdentity();
startPage();
List<TrainClassPlan> list = lessonMapper.selectPlansByTeacherId(tId, planStatus);
return getDataTable(list);
}
/**
* 获取我的授课课表日程
*/
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
@GetMapping(value = {"/timetable", "/lessons"})
public AjaxResult getMyTimetable(
@RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate,
@RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate)
{
String tId = identityService.assertTeacherIdentity();
List<TimetableLessonItemVO> list = lessonMapper.selectScheduleLessonsByTeacherId(tId, startDate, endDate);
return success(list);
}
/**
* 获取我所带班级学员花名册 (严格校验任教归属与敏感信息脱敏)
*/
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
@GetMapping("/students")
public TableDataInfo getMyClassStudents(@RequestParam("planId") String planId)
{
String tId = identityService.assertTeacherIdentity();
// IDOR 安全校验:断言教师在当前班级拥有授课关系
int lessonCount = lessonMapper.countTeacherInPlan(tId, planId);
if (lessonCount <= 0)
{
throw new ServiceException("您并非该培训班的任课教师,无权查看学员名册", HttpStatus.FORBIDDEN);
}
EnrollmentQuery query = new EnrollmentQuery();
query.setPlanId(planId);
startPage();
List<EnrollmentItemVO> list = enrollmentMapper.selectEnrollmentItemList(query);
for (EnrollmentItemVO item : list)
{
if (item.getPhone() != null)
{
item.setPhone(maskPhone(item.getPhone()));
}
}
return getDataTable(list);
}
/**
* 获取我授课课次的学员出勤情况 (严格校验课次归属)
*/
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
@GetMapping("/lessons/{lessonId}/attendance")
public AjaxResult getLessonAttendance(@PathVariable("lessonId") Long lessonId)
{
String tId = identityService.assertTeacherIdentity();
TrainLesson lesson = lessonMapper.selectTrainLessonById(lessonId);
if (lesson == null)
{
throw new ServiceException("指定课次不存在", HttpStatus.NOT_FOUND);
}
// IDOR 安全校验:断言该课次为本人授课
if (lesson.getTId() == null || !lesson.getTId().equals(tId))
{
throw new ServiceException("您并非该课次的授课教师,无权查看出勤记录", HttpStatus.FORBIDDEN);
}
List<TrainStudentCheckin> checkins = checkinMapper.selectCheckinsByLessonId(lessonId);
return success(checkins);
}
private String maskPhone(String phone)
{
if (phone == null || phone.length() < 7)
{
return phone;
}
if (phone.length() == 11)
{
return phone.substring(0, 3) + "****" + phone.substring(7);
}
return phone.substring(0, 2) + "****" + phone.substring(phone.length() - 2);
}
}
@@ -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 + "),请先在用户管理中分配对应角色");
}
}
}
@@ -0,0 +1,324 @@
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.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.access.AccessDeniedException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* 真实 HTTP / AjaxResult 响应体语义、IDOR 越权与敏感数据脱敏集成测试。
*
* @author ruoyi
*/
public class RbacHttpAndAuthorizationSemanticsTest
{
private TrainTeacherMeController teacherController;
private TrainStudentMeController studentController;
private GlobalExceptionHandler exceptionHandler;
private ObjectMapper objectMapper;
private String currentTeacherId = "T_MOCK_01";
private String currentStudentId = "S_MOCK_01";
@Before
public void setUp()
{
exceptionHandler = new GlobalExceptionHandler();
objectMapper = new ObjectMapper();
ITrainingIdentityService identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("assertTeacherIdentity".equals(name))
{
if (currentTeacherId == null)
{
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
}
return currentTeacherId;
}
if ("assertStudentIdentity".equals(name))
{
if (currentStudentId == null)
{
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
}
return currentStudentId;
}
if ("getCurrentUserId".equals(name))
{
return 1001L;
}
return null;
}
);
TrainTeacherMapper teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainTeacherMapper.class},
(proxy, method, args) -> {
if ("selectTrainTeacherByTId".equals(method.getName()))
{
TrainTeacher teacher = new TrainTeacher();
teacher.setTId((String) args[0]);
teacher.settName("李老师");
return teacher;
}
return null;
}
);
TrainLessonMapper lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainLessonMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("countTeacherInPlan".equals(name))
{
String tId = (String) args[0];
String planId = (String) args[1];
return ("T_MOCK_01".equals(tId) && "PLAN_LEGAL".equals(planId)) ? 1 : 0;
}
if ("selectTrainLessonById".equals(name))
{
Long lessonId = (Long) args[0];
TrainLesson lesson = new TrainLesson();
lesson.setLessonId(lessonId);
if (Long.valueOf(101L).equals(lessonId))
{
lesson.setTId("T_MOCK_01"); // 本人课次
}
else
{
lesson.setTId("T_OTHER"); // 他人课次
}
return lesson;
}
return null;
}
);
TrainEnrollmentMapper enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainEnrollmentMapper.class},
(proxy, method, args) -> {
if ("selectEnrollmentItemList".equals(method.getName()))
{
List<EnrollmentItemVO> list = new ArrayList<>();
EnrollmentItemVO item = new EnrollmentItemVO();
item.setStudentId("S001");
item.setStudentName("张三");
item.setPhone("13812345678");
list.add(item);
return list;
}
return Collections.emptyList();
}
);
TrainStudentCheckinMapper checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentCheckinMapper.class},
(proxy, method, args) -> Collections.emptyList()
);
teacherController = new TrainTeacherMeController(
identityService, teacherMapper, lessonMapper, enrollmentMapper, checkinMapper);
ITrainStudentService studentService = (ITrainStudentService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainStudentService.class},
(proxy, method, args) -> {
if ("selectStudentDetail".equals(method.getName()))
{
StudentDetailVO detail = new StudentDetailVO();
detail.setsId("S_MOCK_01");
detail.setsName("李四");
detail.setIdCardMasked("110101********1234");
detail.setIdCardLast4("1234");
return detail;
}
return null;
}
);
TrainClassReportMapper reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
getClass().getClassLoader(), new Class<?>[]{TrainClassReportMapper.class}, (p, m, a) -> null);
TrainStudyDurationMapper durationMapper = (TrainStudyDurationMapper) Proxy.newProxyInstance(
getClass().getClassLoader(), new Class<?>[]{TrainStudyDurationMapper.class}, (p, m, a) -> Collections.emptyList());
TrainStudentPortraitMapper portraitMapper = (TrainStudentPortraitMapper) Proxy.newProxyInstance(
getClass().getClassLoader(), new Class<?>[]{TrainStudentPortraitMapper.class}, (p, m, a) -> null);
studentController = new TrainStudentMeController(
identityService, studentService, enrollmentMapper, lessonMapper, reportMapper, checkinMapper, durationMapper, portraitMapper);
}
@Test
public void testTeacherMe_NoIdentity_GlobalExceptionHandlingSemantics()
{
currentTeacherId = null; // 模拟无教师身份
try
{
teacherController.getTeacherProfile();
fail("无教师身份必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
// 经过若依全局异常处理器,生成最终前端收到的 AjaxResult
AjaxResult result = exceptionHandler.handleServiceException(e, null);
assertEquals(403, result.get("code"));
assertEquals("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", result.get("msg"));
}
}
@Test
public void testTeacherStudents_CrossPlanIdor_GlobalExceptionHandlingSemantics()
{
currentTeacherId = "T_MOCK_01";
try
{
teacherController.getMyClassStudents("PLAN_ILLEGAL_OTHER"); // 跨班越权
fail("跨班越权必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
AjaxResult result = exceptionHandler.handleServiceException(e, null);
assertEquals(403, result.get("code"));
assertEquals("您并非该培训班的任课教师,无权查看学员名册", result.get("msg"));
}
}
@Test
public void testTeacherAttendance_CrossLessonIdor_GlobalExceptionHandlingSemantics()
{
currentTeacherId = "T_MOCK_01";
try
{
teacherController.getLessonAttendance(888L); // 888属于T_OTHER
fail("跨Lesson越权必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
AjaxResult result = exceptionHandler.handleServiceException(e, null);
assertEquals(403, result.get("code"));
assertEquals("您并非该课次的授课教师,无权查看出勤记录", result.get("msg"));
}
}
@Test
public void testStudentMe_NoIdentity_GlobalExceptionHandlingSemantics()
{
currentStudentId = null; // 模拟无学员身份
try
{
studentController.getStudentProfile();
fail("无学员身份必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
AjaxResult result = exceptionHandler.handleServiceException(e, null);
assertEquals(403, result.get("code"));
assertEquals("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", result.get("msg"));
}
}
@Test
public void testSpringSecurity_AccessDeniedException_HandlingSemantics()
{
// 模拟 Spring Security 权限不足异常
AccessDeniedException ex = new AccessDeniedException("Access is denied");
javax.servlet.http.HttpServletRequest request = (javax.servlet.http.HttpServletRequest) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{javax.servlet.http.HttpServletRequest.class},
(proxy, method, args) -> {
if ("getRequestURI".equals(method.getName())) return "/train/me/teacher";
return null;
}
);
// 调用全局异常处理
AjaxResult result = exceptionHandler.handleAccessDeniedException(ex, request);
assertEquals(403, result.get("code"));
assertEquals("没有权限,请联系管理员授权", result.get("msg"));
}
@Test
public void testAdminUserWithTeacherIdentity_AccessAllowed()
{
currentTeacherId = "T_MOCK_01";
// Admin角色用户同时拥有Teacher档案时,正常访问
AjaxResult result = teacherController.getTeacherProfile();
assertEquals(200, result.get("code"));
TrainTeacher teacher = (TrainTeacher) result.get("data");
assertEquals("T_MOCK_01", teacher.getTId());
assertEquals("李老师", teacher.gettName());
}
@Test
public void testSensitiveData_StudentProfileJsonNeverLeaksCiphertextOrHash() throws Exception
{
currentStudentId = "S_MOCK_01";
AjaxResult result = studentController.getStudentProfile();
assertEquals(200, result.get("code"));
assertNotNull(result.get("data"));
String json = objectMapper.writeValueAsString(result);
// 严格断言:JSON响应体严禁包含任何密文或查重哈希字段
assertFalse("JSON不得泄露密文字段 idCardCiphertext", json.contains("idCardCiphertext"));
assertFalse("JSON不得泄露哈希字段 idCardHash", json.contains("idCardHash"));
assertFalse("JSON不得泄露数据库内部密钥字段 secretKey", json.contains("secretKey"));
// 必须暴露脱敏展示字段
assertTrue("JSON必须包含脱敏掩码字段 idCardMasked", json.contains("idCardMasked"));
assertTrue("JSON必须包含末4位字段 idCardLast4", json.contains("idCardLast4"));
}
@Test
public void testSensitiveData_TeacherStudentsMasksPhone() throws Exception
{
currentTeacherId = "T_MOCK_01";
TableDataInfo dataInfo = teacherController.getMyClassStudents("PLAN_LEGAL");
assertEquals(200, dataInfo.getCode());
String json = objectMapper.writeValueAsString(dataInfo);
assertFalse("学员列表严禁泄露完整11位手机号", json.contains("13812345678"));
assertTrue("学员列表必须展示掩码脱敏手机号", json.contains("138****5678"));
}
}
@@ -0,0 +1,225 @@
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.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Collections;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.domain.vo.StudentItemVO;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* RBAC 与 IDOR (Insecure Direct Object Reference) 安全渗透专项测试。
*
* @author ruoyi
*/
public class RbacIdorSecurityTest
{
private TrainTeacherMeController teacherController;
private TrainStudentMeController studentController;
private ITrainingIdentityService identityService;
private TrainLessonMapper lessonMapper;
private String loggedInTeacherId = "T_AUTHENTICATED";
private String loggedInStudentId = "S_AUTHENTICATED";
@Before
public void setUp()
{
identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("assertTeacherIdentity".equals(name))
{
return loggedInTeacherId;
}
if ("assertStudentIdentity".equals(name))
{
return loggedInStudentId;
}
return null;
}
);
lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainLessonMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("countTeacherInPlan".equals(name))
{
String tId = (String) args[0];
String planId = (String) args[1];
// 仅允许当前教师访问班级 "PLAN_AUTHORIZED"
return ("T_AUTHENTICATED".equals(tId) && "PLAN_AUTHORIZED".equals(planId)) ? 1 : 0;
}
if ("selectTrainLessonById".equals(name))
{
Long lessonId = (Long) args[0];
TrainLesson lesson = new TrainLesson();
lesson.setLessonId(lessonId);
if (Long.valueOf(888L).equals(lessonId))
{
lesson.setTId("T_AUTHENTICATED");
}
else
{
lesson.setTId("T_VICTIM");
}
return lesson;
}
return null;
}
);
TrainTeacherMapper teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainTeacherMapper.class},
(proxy, method, args) -> null
);
TrainEnrollmentMapper enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainEnrollmentMapper.class},
(proxy, method, args) -> Collections.emptyList()
);
TrainStudentCheckinMapper checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentCheckinMapper.class},
(proxy, method, args) -> Collections.emptyList()
);
teacherController = new TrainTeacherMeController(identityService, teacherMapper, lessonMapper, enrollmentMapper, checkinMapper);
ITrainStudentService studentService = (ITrainStudentService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainStudentService.class},
(proxy, method, args) -> new StudentDetailVO()
);
TrainClassReportMapper reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainClassReportMapper.class},
(proxy, method, args) -> null
);
TrainStudyDurationMapper durationMapper = (TrainStudyDurationMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudyDurationMapper.class},
(proxy, method, args) -> Collections.emptyList()
);
TrainStudentPortraitMapper portraitMapper = (TrainStudentPortraitMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentPortraitMapper.class},
(proxy, method, args) -> null
);
studentController = new TrainStudentMeController(
identityService, studentService, enrollmentMapper,
lessonMapper, reportMapper, checkinMapper,
durationMapper, portraitMapper
);
}
@Test
public void teacherCannotProbeForeignClassStudents()
{
// 攻击向量:试图遍历不存在任教关系的 planId 获取学员花名册
String[] probePlanIds = {"PLAN_FOREIGN_1", "PLAN_FOREIGN_2", "PLAN_SYS_ADMIN", "../../../etc/passwd"};
for (String foreignPlanId : probePlanIds)
{
try
{
teacherController.getMyClassStudents(foreignPlanId);
fail("IDOR探测必须被拦截: " + foreignPlanId);
}
catch (ServiceException e)
{
assertEquals("您并非该培训班的任课教师,无权查看学员名册", e.getMessage());
}
}
}
@Test
public void teacherCannotProbeForeignLessonAttendance()
{
// 攻击向量:试图直接修改 lessonId 读取受害者课次的考勤流水
Long[] probeLessonIds = {100L, 101L, 9999L};
for (Long lessonId : probeLessonIds)
{
try
{
teacherController.getLessonAttendance(lessonId);
fail("IDOR课次探测必须被拦截: lessonId=" + lessonId);
}
catch (ServiceException e)
{
assertEquals("您并非该课次的授课教师,无权查看出勤记录", e.getMessage());
}
}
}
@Test
public void teacherCanOnlyAccessAuthorizedResources()
{
// 访问授权班级与课次
assertNotNull(teacherController.getMyClassStudents("PLAN_AUTHORIZED"));
assertNotNull(teacherController.getLessonAttendance(888L));
}
@Test
public void studentViewsMustNeverExposeSensitiveCiphertextOrHash()
{
// 验证学员向外部返回的视图类结构,严禁含有密文字段或查重哈希
for (Field field : StudentItemVO.class.getDeclaredFields())
{
String name = field.getName().toLowerCase();
assertFalse("StudentItemVO 严禁包含明文或密文身份证字段: " + name, name.contains("ciphertext") || name.contains("hash"));
}
for (Field field : StudentDetailVO.class.getDeclaredFields())
{
String name = field.getName().toLowerCase();
assertFalse("StudentDetailVO 严禁包含明文或密文身份证字段: " + name, name.contains("ciphertext") || name.contains("hash"));
}
}
@Test
public void electronicBoardControllerHasZeroLegacyPermissions()
{
// 验证系统内班牌控制器完全收敛为 train:board:*
for (Method method : TrainElectronicBoardController.class.getDeclaredMethods())
{
if (method.isAnnotationPresent(PreAuthorize.class))
{
String perm = method.getAnnotation(PreAuthorize.class).value();
assertTrue("必须使用 train:board:* 权限: " + perm, perm.startsWith("@ss.hasPermi('train:board:"));
assertFalse("严禁包含 system:board:* 权限: " + perm, perm.contains("system:board:"));
}
}
}
}
@@ -0,0 +1,152 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
/**
* R.2B RBAC 数据库迁移与命名空间对齐静态契约测试 (004 / 005 SQL & Controller Annotation)。
*
* @author ruoyi
*/
public class RbacPermissionMigrationContractTest
{
@Test
public void migration004HasPartialUniqueIndexesAndNoDrop() throws Exception
{
String sql = new String(Files.readAllBytes(findMigration("004_rbac_identity_binding.sql")), StandardCharsets.UTF_8).toLowerCase();
assertContains(sql,
"add column if not exists user_id",
"create unique index if not exists uq_train_teacher_user_id",
"create unique index if not exists uq_train_student_user_id",
"where user_id is not null and del_flag = '0'");
// 严禁包含实际的 DROP / TRUNCATE DDL 语句
assertFalse("must not execute drop index", sql.contains("drop index "));
assertFalse("must not execute drop table", sql.contains("drop table "));
assertFalse("must not execute truncate table", sql.contains("truncate table "));
assertFalse("must not set user_id not null", sql.contains("alter column user_id set not null"));
}
@Test
public void migration005ProvisionsThreeRolesAndCollisionGuard() throws Exception
{
String sql = new String(Files.readAllBytes(findMigration("005_training_roles_permissions.sql")), StandardCharsets.UTF_8).toLowerCase();
assertContains(sql,
"collision detected",
"train_admin",
"train_teacher",
"train_student",
"train:board:list",
"train:board:query",
"train:board:add",
"train:board:edit",
"train:board:remove",
"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
assertFalse("must not delete sys_user", sql.contains("delete from public.sys_user"));
assertFalse("must not execute drop table", sql.contains("drop table "));
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()
{
for (Method method : TrainElectronicBoardController.class.getDeclaredMethods())
{
if (method.isAnnotationPresent(PreAuthorize.class))
{
String perm = method.getAnnotation(PreAuthorize.class).value();
assertTrue("班牌Controller权限必须属于train:board:*命名空间: " + perm, perm.contains("train:board:"));
assertFalse("班牌Controller严禁遗留system:board:*命名空间: " + perm, perm.contains("system:board:"));
}
}
}
@Test
public void migration005ConfiguresTeacherAndStudentRouteMenusAndRoleBindings() throws Exception
{
String sql = new String(Files.readAllBytes(findMigration("005_training_roles_permissions.sql")), StandardCharsets.UTF_8).toLowerCase();
// 验证 2239 教师工作台路由配置
assertContains(sql,
"2239",
"教师工作台",
"'teacher'",
"'train/me/teacher/index'",
"'c'",
"'train:me:teacher'");
// 验证 2240 学员中心路由配置
assertContains(sql,
"2240",
"学员中心",
"'student'",
"'train/me/student/index'",
"'c'",
"'train:me:student'");
// 验证教师角色 (role_id=4) 与学员角色 (role_id=5) 的专属菜单绑定
assertContains(sql,
"select 4, 2239",
"select 5, 2240");
}
private Path findMigration(String filename)
{
for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent())
{
Path candidate = path.resolve("sql/system/kingbase/" + filename);
if (Files.isRegularFile(candidate)) return candidate;
}
throw new IllegalStateException(filename + " migration not found from working directory");
}
private void assertContains(String sql, String... tokens)
{
for (String token : tokens)
{
assertTrue("missing migration token: " + token, sql.contains(token));
}
}
}
@@ -0,0 +1,346 @@
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.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.Proxy;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainClassReport;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.TrainStudentPortrait;
import com.ruoyi.train.domain.TrainStudyDuration;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* 学员专属个人中心接口契约与自服务推导单元测试。
*
* @author ruoyi
*/
public class StudentSelfServiceApiTest
{
private TrainStudentMeController controller;
private ITrainingIdentityService identityService;
private ITrainStudentService studentService;
private TrainEnrollmentMapper enrollmentMapper;
private TrainLessonMapper lessonMapper;
private TrainClassReportMapper reportMapper;
private TrainStudentCheckinMapper checkinMapper;
private TrainStudyDurationMapper durationMapper;
private TrainStudentPortraitMapper portraitMapper;
private String currentStudentId = "S_CURRENT";
@Before
public void setUp()
{
identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
if ("assertStudentIdentity".equals(method.getName()))
{
if (currentStudentId == null)
{
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", 403);
}
return currentStudentId;
}
return null;
}
);
studentService = (ITrainStudentService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainStudentService.class},
(proxy, method, args) -> {
if ("selectStudentDetail".equals(method.getName()))
{
StudentDetailVO detail = new StudentDetailVO();
detail.setsId((String) args[0]);
detail.setsName("李四");
detail.setIdCardLast4("5678");
detail.setIdCardMasked("****************5678");
detail.setOrgName("供电段");
return detail;
}
return null;
}
);
enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainEnrollmentMapper.class},
(proxy, method, args) -> {
if ("selectEnrollmentsByStudentId".equals(method.getName()))
{
List<EnrollmentItemVO> list = new ArrayList<>();
EnrollmentItemVO vo = new EnrollmentItemVO();
vo.setStudentId((String) args[0]);
vo.setPlanId("PLAN_2026_01");
vo.setEnrollmentStatus(1);
list.add(vo);
return list;
}
return Collections.emptyList();
}
);
lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainLessonMapper.class},
(proxy, method, args) -> {
if ("selectScheduleLessonsByStudentId".equals(method.getName()))
{
List<TimetableLessonItemVO> list = new ArrayList<>();
TimetableLessonItemVO item = new TimetableLessonItemVO();
item.setLessonId(201L);
item.setCourseName("铁路信号基础");
list.add(item);
return list;
}
return Collections.emptyList();
}
);
reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainClassReportMapper.class},
(proxy, method, args) -> {
if ("selectLatestReportByStudentId".equals(method.getName()))
{
TrainClassReport report = new TrainClassReport();
report.setStudentId((String) args[0]);
report.setVerifyResult(1);
return report;
}
return null;
}
);
checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentCheckinMapper.class},
(proxy, method, args) -> {
if ("selectCheckinsByStudentId".equals(method.getName()))
{
List<TrainStudentCheckin> list = new ArrayList<>();
TrainStudentCheckin checkin = new TrainStudentCheckin();
checkin.setStudentId((String) args[0]);
checkin.setAttendanceStatus(1);
list.add(checkin);
return list;
}
return Collections.emptyList();
}
);
durationMapper = (TrainStudyDurationMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudyDurationMapper.class},
(proxy, method, args) -> {
if ("selectDurationsByStudentId".equals(method.getName()))
{
List<TrainStudyDuration> list = new ArrayList<>();
TrainStudyDuration dur = new TrainStudyDuration();
dur.setStudentId((String) args[0]);
dur.setStudySeconds(3600L);
list.add(dur);
return list;
}
return Collections.emptyList();
}
);
portraitMapper = (TrainStudentPortraitMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentPortraitMapper.class},
(proxy, method, args) -> {
if ("selectPortraitByStudentId".equals(method.getName()))
{
TrainStudentPortrait portrait = new TrainStudentPortrait();
portrait.setStudentId((String) args[0]);
portrait.setAttendanceRate(new BigDecimal("98.5"));
portrait.setTheoryScore(new BigDecimal("92.0"));
return portrait;
}
return null;
}
);
controller = new TrainStudentMeController(
identityService, studentService, enrollmentMapper,
lessonMapper, reportMapper, checkinMapper,
durationMapper, portraitMapper
);
}
@Test
public void shouldExposeStandardEndpointsAndPermissions() throws Exception
{
assertEquals("/train/me/student", TrainStudentMeController.class.getAnnotation(RequestMapping.class).value()[0]);
Method profile = TrainStudentMeController.class.getMethod("getStudentProfile");
Method enrollments = TrainStudentMeController.class.getMethod("getMyEnrollments");
Method timetable = TrainStudentMeController.class.getMethod("getMyTimetable", Date.class, Date.class);
Method report = TrainStudentMeController.class.getMethod("getMyReport");
Method attendance = TrainStudentMeController.class.getMethod("getMyAttendance", String.class, String.class);
Method duration = TrainStudentMeController.class.getMethod("getMyDuration");
Method portrait = TrainStudentMeController.class.getMethod("getMyPortrait");
assertNotNull(profile.getAnnotation(GetMapping.class));
assertNotNull(enrollments.getAnnotation(GetMapping.class));
assertNotNull(timetable.getAnnotation(GetMapping.class));
assertNotNull(report.getAnnotation(GetMapping.class));
assertNotNull(attendance.getAnnotation(GetMapping.class));
assertNotNull(duration.getAnnotation(GetMapping.class));
assertNotNull(portrait.getAnnotation(GetMapping.class));
String expectedPerm = "@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')";
assertEquals(expectedPerm, profile.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, enrollments.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, timetable.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, report.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, attendance.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, duration.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, portrait.getAnnotation(PreAuthorize.class).value());
}
@Test
public void endpointsMustNeverAcceptStudentIdParameter()
{
// 核心安全防线:学员自服务控制器所有方法的参数列表严禁包含名为 studentId 或 sId 的入参
for (Method method : TrainStudentMeController.class.getDeclaredMethods())
{
if (method.isAnnotationPresent(GetMapping.class))
{
for (Parameter parameter : method.getParameters())
{
String paramName = parameter.getName().toLowerCase();
assertFalse("端点严禁接收studentId参数,杜绝越权: " + method.getName(),
paramName.contains("studentid") || "sid".equals(paramName));
}
}
}
}
@Test
public void shouldGetStudentProfileWithMaskedIdCard()
{
AjaxResult result = controller.getStudentProfile();
assertEquals(200, result.get("code"));
StudentDetailVO detail = (StudentDetailVO) result.get("data");
assertEquals("S_CURRENT", detail.getsId());
assertEquals("5678", detail.getIdCardLast4());
assertEquals("****************5678", detail.getIdCardMasked());
}
@Test
public void shouldGetEnrollmentsAndTimetableSuccessfully()
{
TableDataInfo enrollments = controller.getMyEnrollments();
assertEquals(200, enrollments.getCode());
assertEquals(1, enrollments.getRows().size());
AjaxResult timetable = controller.getMyTimetable(null, null);
assertEquals(200, timetable.get("code"));
@SuppressWarnings("unchecked")
List<TimetableLessonItemVO> lessons = (List<TimetableLessonItemVO>) timetable.get("data");
assertEquals(1, lessons.size());
assertEquals("铁路信号基础", lessons.get(0).getCourseName());
}
@Test
public void shouldGetReportAttendanceDurationAndPortrait()
{
AjaxResult reportRes = controller.getMyReport();
assertEquals(200, reportRes.get("code"));
TrainClassReport report = (TrainClassReport) reportRes.get("data");
assertEquals("S_CURRENT", report.getStudentId());
TableDataInfo attRes = controller.getMyAttendance(null, null);
assertEquals(200, attRes.getCode());
assertEquals(1, attRes.getRows().size());
AjaxResult durRes = controller.getMyDuration();
assertEquals(200, durRes.get("code"));
@SuppressWarnings("unchecked")
List<TrainStudyDuration> durations = (List<TrainStudyDuration>) durRes.get("data");
assertEquals(1, durations.size());
AjaxResult portraitRes = controller.getMyPortrait();
assertEquals(200, portraitRes.get("code"));
TrainStudentPortrait portrait = (TrainStudentPortrait) portraitRes.get("data");
assertEquals(new BigDecimal("98.5"), portrait.getAttendanceRate());
}
@Test
public void shouldThrowForbiddenWhenUserHasNoStudentBinding()
{
currentStudentId = null;
try
{
controller.getStudentProfile();
fail("未绑定学员档案必须抛出 403");
}
catch (ServiceException e)
{
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\""));
}
}
@@ -0,0 +1,306 @@
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.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainStudentCheckin;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.domain.query.EnrollmentQuery;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* 教师专属自服务接口契约与 IDOR 越权防护测试。
*
* @author ruoyi
*/
public class TeacherSelfServiceApiTest
{
private TrainTeacherMeController controller;
private ITrainingIdentityService identityService;
private TrainTeacherMapper teacherMapper;
private TrainLessonMapper lessonMapper;
private TrainEnrollmentMapper enrollmentMapper;
private TrainStudentCheckinMapper checkinMapper;
private String currentTeacherId = "T_CURRENT";
@Before
public void setUp()
{
identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
if ("assertTeacherIdentity".equals(method.getName()))
{
if (currentTeacherId == null)
{
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", 403);
}
return currentTeacherId;
}
return null;
}
);
teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainTeacherMapper.class},
(proxy, method, args) -> {
if ("selectTrainTeacherByTId".equals(method.getName()))
{
TrainTeacher teacher = new TrainTeacher();
teacher.setTId((String) args[0]);
teacher.settName("王老师");
teacher.setOrgName("机务段");
return teacher;
}
return null;
}
);
lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainLessonMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("countTeacherInPlan".equals(name))
{
String tId = (String) args[0];
String planId = (String) args[1];
// 仅当教师是 T_CURRENT 且班级是 PLAN_MY 时有任教关系
return ("T_CURRENT".equals(tId) && "PLAN_MY".equals(planId)) ? 1 : 0;
}
if ("selectPlansByTeacherId".equals(name))
{
List<TrainClassPlan> list = new ArrayList<>();
TrainClassPlan plan = new TrainClassPlan();
plan.setPlanId("PLAN_MY");
plan.setPlanName("高速铁路司机综合实训");
list.add(plan);
return list;
}
if ("selectScheduleLessonsByTeacherId".equals(name))
{
List<TimetableLessonItemVO> list = new ArrayList<>();
TimetableLessonItemVO item = new TimetableLessonItemVO();
item.setLessonId(101L);
item.setTId((String) args[0]);
item.setCourseName("列控车载设备");
list.add(item);
return list;
}
if ("selectTrainLessonById".equals(name))
{
Long lessonId = (Long) args[0];
TrainLesson lesson = new TrainLesson();
lesson.setLessonId(lessonId);
if (Long.valueOf(101L).equals(lessonId))
{
lesson.setTId("T_CURRENT");
}
else
{
lesson.setTId("T_OTHER");
}
return lesson;
}
return null;
}
);
enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainEnrollmentMapper.class},
(proxy, method, args) -> {
if ("selectEnrollmentItemList".equals(method.getName()))
{
List<EnrollmentItemVO> list = new ArrayList<>();
EnrollmentItemVO item = new EnrollmentItemVO();
item.setStudentId("S001");
item.setStudentName("张三");
item.setPhone("13912345678");
item.setIdCardMasked("1234");
list.add(item);
return list;
}
return Collections.emptyList();
}
);
checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentCheckinMapper.class},
(proxy, method, args) -> {
if ("selectCheckinsByLessonId".equals(method.getName()))
{
List<TrainStudentCheckin> list = new ArrayList<>();
TrainStudentCheckin checkin = new TrainStudentCheckin();
checkin.setLessonId((Long) args[0]);
checkin.setStudentId("S001");
list.add(checkin);
return list;
}
return Collections.emptyList();
}
);
controller = new TrainTeacherMeController(identityService, teacherMapper, lessonMapper, enrollmentMapper, checkinMapper);
}
@Test
public void shouldExposeStandardEndpointsAndPermissions() throws Exception
{
assertEquals("/train/me/teacher", TrainTeacherMeController.class.getAnnotation(RequestMapping.class).value()[0]);
Method profileMethod = TrainTeacherMeController.class.getMethod("getTeacherProfile");
Method plansMethod = TrainTeacherMeController.class.getMethod("getMyPlans", Integer.class);
Method timetableMethod = TrainTeacherMeController.class.getMethod("getMyTimetable", Date.class, Date.class);
Method studentsMethod = TrainTeacherMeController.class.getMethod("getMyClassStudents", String.class);
Method attendanceMethod = TrainTeacherMeController.class.getMethod("getLessonAttendance", Long.class);
assertNotNull(profileMethod.getAnnotation(GetMapping.class));
assertNotNull(plansMethod.getAnnotation(GetMapping.class));
assertNotNull(timetableMethod.getAnnotation(GetMapping.class));
assertNotNull(studentsMethod.getAnnotation(GetMapping.class));
assertNotNull(attendanceMethod.getAnnotation(GetMapping.class));
String expectedPerm = "@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')";
assertEquals(expectedPerm, profileMethod.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, plansMethod.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, timetableMethod.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, studentsMethod.getAnnotation(PreAuthorize.class).value());
assertEquals(expectedPerm, attendanceMethod.getAnnotation(PreAuthorize.class).value());
}
@Test
public void shouldGetTeacherProfileSuccessfully()
{
AjaxResult result = controller.getTeacherProfile();
assertEquals(200, result.get("code"));
TrainTeacher teacher = (TrainTeacher) result.get("data");
assertEquals("T_CURRENT", teacher.getTId());
assertEquals("王老师", teacher.gettName());
}
@Test
public void shouldGetMyPlansAndTimetableSuccessfully()
{
TableDataInfo plans = controller.getMyPlans(null);
assertEquals(200, plans.getCode());
assertEquals(1, plans.getRows().size());
AjaxResult timetable = controller.getMyTimetable(null, null);
assertEquals(200, timetable.get("code"));
@SuppressWarnings("unchecked")
List<TimetableLessonItemVO> list = (List<TimetableLessonItemVO>) timetable.get("data");
assertEquals(1, list.size());
assertEquals(Long.valueOf(101L), list.get(0).getLessonId());
}
@Test
public void shouldBlockTeacherAccessToUnassignedPlanStudentsWithIdorException()
{
// 教师试图访问未任教班级 PLAN_OTHER 的学员花名册
try
{
controller.getMyClassStudents("PLAN_OTHER");
fail("访问未任教班级必须抛出 403 越权异常");
}
catch (ServiceException e)
{
assertEquals("您并非该培训班的任课教师,无权查看学员名册", e.getMessage());
}
}
@Test
public void shouldMaskStudentPhoneWhenTeacherViewsAssignedClassStudents()
{
// 教师访问自己任教班级 PLAN_MY
TableDataInfo dataInfo = controller.getMyClassStudents("PLAN_MY");
assertEquals(200, dataInfo.getCode());
@SuppressWarnings("unchecked")
List<EnrollmentItemVO> rows = (List<EnrollmentItemVO>) dataInfo.getRows();
assertEquals(1, rows.size());
// 验证手机号脱敏
assertEquals("139****5678", rows.get(0).getPhone());
}
@Test
public void shouldBlockTeacherAccessToUnassignedLessonAttendanceWithIdorException()
{
// 课次 999 属于 T_OTHER 授课,T_CURRENT 试图越权读取考勤
try
{
controller.getLessonAttendance(999L);
fail("访问非本人授课课次出勤必须抛出 403 越权异常");
}
catch (ServiceException e)
{
assertEquals("您并非该课次的授课教师,无权查看出勤记录", e.getMessage());
}
}
@Test
public void shouldAllowTeacherAccessToAssignedLessonAttendance()
{
// 课次 101 属于 T_CURRENT 本人授课
AjaxResult result = controller.getLessonAttendance(101L);
assertEquals(200, result.get("code"));
@SuppressWarnings("unchecked")
List<TrainStudentCheckin> list = (List<TrainStudentCheckin>) result.get("data");
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 迁移) */
@@ -69,6 +70,16 @@ public class TrainTeacher extends BaseEntity
return tId;
}
public String getTId()
{
return tId;
}
public void setTId(String tId)
{
this.tId = tId;
}
public void settName(String tName)
{
this.tName = tName;
@@ -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;
}
}
@@ -42,4 +42,9 @@ public interface TrainClassReportMapper
* @return 插入行数
*/
public int insertTrainClassReport(TrainClassReport trainClassReport);
/**
* 查询指定学员的最新开班报到记录
*/
public TrainClassReport selectLatestReportByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
}
@@ -171,4 +171,12 @@ public interface TrainEnrollmentMapper
* @return 引用记录数
*/
int countStudyDurationByEnrollmentId(@Param("enrollmentId") Long enrollmentId);
/**
* 查询指定学员的全部参训记录及班级信息
*
* @param studentId 学员ID
* @return 参训记录列表
*/
List<EnrollmentItemVO> selectEnrollmentsByStudentId(@Param("studentId") String studentId);
}
@@ -117,4 +117,29 @@ public interface TrainLessonMapper
* 获取指定培训计划下的全部课次日程明细 (按开始时间升序排列)。
*/
List<TimetableLessonItemVO> selectScheduleLessonsByPlanId(@Param("planId") String planId);
/**
* 获取指定教师的授课日程列表 (按开始时间升序排列)。
*/
List<TimetableLessonItemVO> selectScheduleLessonsByTeacherId(@Param("tId") String tId,
@Param("startDate") Date startDate,
@Param("endDate") Date endDate);
/**
* 获取指定学员所在有效班级的参训日程列表 (按开始时间升序排列)。
*/
List<TimetableLessonItemVO> selectScheduleLessonsByStudentId(@Param("studentId") String studentId,
@Param("startDate") Date startDate,
@Param("endDate") Date endDate);
/**
* 校验教师是否在指定培训计划中具备排课任教关系。
*/
int countTeacherInPlan(@Param("tId") String tId, @Param("planId") String planId);
/**
* 查询指定教师参与授课的培训班列表。
*/
List<com.ruoyi.train.domain.TrainClassPlan> selectPlansByTeacherId(@Param("tId") String tId,
@Param("planStatus") Integer planStatus);
}
@@ -52,4 +52,16 @@ public interface TrainStudentCheckinMapper
* @return 更新行数
*/
public int updateTrainStudentCheckin(TrainStudentCheckin trainStudentCheckin);
/**
* 查询指定学员的考勤打卡流水 (按打卡时间倒序)
*/
public List<TrainStudentCheckin> selectCheckinsByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId,
@org.apache.ibatis.annotations.Param("planId") String planId,
@org.apache.ibatis.annotations.Param("month") String month);
/**
* 查询指定课次的学员考勤打卡流水
*/
public List<TrainStudentCheckin> selectCheckinsByLessonId(@org.apache.ibatis.annotations.Param("lessonId") Long lessonId);
}
@@ -9,10 +9,45 @@ 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。 */
Integer selectOneByIdCardHash(String idCardHash);
int insertTrainStudent(TrainStudent trainStudent);
/**
* 根据系统用户ID查询学员信息 (del_flag = '0')
*
* @param userId 系统用户ID
* @return 学员信息
*/
TrainStudent selectTrainStudentByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 统计指定系统用户ID绑定的有效学员数
*
* @param userId 系统用户ID
* @return 绑定数量
*/
int countByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 绑定学员系统用户账号
*
* @param sId 学员ID
* @param userId 系统用户ID
* @return 影响行数
*/
int bindUserId(@org.apache.ibatis.annotations.Param("sId") String sId, @org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 解绑学员系统用户账号
*
* @param sId 学员ID
* @return 影响行数
*/
int unbindUserId(@org.apache.ibatis.annotations.Param("sId") String sId);
}
@@ -27,4 +27,8 @@ public interface TrainStudentPortraitMapper
*/
public List<TrainStudentPortrait> selectTrainStudentPortraitList(TrainStudentPortrait trainStudentPortrait);
/**
* 查询指定学员的最新多维成长画像
*/
public TrainStudentPortrait selectPortraitByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
}
@@ -27,4 +27,8 @@ public interface TrainStudyDurationMapper
*/
public List<TrainStudyDuration> selectTrainStudyDurationList(TrainStudyDuration trainStudyDuration);
/**
* 查询指定学员的学习时长记录列表 (按同步时间倒序)
*/
public List<TrainStudyDuration> selectDurationsByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
}
@@ -66,4 +66,37 @@ public interface TrainTeacherMapper
* @return 结果
*/
public int deleteTrainTeacherByTIds(String[] tIds);
/**
* 根据系统用户ID查询教师信息
*
* @param userId 系统用户ID
* @return 教师信息
*/
public TrainTeacher selectTrainTeacherByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 统计指定系统用户ID绑定的有效教师数
*
* @param userId 系统用户ID
* @return 绑定数量
*/
public int countByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 绑定教师系统用户账号
*
* @param tId 教师工号
* @param userId 系统用户ID
* @return 影响行数
*/
public int bindUserId(@org.apache.ibatis.annotations.Param("tId") String tId, @org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 解绑教师系统用户账号
*
* @param tId 教师工号
* @return 影响行数
*/
public int unbindUserId(@org.apache.ibatis.annotations.Param("tId") String tId);
}
@@ -0,0 +1,21 @@
package com.ruoyi.train.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 用户跨表并发悲观锁 Mapper
*
* @author ruoyi
*/
public interface TrainUserLockMapper
{
/**
* 悲观排他行锁:对当前绑定的系统用户账号加行锁,防止 Teacher / Student 跨表并发竞态
*
* @param userId 系统用户ID
* @return 锁定的用户ID
*/
@Select("select user_id from sys_user where user_id = #{userId} for update")
Long lockUserForUpdate(@Param("userId") Long userId);
}
@@ -0,0 +1,93 @@
package com.ruoyi.train.service;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
/**
* 培训业务身份解析与绑定服务契约接口。
*
* <p>负责统一处理 sys_user 与 train_teacher / train_student 之间的映射推导、
* 跨表互斥校验以及服务端身份断言,杜绝客户端主键篡改与 IDOR 越权。</p>
*
* @author ruoyi
*/
public interface ITrainingIdentityService
{
/**
* 获取当前登录用户的系统用户ID
*
* @return 当前用户ID
*/
Long getCurrentUserId();
/**
* 断言当前登录用户具备合规教师身份并返回教师工号 (tId)
*
* @return 教师工号 tId
* @throws com.ruoyi.common.exception.ServiceException 当未绑定教师档案或档案停用时抛出 403
*/
String assertTeacherIdentity();
/**
* 断言当前登录用户具备合规学员身份并返回学员编号 (sId)
*
* @return 学员编号 sId
* @throws com.ruoyi.common.exception.ServiceException 当未绑定学员档案或档案停用时抛出 403
*/
String assertStudentIdentity();
/**
* 根据系统用户ID查询绑定的有效教师档案
*
* @param userId 系统用户ID
* @return 教师实体,未绑定返回 null
*/
TrainTeacher getTeacherByUserId(Long userId);
/**
* 根据系统用户ID查询绑定的有效学员档案
*
* @param userId 系统用户ID
* @return 学员实体,未绑定返回 null
*/
TrainStudent getStudentByUserId(Long userId);
/**
* 校验系统用户绑定的跨表互斥性与唯一性
*
* @param userId 待绑定的系统用户ID
* @param targetType 目标业务主体类型 ("TEACHER" / "STUDENT")
* @param targetBusinessId 目标业务主键 (tId 或 sId)
*/
void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId);
/**
* 绑定教师业务实体与系统用户
*
* @param tId 教师工号
* @param userId 系统用户ID
*/
void bindTeacherUser(String tId, Long userId);
/**
* 绑定学员业务实体与系统用户
*
* @param sId 学员编号
* @param userId 系统用户ID
*/
void bindStudentUser(String sId, Long userId);
/**
* 解绑教师系统用户账号
*
* @param tId 教师工号
*/
void unbindTeacherUser(String tId);
/**
* 解绑学员系统用户账号
*
* @param sId 学员编号
*/
void unbindStudentUser(String sId);
}
@@ -0,0 +1,212 @@
package com.ruoyi.train.service.impl;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.mapper.TrainUserLockMapper;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* 培训业务身份解析与绑定服务实现类。
*
* @author ruoyi
*/
@Service
public class TrainingIdentityServiceImpl implements ITrainingIdentityService
{
private final TrainTeacherMapper teacherMapper;
private final TrainStudentMapper studentMapper;
private TrainUserLockMapper trainUserLockMapper;
/** 用于单机并发/单元测试环境提供额外的针对目标 userId 的原子锁保障 */
private final ConcurrentHashMap<Long, ReentrantLock> userLocks = new ConcurrentHashMap<>();
public TrainingIdentityServiceImpl(TrainTeacherMapper teacherMapper, TrainStudentMapper studentMapper)
{
this.teacherMapper = teacherMapper;
this.studentMapper = studentMapper;
}
@Autowired(required = false)
public void setTrainUserLockMapper(TrainUserLockMapper trainUserLockMapper)
{
this.trainUserLockMapper = trainUserLockMapper;
}
@Override
public Long getCurrentUserId()
{
return SecurityUtils.getUserId();
}
@Override
public String assertTeacherIdentity()
{
Long userId = getCurrentUserId();
TrainTeacher teacher = teacherMapper.selectTrainTeacherByUserId(userId);
if (teacher == null)
{
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
}
if (teacher.getStatus() != null && teacher.getStatus() == 0)
{
throw new ServiceException("当前教师档案已被停用,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
}
return teacher.getTId();
}
@Override
public String assertStudentIdentity()
{
Long userId = getCurrentUserId();
TrainStudent student = studentMapper.selectTrainStudentByUserId(userId);
if (student == null)
{
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
}
if (student.getStatus() != null && student.getStatus() == 0)
{
throw new ServiceException("当前学员档案已被停用,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
}
return student.getsId();
}
@Override
public TrainTeacher getTeacherByUserId(Long userId)
{
if (userId == null)
{
return null;
}
return teacherMapper.selectTrainTeacherByUserId(userId);
}
@Override
public TrainStudent getStudentByUserId(Long userId)
{
if (userId == null)
{
return null;
}
return studentMapper.selectTrainStudentByUserId(userId);
}
@Override
public void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId)
{
if (userId == null)
{
return;
}
// 正式一期身份规则:
// Admin是系统Role,不是业务身份。Admin角色允许绑定Teacher业务档案;
// Teacher与Student业务身份严格互斥,同一系统用户不得同时绑定Teacher与Student。
if ("TEACHER".equalsIgnoreCase(targetType))
{
TrainStudent existingStudent = studentMapper.selectTrainStudentByUserId(userId);
if (existingStudent != null)
{
throw new ServiceException("该系统登录账号已绑定为学员身份,不可重复绑定为教师");
}
TrainTeacher existingTeacher = teacherMapper.selectTrainTeacherByUserId(userId);
if (existingTeacher != null && (targetBusinessId == null || !existingTeacher.getTId().equals(targetBusinessId)))
{
throw new ServiceException("该系统登录账号已被其他教师档案绑定,不可重复绑定");
}
}
else if ("STUDENT".equalsIgnoreCase(targetType))
{
TrainTeacher existingTeacher = teacherMapper.selectTrainTeacherByUserId(userId);
if (existingTeacher != null)
{
throw new ServiceException("该系统登录账号已绑定为教师身份,不可重复绑定为学员");
}
TrainStudent existingStudent = studentMapper.selectTrainStudentByUserId(userId);
if (existingStudent != null && (targetBusinessId == null || !existingStudent.getsId().equals(targetBusinessId)))
{
throw new ServiceException("该系统登录账号已被其他学员档案绑定,不可重复绑定");
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void bindTeacherUser(String tId, Long userId)
{
if (userId == null)
{
throw new ServiceException("绑定目标系统用户ID不能为空");
}
// 1. 跨表悲观排他锁:在事务内锁定 sys_user 共同资源
if (trainUserLockMapper != null)
{
trainUserLockMapper.lockUserForUpdate(userId);
}
// 应用层同步保障(用于多线程单测或脱机环境)
ReentrantLock lock = userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
lock.lock();
try
{
// 2. 跨表互斥与重复绑定校验
validateUserIdentityExclusive(userId, "TEACHER", tId);
// 3. 执行绑定更新
teacherMapper.bindUserId(tId, userId);
}
finally
{
lock.unlock();
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void bindStudentUser(String sId, Long userId)
{
if (userId == null)
{
throw new ServiceException("绑定目标系统用户ID不能为空");
}
// 1. 跨表悲观排他锁:在事务内锁定 sys_user 共同资源
if (trainUserLockMapper != null)
{
trainUserLockMapper.lockUserForUpdate(userId);
}
// 应用层同步保障(用于多线程单测或脱机环境)
ReentrantLock lock = userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
lock.lock();
try
{
// 2. 跨表互斥与重复绑定校验
validateUserIdentityExclusive(userId, "STUDENT", sId);
// 3. 执行绑定更新
studentMapper.bindUserId(sId, userId);
}
finally
{
lock.unlock();
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindTeacherUser(String tId)
{
teacherMapper.unbindUserId(tId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindStudentUser(String sId)
{
studentMapper.unbindUserId(sId);
}
}
@@ -57,4 +57,12 @@
#{verifyResult}, #{verifyNote}, #{createBy}, #{createTime}, #{remark}
)
</insert>
<!-- 查询指定学员的最新报到记录 -->
<select id="selectLatestReportByStudentId" parameterType="String" resultMap="TrainClassReportResult">
<include refid="selectTrainClassReportVo"/>
where e.student_id = #{studentId}
order by r.report_time desc, r.report_id desc
limit 1
</select>
</mapper>
@@ -179,4 +179,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select count(1) from train_study_duration where enrollment_id = #{enrollmentId}
</select>
<!-- 查询指定学员的全部参训记录及班级信息 -->
<select id="selectEnrollmentsByStudentId" resultType="com.ruoyi.train.domain.vo.EnrollmentItemVO">
select e.enrollment_id as enrollmentId,
e.plan_id as planId,
e.student_id as studentId,
s.s_name as studentName,
s.s_gender as gender,
s.id_card_last4 as idCardMasked,
s.org_name as orgName,
s.s_phone as phone,
e.source_type as sourceType,
e.enrollment_status as enrollmentStatus,
e.accommodation_required as accommodationRequired,
e.join_time as joinTime,
e.complete_time as completeTime,
e.remark as remark
from train_enrollment e
left join train_student s on e.student_id = s.s_id
where e.student_id = #{studentId}
order by e.create_time desc
</select>
</mapper>
@@ -275,4 +275,115 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by l.start_time asc, l.lesson_id asc
</select>
<!-- 查询指定教师的授课课次日程 -->
<select id="selectScheduleLessonsByTeacherId" resultMap="TimetableLessonItemResult">
select l.lesson_id,
l.plan_course_id,
c.c_name as course_name,
l.t_id,
t.t_name as teacher_name,
l.start_time,
l.end_time,
l.venue_type,
l.class_room_id,
cr.room_name as class_room_name,
l.site_id,
ts.site_name,
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
b.board_sn,
l.remark
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
left join train_training_site ts on l.site_id = ts.site_id
left join (
select bind_classroom_id,
case when count(*) = 1 then min(board_sn) else null end as board_sn
from train_electronic_board
where del_flag = '0'
group by bind_classroom_id
) b on (l.venue_type = 1 and b.bind_classroom_id = l.class_room_id)
where l.t_id = #{tId}
<if test="startDate != null">
and l.start_time &gt;= #{startDate}
</if>
<if test="endDate != null">
and l.start_time &lt;= #{endDate}
</if>
order by l.start_time asc, l.lesson_id asc
</select>
<!-- 查询指定学员所在有效班级的排课日程 -->
<select id="selectScheduleLessonsByStudentId" resultMap="TimetableLessonItemResult">
select l.lesson_id,
l.plan_course_id,
c.c_name as course_name,
l.t_id,
t.t_name as teacher_name,
l.start_time,
l.end_time,
l.venue_type,
l.class_room_id,
cr.room_name as class_room_name,
l.site_id,
ts.site_name,
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
b.board_sn,
l.remark
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
left join train_training_site ts on l.site_id = ts.site_id
left join (
select bind_classroom_id,
case when count(*) = 1 then min(board_sn) else null end as board_sn
from train_electronic_board
where del_flag = '0'
group by bind_classroom_id
) b on (l.venue_type = 1 and b.bind_classroom_id = l.class_room_id)
where pc.plan_id in (
select plan_id from train_enrollment
where student_id = #{studentId} and enrollment_status in (0, 1, 4)
)
<if test="startDate != null">
and l.start_time &gt;= #{startDate}
</if>
<if test="endDate != null">
and l.start_time &lt;= #{endDate}
</if>
order by l.start_time asc, l.lesson_id asc
</select>
<!-- 校验教师是否在计划中具有授课课次 -->
<select id="countTeacherInPlan" resultType="int">
select count(1)
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
where l.t_id = #{tId} and pc.plan_id = #{planId}
</select>
<!-- 查询教师参与任教的培训班列表 -->
<select id="selectPlansByTeacherId" resultType="com.ruoyi.train.domain.TrainClassPlan">
select distinct p.plan_id as planId,
p.plan_name as planName,
p.plan_status as planStatus,
p.start_time as startTime,
p.end_time as endTime,
p.recruit_num as recruitNum,
p.del_flag as delFlag
from train_class_plan p
inner join train_plan_course pc on p.plan_id = pc.plan_id
inner join train_lesson l on pc.plan_course_id = l.plan_course_id
where l.t_id = #{tId}
and p.del_flag = '0'
<if test="planStatus != null">
and p.plan_status = #{planStatus}
</if>
order by p.start_time desc
</select>
</mapper>
@@ -87,4 +87,19 @@
</set>
where checkin_id = #{checkinId}
</update>
<!-- 查询指定学员的考勤打卡流水 -->
<select id="selectCheckinsByStudentId" resultMap="TrainStudentCheckinResult">
<include refid="selectTrainStudentCheckinVo"/>
where e.student_id = #{studentId}
<if test="planId != null and planId != ''">and e.plan_id = #{planId}</if>
order by a.sign_in_time desc, a.checkin_id desc
</select>
<!-- 查询指定课次的学员考勤打卡流水 -->
<select id="selectCheckinsByLessonId" resultMap="TrainStudentCheckinResult">
<include refid="selectTrainStudentCheckinVo"/>
where a.lesson_id = #{lessonId}
order by s.s_name asc, a.checkin_id asc
</select>
</mapper>
@@ -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>
@@ -76,4 +84,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{status}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}, #{delFlag}
)
</insert>
<!-- 根据系统用户ID查询学员 (仅限未删除 del_flag = '0') -->
<select id="selectTrainStudentByUserId" parameterType="Long" resultMap="TrainStudentResult">
<include refid="selectTrainStudentDomain" />
where user_id = #{userId} and del_flag = '0'
limit 1
</select>
<!-- 统计绑定指定userId的有效学员记录数 -->
<select id="countByUserId" parameterType="Long" resultType="int">
select count(1) from train_student where user_id = #{userId} and del_flag = '0'
</select>
<!-- 绑定系统用户账号 -->
<update id="bindUserId">
update train_student set user_id = #{userId}, update_time = CURRENT_TIMESTAMP where s_id = #{sId} and del_flag = '0'
</update>
<!-- 解绑系统用户账号 -->
<update id="unbindUserId">
update train_student set user_id = null, update_time = CURRENT_TIMESTAMP where s_id = #{sId}
</update>
</mapper>
@@ -47,4 +47,12 @@
<include refid="selectTrainStudentPortraitVo"/>
where pt.portrait_id = #{portraitId}
</select>
<!-- 查询指定学员的最新画像 -->
<select id="selectPortraitByStudentId" parameterType="String" resultMap="TrainStudentPortraitResult">
<include refid="selectTrainStudentPortraitVo"/>
where e.student_id = #{studentId}
order by pt.generated_time desc, pt.portrait_id desc
limit 1
</select>
</mapper>
@@ -56,4 +56,11 @@
<include refid="selectTrainStudyDurationVo"/>
where d.dur_id = #{durId}
</select>
<!-- 查询指定学员的学习时长记录列表 -->
<select id="selectDurationsByStudentId" parameterType="String" resultMap="TrainStudyDurationResult">
<include refid="selectTrainStudyDurationVo"/>
where e.student_id = #{studentId}
order by d.sync_time desc, d.dur_id desc
</select>
</mapper>
@@ -124,4 +124,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{tId}
</foreach>
</update>
<!-- 根据系统用户ID查询教师 (仅限未删除 del_flag = '0') -->
<select id="selectTrainTeacherByUserId" parameterType="Long" resultMap="TrainTeacherResult">
<include refid="selectTrainTeacherVo"/>
where user_id = #{userId} and del_flag = '0'
limit 1
</select>
<!-- 统计绑定指定userId的有效教师记录数 -->
<select id="countByUserId" parameterType="Long" resultType="int">
select count(1) from train_teacher where user_id = #{userId} and del_flag = '0'
</select>
<!-- 绑定系统用户账号 -->
<update id="bindUserId">
update train_teacher set user_id = #{userId}, update_time = CURRENT_TIMESTAMP where t_id = #{tId} and del_flag = '0'
</update>
<!-- 解绑系统用户账号 -->
<update id="unbindUserId">
update train_teacher set user_id = null, update_time = CURRENT_TIMESTAMP where t_id = #{tId}
</update>
</mapper>
@@ -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)
{
@@ -959,6 +977,30 @@ public class EnrollmentAppServiceImplTest
studentStore.put(student.getsId(), student);
return 1;
}
@Override
public TrainStudent selectTrainStudentByUserId(Long userId)
{
return null;
}
@Override
public int countByUserId(Long userId)
{
return 0;
}
@Override
public int bindUserId(String sId, Long userId)
{
return 0;
}
@Override
public int unbindUserId(String sId)
{
return 0;
}
}
private static class MockTrainEnrollmentMapper implements TrainEnrollmentMapper
@@ -1090,5 +1132,11 @@ public class EnrollmentAppServiceImplTest
{
return 1;
}
@Override
public List<com.ruoyi.train.domain.vo.EnrollmentItemVO> selectEnrollmentsByStudentId(String studentId)
{
return Collections.emptyList();
}
}
}
@@ -32,6 +32,7 @@ public class LegacyAnalyticsV2ServiceTest
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
}
public int insertTrainClassReport(TrainClassReport report) { return 0; }
public TrainClassReport selectLatestReportByStudentId(String studentId) { return null; }
});
assertEquals(0, service.selectTrainClassReportList(new TrainClassReport()).size());
TrainClassReport query = new TrainClassReport();
@@ -53,6 +54,8 @@ public class LegacyAnalyticsV2ServiceTest
}
public int insertTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; }
public int updateTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; }
public List<TrainStudentCheckin> selectCheckinsByStudentId(String sId, String pId, String m) { return Collections.emptyList(); }
public List<TrainStudentCheckin> selectCheckinsByLessonId(Long lId) { return Collections.emptyList(); }
});
assertEquals(0, service.selectTrainStudentCheckinList(new TrainStudentCheckin()).size());
TrainStudentCheckin query = new TrainStudentCheckin();
@@ -71,6 +74,7 @@ public class LegacyAnalyticsV2ServiceTest
public List<TrainStudyDuration> selectTrainStudyDurationList(TrainStudyDuration query) {
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
}
public List<TrainStudyDuration> selectDurationsByStudentId(String studentId) { return Collections.emptyList(); }
});
assertEquals(0, service.selectTrainStudyDurationList(new TrainStudyDuration()).size());
TrainStudyDuration query = new TrainStudyDuration();
@@ -89,6 +93,7 @@ public class LegacyAnalyticsV2ServiceTest
public List<TrainStudentPortrait> selectTrainStudentPortraitList(TrainStudentPortrait query) {
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
}
public TrainStudentPortrait selectPortraitByStudentId(String studentId) { return null; }
});
assertEquals(0, service.selectTrainStudentPortraitList(new TrainStudentPortrait()).size());
TrainStudentPortrait query = new TrainStudentPortrait();
@@ -1043,6 +1043,30 @@ public class SchedulingAppServiceTest
vo.setEndTime(l.getEndTime());
return vo;
}
@Override
public List<TimetableLessonItemVO> selectScheduleLessonsByTeacherId(String tId, Date startDate, Date endDate)
{
return Collections.emptyList();
}
@Override
public List<TimetableLessonItemVO> selectScheduleLessonsByStudentId(String studentId, Date startDate, Date endDate)
{
return Collections.emptyList();
}
@Override
public int countTeacherInPlan(String tId, String planId)
{
return 0;
}
@Override
public List<TrainClassPlan> selectPlansByTeacherId(String tId, Integer planStatus)
{
return Collections.emptyList();
}
}
/**
@@ -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)
{
@@ -288,5 +306,29 @@ public class TrainStudentServiceImplTest
store.put(student.getsId(), student);
return 1;
}
@Override
public TrainStudent selectTrainStudentByUserId(Long userId)
{
return null;
}
@Override
public int countByUserId(Long userId)
{
return 0;
}
@Override
public int bindUserId(String sId, Long userId)
{
return 0;
}
@Override
public int unbindUserId(String sId)
{
return 0;
}
}
}
@@ -0,0 +1,468 @@
package com.ruoyi.train.service.impl;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
/**
* 身份解析、跨表互斥、并发安全与绑定解绑生命周期测试。
*
* @author ruoyi
*/
public class TrainingIdentityServiceTest
{
private TrainingIdentityServiceImpl identityService;
private final Map<Long, TrainTeacher> teachersByUserId = Collections.synchronizedMap(new HashMap<>());
private final Map<String, TrainTeacher> teachersByTId = Collections.synchronizedMap(new HashMap<>());
private final Map<Long, TrainStudent> studentsByUserId = Collections.synchronizedMap(new HashMap<>());
private final Map<String, TrainStudent> studentsBySId = Collections.synchronizedMap(new HashMap<>());
@Before
public void setUp()
{
teachersByUserId.clear();
teachersByTId.clear();
studentsByUserId.clear();
studentsBySId.clear();
TrainTeacherMapper teacherMapper = createProxy(TrainTeacherMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainTeacherByUserId".equals(name))
{
return teachersByUserId.get((Long) args[0]);
}
if ("countByUserId".equals(name))
{
return teachersByUserId.containsKey((Long) args[0]) ? 1 : 0;
}
if ("bindUserId".equals(name))
{
String tId = (String) args[0];
Long userId = (Long) args[1];
TrainTeacher teacher = teachersByTId.get(tId);
if (teacher != null)
{
teacher.setUserId(userId);
teachersByUserId.put(userId, teacher);
}
return 1;
}
if ("unbindUserId".equals(name))
{
String tId = (String) args[0];
TrainTeacher teacher = teachersByTId.get(tId);
if (teacher != null && teacher.getUserId() != null)
{
teachersByUserId.remove(teacher.getUserId());
teacher.setUserId(null);
}
return 1;
}
return null;
}
});
TrainStudentMapper studentMapper = createProxy(TrainStudentMapper.class, new InvocationHandler()
{
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
{
String name = method.getName();
if ("selectTrainStudentByUserId".equals(name))
{
return studentsByUserId.get((Long) args[0]);
}
if ("countByUserId".equals(name))
{
return studentsByUserId.containsKey((Long) args[0]) ? 1 : 0;
}
if ("bindUserId".equals(name))
{
String sId = (String) args[0];
Long userId = (Long) args[1];
TrainStudent student = studentsBySId.get(sId);
if (student != null)
{
student.setUserId(userId);
studentsByUserId.put(userId, student);
}
return 1;
}
if ("unbindUserId".equals(name))
{
String sId = (String) args[0];
TrainStudent student = studentsBySId.get(sId);
if (student != null && student.getUserId() != null)
{
studentsByUserId.remove(student.getUserId());
student.setUserId(null);
}
return 1;
}
return null;
}
});
identityService = new TrainingIdentityServiceImpl(teacherMapper, studentMapper);
}
@After
public void tearDown()
{
SecurityContextHolder.clearContext();
}
private void mockLoginUser(Long userId)
{
LoginUser loginUser = new LoginUser();
loginUser.setUserId(userId);
SysUser user = new SysUser();
user.setUserId(userId);
loginUser.setUser(user);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
@Test
public void adminRoleUserCanBeBoundToTeacherIdentity()
{
// 规则审计修正:Admin是Role而非业务身份,只要未绑定Student,允许绑定Teacher业务身份
Long adminUserId = 1L;
identityService.validateUserIdentityExclusive(adminUserId, "TEACHER", "T001");
TrainTeacher teacher = new TrainTeacher();
teacher.setTId("T001");
teachersByTId.put("T001", teacher);
identityService.bindTeacherUser("T001", adminUserId);
TrainTeacher bound = identityService.getTeacherByUserId(adminUserId);
assertNotNull(bound);
assertEquals("T001", bound.getTId());
}
@Test
public void boundStudentCannotBeBoundAsTeacher()
{
Long userId = 100L;
TrainStudent student = new TrainStudent();
student.setsId("S100");
student.setUserId(userId);
studentsBySId.put("S100", student);
studentsByUserId.put(userId, student);
try
{
identityService.validateUserIdentityExclusive(userId, "TEACHER", "T100");
fail("学员账号不可重复绑定为教师");
}
catch (ServiceException e)
{
assertEquals("该系统登录账号已绑定为学员身份,不可重复绑定为教师", e.getMessage());
}
}
@Test
public void boundTeacherCannotBeBoundAsStudent()
{
Long userId = 200L;
TrainTeacher teacher = new TrainTeacher();
teacher.setTId("T200");
teacher.setUserId(userId);
teachersByTId.put("T200", teacher);
teachersByUserId.put(userId, teacher);
try
{
identityService.validateUserIdentityExclusive(userId, "STUDENT", "S200");
fail("教师账号不可重复绑定为学员");
}
catch (ServiceException e)
{
assertEquals("该系统登录账号已绑定为教师身份,不可重复绑定为学员", e.getMessage());
}
}
@Test
public void userIdCannotBeBoundToAnotherTeacher()
{
Long userId = 300L;
TrainTeacher teacher1 = new TrainTeacher();
teacher1.setTId("T301");
teacher1.setUserId(userId);
teachersByTId.put("T301", teacher1);
teachersByUserId.put(userId, teacher1);
try
{
identityService.validateUserIdentityExclusive(userId, "TEACHER", "T302");
fail("已被其他教师绑定的userId不可绑定到另一教师");
}
catch (ServiceException e)
{
assertEquals("该系统登录账号已被其他教师档案绑定,不可重复绑定", e.getMessage());
}
}
@Test
public void userIdCanBeBoundIdempotentlyToSameTeacher()
{
Long userId = 400L;
TrainTeacher teacher1 = new TrainTeacher();
teacher1.setTId("T400");
teacher1.setUserId(userId);
teachersByTId.put("T400", teacher1);
teachersByUserId.put(userId, teacher1);
// 幂等校验同一个目标业务ID不会抛异常
identityService.validateUserIdentityExclusive(userId, "TEACHER", "T400");
}
@Test
public void assertTeacherIdentitySuccessAndFailureCases()
{
// 1. 未登录
try
{
identityService.assertTeacherIdentity();
fail("未登录应抛出获取用户信息异常");
}
catch (ServiceException ignored) {}
// 2. 登录了但没有绑定教师档案
mockLoginUser(500L);
try
{
identityService.assertTeacherIdentity();
fail("未绑定教师档案应抛出拒绝异常");
}
catch (ServiceException e)
{
assertEquals("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", e.getMessage());
}
// 3. 绑定了教师档案但停用
TrainTeacher disabledTeacher = new TrainTeacher();
disabledTeacher.setTId("T501");
disabledTeacher.setUserId(500L);
disabledTeacher.setStatus(0);
teachersByUserId.put(500L, disabledTeacher);
try
{
identityService.assertTeacherIdentity();
fail("停用教师档案应抛出禁止访问异常");
}
catch (ServiceException e)
{
assertEquals("当前教师档案已被停用,禁止访问教师专属工作台", e.getMessage());
}
// 4. 正常状态教师档案
disabledTeacher.setStatus(1);
String tId = identityService.assertTeacherIdentity();
assertEquals("T501", tId);
}
@Test
public void assertStudentIdentitySuccessAndFailureCases()
{
mockLoginUser(600L);
// 1. 未绑定学员档案
try
{
identityService.assertStudentIdentity();
fail("未绑定学员档案应抛出拒绝异常");
}
catch (ServiceException e)
{
assertEquals("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", e.getMessage());
}
// 2. 绑定了学员档案但停用
TrainStudent disabledStudent = new TrainStudent();
disabledStudent.setsId("S601");
disabledStudent.setUserId(600L);
disabledStudent.setStatus(0);
studentsByUserId.put(600L, disabledStudent);
try
{
identityService.assertStudentIdentity();
fail("停用学员档案应抛出禁止访问异常");
}
catch (ServiceException e)
{
assertEquals("当前学员档案已被停用,禁止访问学员个人培训中心", e.getMessage());
}
// 3. 正常状态学员档案
disabledStudent.setStatus(1);
String sId = identityService.assertStudentIdentity();
assertEquals("S601", sId);
}
@Test
public void bindAndUnbindTeacherUserLifecycle()
{
TrainTeacher teacher = new TrainTeacher();
teacher.setTId("T700");
teachersByTId.put("T700", teacher);
identityService.bindTeacherUser("T700", 700L);
assertNotNull(identityService.getTeacherByUserId(700L));
assertEquals("T700", identityService.getTeacherByUserId(700L).getTId());
identityService.unbindTeacherUser("T700");
assertNull(identityService.getTeacherByUserId(700L));
}
@Test
public void bindAndUnbindStudentUserLifecycle()
{
TrainStudent student = new TrainStudent();
student.setsId("S800");
studentsBySId.put("S800", student);
identityService.bindStudentUser("S800", 800L);
assertNotNull(identityService.getStudentByUserId(800L));
assertEquals("S800", identityService.getStudentByUserId(800L).getsId());
identityService.unbindStudentUser("S800");
assertNull(identityService.getStudentByUserId(800L));
}
@Test
public void concurrentCrossTableBindingExclusive_TeacherXorStudent() throws Exception
{
// 并发竞态测试:同一个 userId = 999L,线程 A 执行 bindTeacher,线程 B 执行 bindStudent
Long targetUserId = 999L;
TrainTeacher teacher = new TrainTeacher();
teacher.setTId("T999");
teachersByTId.put("T999", teacher);
TrainStudent student = new TrainStudent();
student.setsId("S999");
studentsBySId.put("S999", student);
int threadCount = 2;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger conflictCount = new AtomicInteger(0);
// 线程 1: bindTeacherUser
executor.submit(() -> {
try
{
startLatch.await();
identityService.bindTeacherUser("T999", targetUserId);
successCount.incrementAndGet();
}
catch (ServiceException e)
{
conflictCount.incrementAndGet();
}
catch (Exception ignored) {}
finally
{
doneLatch.countDown();
}
});
// 线程 2: bindStudentUser
executor.submit(() -> {
try
{
startLatch.await();
identityService.bindStudentUser("S999", targetUserId);
successCount.incrementAndGet();
}
catch (ServiceException e)
{
conflictCount.incrementAndGet();
}
catch (Exception ignored) {}
finally
{
doneLatch.countDown();
}
});
// 同时放行竞态
startLatch.countDown();
boolean finished = doneLatch.await(5, TimeUnit.SECONDS);
executor.shutdown();
assertTrue("并发测试应在时限内完成", finished);
// 断言:严格仅有 1 个成功,1 个冲突拒绝
assertEquals("必须恰好一个线程成功", 1, successCount.get());
assertEquals("必须恰好一个线程捕获到身份冲突", 1, conflictCount.get());
// 断言数据库最终状态:teacher XOR student
boolean hasTeacher = teachersByUserId.containsKey(targetUserId);
boolean hasStudent = studentsByUserId.containsKey(targetUserId);
assertTrue("必须严格呈现 Teacher XOR Student 互斥状态", hasTeacher ^ hasStudent);
}
@Test
public void identityTransactionAtomicity()
{
// 验证非法入参时,不产生任何副作用
try
{
identityService.bindTeacherUser("T_NON_EXIST", null);
fail("null userId 应抛出异常");
}
catch (ServiceException e)
{
assertEquals("绑定目标系统用户ID不能为空", e.getMessage());
}
try
{
identityService.bindStudentUser("S_NON_EXIST", null);
fail("null userId 应抛出异常");
}
catch (ServiceException e)
{
assertEquals("绑定目标系统用户ID不能为空", e.getMessage());
}
}
@SuppressWarnings("unchecked")
private <T> T createProxy(Class<T> clazz, InvocationHandler handler)
{
return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{clazz}, handler);
}
}
@@ -0,0 +1,48 @@
-- =============================================================================
-- RailwaySystem 数据库 RBAC 迁移脚本: 004_rbac_identity_binding.sql
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
-- 执行目标: 建立 sys_user 与业务主体(教师/学员)物理约束与部分唯一索引
-- 事务边界: 全程在显式事务 BEGIN ... COMMIT 中执行
-- 幂等保证: 仅使用 ADD COLUMN IF NOT EXISTS 与 CREATE INDEX IF NOT EXISTS,严禁 DROP / TRUNCATE
-- 设计依据: docs/development/rbac-r2/01_identity_binding_design.md 及 06_migration_plan.md
-- =============================================================================
BEGIN;
-- 步骤 1: 确保 user_id 列存在 (幂等容错,已存在则静默跳过)
ALTER TABLE train_teacher ADD COLUMN IF NOT EXISTS user_id BIGINT;
ALTER TABLE train_student ADD COLUMN IF NOT EXISTS user_id BIGINT;
-- 步骤 2: 建立教师账号部分唯一索引 (无 DROP,幂等容错,忽略已删除 del_flag='2' 与空值)
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_teacher_user_id
ON train_teacher(user_id)
WHERE user_id IS NOT NULL AND del_flag = '0';
-- 步骤 3: 建立学员账号部分唯一索引 (无 DROP,幂等容错,忽略已删除 del_flag='2' 与空值)
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_student_user_id
ON train_student(user_id)
WHERE user_id IS NOT NULL AND del_flag = '0';
-- 步骤 4: 更新字段元数据注释
COMMENT ON COLUMN train_teacher.user_id IS '关联sys_user.user_id (唯一绑定教师登录账号)';
COMMENT ON COLUMN train_student.user_id IS '关联sys_user.user_id (唯一绑定学员登录账号)';
-- 步骤 5: 迁移完整性校验
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'train_teacher' AND indexname = 'uq_train_teacher_user_id'
) THEN
RAISE EXCEPTION 'uq_train_teacher_user_id partial unique index creation failed';
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'train_student' AND indexname = 'uq_train_student_user_id'
) THEN
RAISE EXCEPTION 'uq_train_student_user_id partial unique index creation failed';
END IF;
END $$;
COMMIT;
@@ -0,0 +1,334 @@
-- =============================================================================
-- RailwaySystem 数据库 RBAC 迁移脚本: 005_training_roles_permissions.sql
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
-- 执行目标: 预置培训业务三角色、统一班牌权限命名空间、补齐按钮权限与角色关联
-- 事务边界: 全程在显式事务 BEGIN ... COMMIT 中执行
-- 防御保证: 内置 Collision Guard 静态检查,绝不覆盖已有外来资源,冲突即显式 ABORT
-- 设计依据: docs/development/rbac-r2/02_role_permission_matrix.md 及 06_migration_plan.md
-- =============================================================================
BEGIN;
-- =============================================================================
-- 步骤 0: ID 碰撞安全守卫 (Migration Collision Guard)
-- =============================================================================
DO $$
DECLARE
rec RECORD;
BEGIN
-- 0.1 检查 role_id 3, 4, 5 的碰撞与业务键一致性
FOR rec IN SELECT role_id, role_key FROM public.sys_role WHERE role_id IN (3, 4, 5)
LOOP
IF rec.role_id = 3 AND rec.role_key <> 'train_admin' THEN
RAISE EXCEPTION 'Collision detected: sys_role.role_id=3 is already occupied by role_key=%', rec.role_key;
ELSIF rec.role_id = 4 AND rec.role_key <> 'train_teacher' THEN
RAISE EXCEPTION 'Collision detected: sys_role.role_id=4 is already occupied by role_key=%', rec.role_key;
ELSIF rec.role_id = 5 AND rec.role_key <> 'train_student' THEN
RAISE EXCEPTION 'Collision detected: sys_role.role_id=5 is already occupied by role_key=%', rec.role_key;
END IF;
END LOOP;
-- 0.2 检查 role_key 存在时是否与预期 role_id 发生错位
FOR rec IN SELECT role_id, role_key FROM public.sys_role WHERE role_key IN ('train_admin', 'train_teacher', 'train_student')
LOOP
IF rec.role_key = 'train_admin' AND rec.role_id <> 3 THEN
RAISE EXCEPTION 'Collision detected: role_key=train_admin exists with mismatched role_id=%', rec.role_id;
ELSIF rec.role_key = 'train_teacher' AND rec.role_id <> 4 THEN
RAISE EXCEPTION 'Collision detected: role_key=train_teacher exists with mismatched role_id=%', rec.role_id;
ELSIF rec.role_key = 'train_student' AND rec.role_id <> 5 THEN
RAISE EXCEPTION 'Collision detected: role_key=train_student exists with mismatched role_id=%', rec.role_id;
END IF;
END LOOP;
-- 0.3 检查 menu_id 2201 ~ 2244 的碰撞:若ID已存在,必须属于预期本系统权限,严禁覆盖外来资源
IF EXISTS (
SELECT 1 FROM public.sys_menu
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..2244 is occupied by non-train permissions';
END IF;
END $$;
-- =============================================================================
-- 步骤 1: 预置 3 个核心业务角色 (train_admin, train_teacher, train_student)
-- =============================================================================
INSERT INTO public.sys_role
(role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly,
status, del_flag, create_by, create_time, update_by, update_time, remark)
SELECT 3, '培训管理员', 'train_admin', 3, '1', 1, 1,
'0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '一期业务全局管理角色'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 3 OR role_key = 'train_admin');
INSERT INTO public.sys_role
(role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly,
status, del_flag, create_by, create_time, update_by, update_time, remark)
SELECT 4, '培训教师', 'train_teacher', 4, '2', 1, 1,
'0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '教师专属教学工作台'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 4 OR role_key = 'train_teacher');
INSERT INTO public.sys_role
(role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly,
status, del_flag, create_by, create_time, update_by, update_time, remark)
SELECT 5, '培训学员', 'train_student', 5, '2', 1, 1,
'0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '学员专属个人培训中心'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 5 OR role_key = 'train_student');
-- =============================================================================
-- 步骤 2: 规范统一电子班牌权限命名空间 (system:board:* -> train:board:*)
-- =============================================================================
UPDATE public.sys_menu
SET perms = REPLACE(perms, 'system:board:', 'train:board:'),
update_by = 'admin',
update_time = CURRENT_TIMESTAMP
WHERE perms LIKE 'system:board:%';
-- =============================================================================
-- 步骤 3: 补齐基础培训管理模块按钮级权限 (sys_menu, menu_type='F')
-- =============================================================================
-- 3.1 教师管理 (parent: 2155)
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 2201, '教师查询', 2155, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:list', '#', 'admin', CURRENT_TIMESTAMP, '教师列表查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2201 OR perms = 'train:teacher:list');
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 2202, '教师详情', 2155, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:query', '#', 'admin', CURRENT_TIMESTAMP, '教师详情查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2202 OR perms = 'train:teacher:query');
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 2203, '教师新增', 2155, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:add', '#', 'admin', CURRENT_TIMESTAMP, '教师新增'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2203 OR perms = 'train:teacher: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 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, '学员列表查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2205 OR perms = 'train:student:list');
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 2206, '学员详情', 2156, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:query', '#', 'admin', CURRENT_TIMESTAMP, '学员详情查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2206 OR perms = 'train:student:query');
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 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, '教室列表查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2208 OR perms = 'train:classroom:list');
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 2209, '教室修改', 2157, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:classroom:edit', '#', 'admin', CURRENT_TIMESTAMP, '教室修改'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2209 OR perms = 'train:classroom:edit');
-- 3.4 实训场地 (parent: 2158)
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 2210, '场地查询', 2158, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:site:list', '#', 'admin', CURRENT_TIMESTAMP, '实训场地列表查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2210 OR perms = 'train:site:list');
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 2211, '场地修改', 2158, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:site:edit', '#', 'admin', CURRENT_TIMESTAMP, '实训场地修改'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2211 OR perms = 'train:site:edit');
-- 3.5 宿舍管理 (parent: 2161)
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 2212, '宿舍查询', 2161, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:dormitory:list', '#', 'admin', CURRENT_TIMESTAMP, '宿舍列表查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2212 OR perms = 'train:dormitory:list');
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 2213, '宿舍修改', 2161, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:dormitory:edit', '#', 'admin', CURRENT_TIMESTAMP, '宿舍修改'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2213 OR perms = 'train:dormitory:edit');
-- 3.6 课程管理 (parent: 2163)
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 2214, '课程查询', 2163, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:list', '#', 'admin', CURRENT_TIMESTAMP, '课程列表查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2214 OR perms = 'train:course:list');
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 2215, '课程详情', 2163, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:query', '#', 'admin', CURRENT_TIMESTAMP, '课程详情查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2215 OR perms = 'train:course:query');
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 2216, '课程新增', 2163, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:add', '#', 'admin', CURRENT_TIMESTAMP, '课程新增'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2216 OR perms = 'train:course: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 2217, '课程修改', 2163, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:edit', '#', 'admin', CURRENT_TIMESTAMP, '课程修改'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2217 OR perms = 'train:course:edit');
-- 3.7 培训计划 (parent: 2164)
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 2218, '计划查询', 2164, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:list', '#', 'admin', CURRENT_TIMESTAMP, '培训计划列表'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2218 OR perms = 'train:plan:list');
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 2219, '计划详情', 2164, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:query', '#', 'admin', CURRENT_TIMESTAMP, '培训计划详情'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2219 OR perms = 'train:plan:query');
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 2220, '计划新增', 2164, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:add', '#', 'admin', CURRENT_TIMESTAMP, '培训计划新增'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2220 OR perms = 'train:plan: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 2221, '计划修改', 2164, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:edit', '#', 'admin', CURRENT_TIMESTAMP, '培训计划修改'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2221 OR perms = 'train:plan: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 2222, '计划结训', 2164, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:complete', '#', 'admin', CURRENT_TIMESTAMP, '培训计划结束归档'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2222 OR perms = 'train:plan:complete');
-- 3.8 课次与排课 (parent: 2164)
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 2223, '排课查询', 2164, 6, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:query', '#', 'admin', CURRENT_TIMESTAMP, '排课课表查询'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2223 OR perms = 'train:lesson:query');
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 2224, '排课新增', 2164, 7, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:add', '#', 'admin', CURRENT_TIMESTAMP, '排课课次新增'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2224 OR perms = 'train:lesson: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 2225, '排课修改', 2164, 8, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:edit', '#', 'admin', CURRENT_TIMESTAMP, '排课课次修改'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2225 OR perms = 'train:lesson: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 2226, '排课删除', 2164, 9, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:remove', '#', 'admin', CURRENT_TIMESTAMP, '排课课次删除'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2226 OR perms = 'train:lesson:remove');
-- 3.9 参训学员分班 (parent: 2164)
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 2227, '参训名单查询', 2164, 10, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:list', '#', 'admin', CURRENT_TIMESTAMP, '参训花名册列表'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2227 OR perms = 'train:enrollment:list');
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 2228, '参训记录详情', 2164, 11, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:query', '#', 'admin', CURRENT_TIMESTAMP, '参训记录详情'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2228 OR perms = 'train:enrollment:query');
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 2229, '参训人员添加', 2164, 12, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:add', '#', 'admin', CURRENT_TIMESTAMP, '参训学员录入'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2229 OR perms = 'train:enrollment: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 2230, '参训人员修改', 2164, 13, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:edit', '#', 'admin', CURRENT_TIMESTAMP, '参训学员状态修改'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2230 OR perms = 'train:enrollment: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 2231, '参训人员移除', 2164, 14, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:remove', '#', 'admin', CURRENT_TIMESTAMP, '参训学员移除'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2231 OR perms = 'train:enrollment:remove');
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 2232, '参训人员导入', 2164, 15, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:import', '#', 'admin', CURRENT_TIMESTAMP, '参训学员批量导入'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2232 OR perms = 'train:enrollment:import');
-- 3.10 电子班牌标准权限 (parent: 2174)
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 2233, '班牌列表', 2174, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:list', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌列表'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2233 OR perms = 'train:board:list');
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 2234, '班牌详情', 2174, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:query', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌详情'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2234 OR perms = 'train:board:query');
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 2235, '班牌新增', 2174, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:add', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌新增'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2235 OR perms = 'train:board: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 2236, '班牌修改', 2174, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:edit', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌修改'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2236 OR perms = 'train:board: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 2237, '班牌删除', 2174, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:remove', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌删除'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2237 OR perms = 'train:board:remove');
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 2238, '班牌导出', 2174, 6, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:export', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌导出'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2238 OR perms = 'train:board:export');
-- 3.11 自服务专属菜单与页面路由 (挂载于根节点 0,供教师与学员在侧边栏直达工作台)
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 2239, '教师工作台', 0, 99, 'teacher', 'train/me/teacher/index', '', 1, 0, 'C', '0', '0', 'train:me:teacher', 'education', 'admin', CURRENT_TIMESTAMP, '教师专属工作台页面及自服务权限'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2239 OR perms = 'train:me:teacher');
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 2240, '学员中心', 0, 100, 'student', 'train/me/student/index', '', 1, 0, 'C', '0', '0', 'train:me:student', 'user', 'admin', CURRENT_TIMESTAMP, '学员专属个人培训中心页面及自服务权限'
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2240 OR perms = 'train:me:student');
-- =============================================================================
-- 步骤 4: 为业务角色分配菜单权限映射 (sys_role_menu)
-- =============================================================================
-- 4.1 train_admin (role_id = 3): 授予全部培训模块菜单与按钮
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 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
);
-- 4.2 train_teacher (role_id = 4): 授予教师专属自服务权限
INSERT INTO public.sys_role_menu (role_id, menu_id)
SELECT 4, 2239
WHERE NOT EXISTS (
SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 4 AND rm.menu_id = 2239
);
-- 4.3 train_student (role_id = 5): 授予学员专属自服务权限
INSERT INTO public.sys_role_menu (role_id, menu_id)
SELECT 5, 2240
WHERE NOT EXISTS (
SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 5 AND rm.menu_id = 2240
);
-- =============================================================================
-- 步骤 5: 迁移验证自检
-- =============================================================================
DO $$
BEGIN
IF (SELECT count(*) FROM public.sys_role WHERE role_key IN ('train_admin', 'train_teacher', 'train_student')) <> 3 THEN
RAISE EXCEPTION 'Training roles count verification failed';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:me:teacher') THEN
RAISE EXCEPTION 'Teacher self-service permission missing';
END IF;
IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:me:student') THEN
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;
END $$;
COMMIT;
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 智慧培训管理平台
VUE_APP_TITLE = SYFJGYD学员管理系统
# 开发环境配置
ENV = 'development'
+2 -2
View File
@@ -1,5 +1,5 @@
# 页面标题 智慧培训管理平台
VUE_APP_TITLE = 智慧培训管理平台
# 页面标题 SYFJGYD学员管理系统
VUE_APP_TITLE = SYFJGYD学员管理系统
# 生产环境配置
ENV = 'production'
+1 -1
View File
@@ -1,5 +1,5 @@
# 页面标题
VUE_APP_TITLE = 智慧培训管理平台
VUE_APP_TITLE = SYFJGYD学员管理系统
NODE_ENV = production
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruoyi",
"version": "3.8.5",
"description": "智慧培训管理平台",
"description": "SYFJGYD学员管理系统",
"author": "若依",
"license": "MIT",
"scripts": {
+60
View File
@@ -0,0 +1,60 @@
import request from '@/utils/request'
// 获取学员本人档案信息 (敏感信息已脱敏,严禁传 studentId)
export function getStudentProfile() {
return request({
url: '/train/me/student',
method: 'get'
})
}
// 获取本人参训班级及报名状态列表
export function getMyEnrollments(query) {
return request({
url: '/train/me/student/enrollments',
method: 'get',
params: query
})
}
// 获取本人参训课表日程
export function getMyTimetable(query) {
return request({
url: '/train/me/student/timetable',
method: 'get',
params: query
})
}
// 获取本人最新开班报到凭据与状态
export function getMyReport() {
return request({
url: '/train/me/student/report',
method: 'get'
})
}
// 获取本人考勤打卡流水
export function getMyAttendance(query) {
return request({
url: '/train/me/student/attendance',
method: 'get',
params: query
})
}
// 获取本人累计培训学时记录
export function getMyDuration() {
return request({
url: '/train/me/student/duration',
method: 'get'
})
}
// 获取本人多维成长雷达画像
export function getMyPortrait() {
return request({
url: '/train/me/student/portrait',
method: 'get'
})
}
+44
View File
@@ -0,0 +1,44 @@
import request from '@/utils/request'
// 获取教师本人档案信息
export function getTeacherProfile() {
return request({
url: '/train/me/teacher',
method: 'get'
})
}
// 获取教师本人任教的培训班列表
export function getMyPlans(query) {
return request({
url: '/train/me/teacher/plans',
method: 'get',
params: query
})
}
// 获取教师授课课表日程
export function getMyTimetable(query) {
return request({
url: '/train/me/teacher/timetable',
method: 'get',
params: query
})
}
// 获取任教班级学员花名册 (强制携带 planId)
export function getMyClassStudents(query) {
return request({
url: '/train/me/teacher/students',
method: 'get',
params: query
})
}
// 查看指定课次打卡考勤
export function getLessonAttendance(lessonId) {
return request({
url: '/train/me/teacher/lessons/' + lessonId + '/attendance',
method: 'get'
})
}
+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'
})
}
@@ -35,7 +35,7 @@ export default {
},
data() {
return {
title: '智慧培训管理平台',
title: 'SYFJGYD学员管理系统',
logo: logoImg
}
}
+2
View File
@@ -1,4 +1,6 @@
module.exports = {
title: 'SYFJGYD学员管理系统',
/**
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light
*/
+91 -23
View File
@@ -1,36 +1,105 @@
<el-row :gutter="20">
<el-col :sm="24" :lg="14" style="padding-left: 20px;">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 16px;">智慧培训管理平台</span>
</div>
<div class="text item" style="margin-bottom: 16px; font-size: 14px; color: #606266; line-height: 1.8;">
<p>欢迎使用智慧培训管理平台。本平台提供培训计划编排、学员主档与参训管理、智能排课排程、电子班牌联动、教学设施与实训场地调度,以及全流程多维学习数据分析等核心功能。</p>
</div>
<div class="text item" style="margin-bottom: 20px; text-align: center">
<img src="@/assets/images/version.png" alt="系统架构" style="max-width: 100%; border-radius: 4px;">
</div>
<el-divider style="margin: 15px 0;" />
<div class="text item" style="display: flex; justify-content: space-between; color: #909399; font-size: 13px;">
<span>系统版本:V1.0</span>
<span>版权所有:中国科学院沈阳计算技术研究所有限公司</span>
</div>
</el-card>
</el-col>
</el-row>
<el-divider />
<template>
<div class="index-container">
<!-- 正在加载角色信息时的占位保护,杜绝瞬态闪烁 -->
<div v-if="loadingRoles" v-loading="true" style="height: 300px;" />
<!-- 1. 管理员优先展示管理端综合驾驶舱 (最高优先级: admin / train_admin) -->
<div v-else-if="isAdmin" class="app-container home">
<el-row :gutter="20">
<el-col :sm="24" :lg="14" style="padding-left: 20px;">
<el-card class="box-card">
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 16px;">SYFJGYD学员管理系统</span>
</div>
<div class="text item" style="margin-bottom: 16px; font-size: 14px; color: #606266; line-height: 1.8;">
<p>欢迎使用SYFJGYD学员管理系统。本平台提供培训计划编排、学员主档与参训管理、智能排课排程、电子班牌联动、教学设施与实训场地调度,以及全流程多维学习数据分析等核心功能。</p>
</div>
<div class="text item" style="margin-bottom: 20px; text-align: center">
<img src="@/assets/images/version.png" alt="系统架构" style="max-width: 100%; border-radius: 4px;">
</div>
<el-divider style="margin: 15px 0;" />
<div class="text item" style="display: flex; justify-content: space-between; color: #909399; font-size: 13px;">
<span>系统版本:V1.0</span>
<span>版权所有:中国科学院沈阳计算技术研究所有限公司</span>
</div>
</el-card>
</el-col>
</el-row>
<el-divider />
</div>
<!-- 2. 异常检测:同时拥有教师与学员互斥角色,展示受控安全告警 -->
<div v-else-if="hasRoleConflict" class="app-container">
<el-result
icon="warning"
title="角色互斥冲突告警"
sub-title="系统检测到您的登录账号同时被分配了【培训教师】与【培训学员】互斥角色,违反业务实名约束。请联系教务管理员核实并调整角色配置。"
>
<template slot="extra">
<el-tag type="danger">train_teacher + train_student 冲突</el-tag>
</template>
</el-result>
</div>
<!-- 3. 教师身份 (仅 train_teacher) -->
<teacher-dashboard v-else-if="isTeacherOnly" />
<!-- 4. 学员身份 (仅 train_student) -->
<student-dashboard v-else-if="isStudentOnly" />
<!-- 5. 兜底普通角色/无培训业务角色 -->
<div v-else class="app-container">
<el-result
icon="info"
title="欢迎登录系统"
sub-title="当前账号未分配具体教学业务角色,请通过左侧导航菜单开展受限工作或联系管理员授权。"
/>
</div>
</div>
</template>
<script>
import TeacherDashboard from '@/views/train/me/teacher/index'
import StudentDashboard from '@/views/train/me/student/index'
export default {
name: 'Index',
components: {
TeacherDashboard,
StudentDashboard
},
data() {
return {
// 版本号
version: '3.8.5'
}
},
computed: {
roles() {
return this.$store.getters.roles || []
},
loadingRoles() {
return !this.roles || this.roles.length === 0
},
isAdmin() {
return this.roles.includes('admin') || this.roles.includes('train_admin')
},
hasTeacher() {
return this.roles.includes('train_teacher')
},
hasStudent() {
return this.roles.includes('train_student')
},
hasRoleConflict() {
// 非 Admin 且同时包含教师和学员角色时,触发受控互斥拦截
return !this.isAdmin && this.hasTeacher && this.hasStudent
},
isTeacherOnly() {
return !this.isAdmin && this.hasTeacher && !this.hasStudent
},
isStudentOnly() {
return !this.isAdmin && this.hasStudent && !this.hasTeacher
}
},
methods: {
goTarget(href) {
window.open(href, '_blank')
@@ -102,4 +171,3 @@ export default {
}
}
</style>
+4 -4
View File
@@ -1,7 +1,7 @@
<template>
<div class="login">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form">
<h3 class="title">智慧培训管理平台</h3>
<h3 class="title">SYFJGYD学员管理系统</h3>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
@@ -49,8 +49,8 @@
<span v-if="!loading">登 录</span>
<span v-else>登 录 中...</span>
</el-button>
<div v-if="register" style="float: right;">
<router-link class="link-type" :to="'/register'">立即注册</router-link>
<div class="login-tip" style="margin-top: 15px; text-align: center; color: #909399; font-size: 13px;">
<span>账号由培训管理部门统一创建与授权维护</span>
</div>
</el-form-item>
</el-form>
@@ -90,7 +90,7 @@ export default {
// 验证码开关
captchaEnabled: false,
// 注册开关
register: true,
register: false,
redirect: undefined
}
},
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="register">
<el-form ref="registerForm" :model="registerForm" :rules="registerRules" class="register-form">
<h3 class="title">智慧培训管理平台</h3>
<h3 class="title">SYFJGYD学员管理系统</h3>
<el-form-item prop="username">
<el-input v-model="registerForm.username" type="text" auto-complete="off" placeholder="账号">
<svg-icon slot="prefix" icon-class="user" class="el-input__icon input-icon" />
+6 -6
View File
@@ -71,7 +71,7 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['system:board:add']"
v-hasPermi="['train:board:add']"
icon="el-icon-plus"
plain
size="mini"
@@ -83,7 +83,7 @@
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['system:board:edit']"
v-hasPermi="['train:board:edit']"
:disabled="single"
icon="el-icon-edit"
plain
@@ -96,7 +96,7 @@
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['system:board:remove']"
v-hasPermi="['train:board:remove']"
:disabled="multiple"
icon="el-icon-delete"
plain
@@ -109,7 +109,7 @@
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['system:board:export']"
v-hasPermi="['train:board:export']"
icon="el-icon-download"
plain
size="mini"
@@ -229,7 +229,7 @@
>
<template slot-scope="scope">
<el-button
v-hasPermi="['system:board:edit']"
v-hasPermi="['train:board:edit']"
icon="el-icon-edit"
size="mini"
type="text"
@@ -238,7 +238,7 @@
修改
</el-button>
<el-button
v-hasPermi="['system:board:remove']"
v-hasPermi="['train:board:remove']"
icon="el-icon-delete"
size="mini"
type="text"
@@ -0,0 +1,638 @@
<template>
<div class="app-container student-center">
<!-- 头部欢迎栏 -->
<el-card class="box-card header-card" shadow="never">
<div class="welcome-bar">
<div class="title-area">
<h2>SYFJGYD学员管理系统 · 学员个人培训中心</h2>
<p class="subtitle">学员专属自服务空间,全流程掌握班级参训状态、排课日程、开班报到凭据与个人考勤成长画像。</p>
</div>
<div v-if="studentProfile" class="profile-chip">
<el-tag effect="dark" type="primary" class="role-tag">参训学员</el-tag>
<span class="user-name">{{ studentProfile.sName || '参训职工' }}</span>
<span class="org-name">({{ studentProfile.orgName || '房建公寓段' }})</span>
</div>
</div>
<!-- 403 业务未绑定友好告警 -->
<el-alert
v-if="identityErrorMsg"
:title="identityErrorMsg"
type="warning"
show-icon
:closable="false"
style="margin-top: 15px;"
/>
</el-card>
<!-- 主体区域 -->
<el-row :gutter="20" style="margin-top: 20px;">
<!-- 左侧:学员个人主档卡片 -->
<el-col :xs="24" :sm="24" :md="7" :lg="6">
<el-card class="box-card profile-card" shadow="hover">
<div slot="header" class="clearfix">
<span><i class="el-icon-postcard" /> 学员基本档案</span>
</div>
<div v-loading="profileLoading">
<div v-if="studentProfile" class="profile-detail">
<div class="profile-avatar">
<el-avatar :size="72" icon="el-icon-user" style="background-color: #409eff;" />
<div class="name-block">{{ studentProfile.sName }}</div>
<div class="id-block">学号:{{ studentProfile.sId }}</div>
</div>
<el-divider />
<div class="info-list">
<div class="info-item">
<span class="label">性别:</span>
<span class="val">{{ studentProfile.sGender || '未知' }}</span>
</div>
<div class="info-item">
<span class="label">出生日期:</span>
<span class="val">{{ studentProfile.sBirth || '-' }}</span>
</div>
<div class="info-item">
<span class="label">所属单位:</span>
<span class="val">{{ studentProfile.orgName || '-' }}</span>
</div>
<div class="info-item">
<span class="label">联系电话:</span>
<span class="val">{{ studentProfile.sPhone || '-' }}</span>
</div>
<div class="info-item">
<span class="label">电子邮箱:</span>
<span class="val">{{ studentProfile.sEmail || '-' }}</span>
</div>
<div class="info-item">
<span class="label">身份证件:</span>
<span class="val">{{ studentProfile.idCardMasked || (studentProfile.idCardLast4 ? '****' + studentProfile.idCardLast4 : '-') }}</span>
</div>
<div class="info-item">
<span class="label">学员状态:</span>
<el-tag size="mini" :type="studentProfile.status === 1 ? 'success' : 'danger'">
{{ studentProfile.status === 1 ? '正常' : '已停用' }}
</el-tag>
</div>
</div>
</div>
<el-empty v-else description="暂无关联学员档案" :image-size="80" />
</div>
</el-card>
<!-- 累计学习时长卡片 -->
<el-card class="box-card duration-card" shadow="hover" style="margin-top: 20px;">
<div slot="header" class="clearfix">
<span><i class="el-icon-time" /> 累计培训学时</span>
</div>
<div v-loading="durationLoading">
<div v-if="durationList && durationList.length > 0" class="duration-summary">
<div class="total-badge">
<div class="num">{{ totalHours }}</div>
<div class="unit">总完成学时 (小时)</div>
</div>
<el-divider style="margin: 12px 0;" />
<div v-for="(item, idx) in durationList" :key="idx" class="duration-row">
<span class="course-name">{{ item.courseName || ('课程 ' + (idx + 1)) }}</span>
<span class="course-hour">{{ item.totalDurationHours || item.duration || 0 }} 学时</span>
</div>
</div>
<el-empty v-else description="暂无累计学时记录" :image-size="70" />
</div>
</el-card>
</el-col>
<!-- 右侧:培训各功能区 Tab -->
<el-col :xs="24" :sm="24" :md="17" :lg="18">
<el-tabs v-model="activeTab" type="border-card" class="student-tabs">
<!-- Tab 1: 我的培训班级 -->
<el-tab-pane label="我的培训" name="enrollments">
<div class="tab-header">
<div class="header-desc">
<i class="el-icon-guide" /> 我参与的培训班级与当前参训流转状态
</div>
<el-button icon="el-icon-refresh" size="mini" plain @click="loadEnrollments">刷新</el-button>
</div>
<el-table v-loading="enrollmentsLoading" :data="enrollmentList" border stripe style="width: 100%; margin-top: 15px;">
<el-table-column prop="planId" label="培训班编号" width="130" align="center" />
<el-table-column prop="planName" label="培训班名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="period" label="期次" width="100" align="center" />
<el-table-column prop="startDate" label="开始日期" width="120" align="center" />
<el-table-column prop="endDate" label="结训日期" width="120" align="center" />
<el-table-column prop="status" label="参训状态" width="120" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatEnrollmentStatusType(scope.row.status)">
{{ formatEnrollmentStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<template slot="empty">
<el-empty description="当前暂未参与任何培训班级" :image-size="80" />
</template>
</el-table>
</el-tab-pane>
<!-- Tab 2: 我的课表日程 -->
<el-tab-pane label="我的课表" name="timetable">
<div class="tab-header">
<div class="header-desc">
<i class="el-icon-date" /> 所在班级培训排课日程明细(严格由当前学员会话推导,无需传参)
</div>
<el-button icon="el-icon-refresh" size="mini" plain @click="loadTimetable">刷新</el-button>
</div>
<el-table v-loading="timetableLoading" :data="timetableList" border stripe style="width: 100%; margin-top: 15px;">
<el-table-column prop="lessonDate" label="上课日期" width="120" align="center" />
<el-table-column label="上课时间" width="140" align="center">
<template slot-scope="scope">
{{ scope.row.startTime || '-' }} ~ {{ scope.row.endTime || '-' }}
</template>
</el-table-column>
<el-table-column prop="planName" label="培训班级" min-width="150" show-overflow-tooltip />
<el-table-column prop="courseName" label="课程名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="teacherName" label="授课教师" width="110" align="center" />
<el-table-column prop="classroomName" label="教室/实训场地" min-width="130" show-overflow-tooltip>
<template slot-scope="scope">
{{ scope.row.classroomName || scope.row.siteName || '-' }}
</template>
</el-table-column>
<template slot="empty">
<el-empty description="当前周期内暂无排课日程" :image-size="80" />
</template>
</el-table>
</el-tab-pane>
<!-- Tab 3: 我的开班报到凭据 -->
<el-tab-pane label="我的报到" name="report">
<div class="tab-header">
<div class="header-desc">
<i class="el-icon-document-checked" /> 开班报到确认凭据与回执信息(只读查询)
</div>
<el-button icon="el-icon-refresh" size="mini" plain @click="loadReport">刷新</el-button>
</div>
<div v-loading="reportLoading" style="margin-top: 20px;">
<div v-if="reportDetail" class="report-receipt-box">
<el-result icon="success" title="开班报到已确认" sub-title="您已顺利完成培训班级开班报到手续。">
<template slot="extra">
<div class="receipt-details">
<el-descriptions border :column="2">
<el-descriptions-item label="报到流水ID">{{ reportDetail.reportId || '-' }}</el-descriptions-item>
<el-descriptions-item label="培训班编号">{{ reportDetail.planId || '-' }}</el-descriptions-item>
<el-descriptions-item label="报到状态">
<el-tag size="small" type="success">已确认报到</el-tag>
</el-descriptions-item>
<el-descriptions-item label="报到确认时间">{{ reportDetail.reportTime || reportDetail.createTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="报到方式">{{ reportDetail.reportMethod || '电子班牌/终端报到' }}</el-descriptions-item>
<el-descriptions-item label="确认人员">{{ reportDetail.createBy || '教务管理员' }}</el-descriptions-item>
</el-descriptions>
</div>
</template>
</el-result>
</div>
<el-empty v-else description="暂无开班报到确认凭据" :image-size="90" />
</div>
</el-tab-pane>
<!-- Tab 4: 我的考勤打卡流水 -->
<el-tab-pane label="我的考勤" name="attendance">
<div class="tab-header">
<div class="header-desc">
<i class="el-icon-check" /> 课堂出勤打卡流水(状态严格遵循:0 缺勤, 1 正常, 2 迟到, 3 早退, 4 请假)
</div>
<el-button icon="el-icon-refresh" size="mini" plain @click="loadAttendance">刷新</el-button>
</div>
<el-table v-loading="attendanceLoading" :data="attendanceList" border stripe style="width: 100%; margin-top: 15px;">
<el-table-column prop="lessonId" label="课次编号" width="110" align="center" />
<el-table-column prop="planName" label="培训班级" min-width="150" show-overflow-tooltip />
<el-table-column prop="courseName" label="课程名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="checkinStatus" label="签到状态" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatAttendanceStatusType(scope.row.checkinStatus)">
{{ formatAttendanceStatusText(scope.row.checkinStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="checkinTime" label="签到打卡时间" min-width="150" align="center" />
<el-table-column prop="checkoutStatus" label="签退状态" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatAttendanceStatusType(scope.row.checkoutStatus)">
{{ formatAttendanceStatusText(scope.row.checkoutStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="checkoutTime" label="签退打卡时间" min-width="150" align="center" />
<template slot="empty">
<el-empty description="暂无考勤打卡流水记录" :image-size="80" />
</template>
</el-table>
</el-tab-pane>
<!-- Tab 5: 多维能力画像 -->
<el-tab-pane label="个人画像" name="portrait">
<div class="tab-header">
<div class="header-desc">
<i class="el-icon-data-analysis" /> 多维能力成长画像(真实能力模型分析)
</div>
<el-button icon="el-icon-refresh" size="mini" plain @click="loadPortrait">刷新</el-button>
</div>
<div v-loading="portraitLoading" style="margin-top: 20px;">
<div v-if="hasPortraitData" class="portrait-container">
<div ref="radarChart" style="width: 100%; height: 380px;" />
</div>
<el-empty v-else description="暂无综合能力画像数据(待培训考核评估完成后生成)" :image-size="90" />
</div>
</el-tab-pane>
</el-tabs>
</el-col>
</el-row>
</div>
</template>
<script>
import * as echarts from 'echarts'
import {
getStudentProfile,
getMyEnrollments,
getMyTimetable,
getMyReport,
getMyAttendance,
getMyDuration,
getMyPortrait
} from '@/api/train/meStudent'
export default {
name: 'StudentCenter',
data() {
return {
activeTab: 'enrollments',
profileLoading: false,
enrollmentsLoading: false,
timetableLoading: false,
reportLoading: false,
attendanceLoading: false,
durationLoading: false,
portraitLoading: false,
studentProfile: null,
identityErrorMsg: '',
enrollmentList: [],
timetableList: [],
reportDetail: null,
attendanceList: [],
durationList: [],
portraitData: null,
hasPortraitData: false,
chartInstance: null
}
},
computed: {
totalHours() {
if (!this.durationList || this.durationList.length === 0) return 0
return this.durationList.reduce((acc, cur) => acc + Number(cur.totalDurationHours || cur.duration || 0), 0)
}
},
created() {
this.initStudentData()
},
mounted() {
window.addEventListener('resize', this.handleResize)
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
if (this.chartInstance) {
this.chartInstance.dispose()
this.chartInstance = null
}
},
methods: {
initStudentData() {
this.loadProfile()
this.loadEnrollments()
this.loadTimetable()
this.loadReport()
this.loadAttendance()
this.loadDuration()
this.loadPortrait()
},
loadProfile() {
this.profileLoading = true
getStudentProfile()
.then(res => {
if (res.code === 200) {
this.studentProfile = res.data
this.identityErrorMsg = ''
} else if (res.code === 403) {
this.identityErrorMsg = res.msg || '当前登录账号未绑定任何学员档案,请联系管理员核对分配。'
}
this.profileLoading = false
})
.catch(err => {
this.profileLoading = false
if (err && err.message && err.message.includes('403')) {
this.identityErrorMsg = '当前账号尚未关联有效学员档案,请联系培训管理部门授权。'
}
})
},
loadEnrollments() {
this.enrollmentsLoading = true
getMyEnrollments()
.then(res => {
this.enrollmentList = res.rows || res.data || []
this.enrollmentsLoading = false
})
.catch(() => {
this.enrollmentList = []
this.enrollmentsLoading = false
})
},
loadTimetable() {
this.timetableLoading = true
getMyTimetable()
.then(res => {
this.timetableList = res.data || res.rows || []
this.timetableLoading = false
})
.catch(() => {
this.timetableList = []
this.timetableLoading = false
})
},
loadReport() {
this.reportLoading = true
getMyReport()
.then(res => {
this.reportDetail = res.data
this.reportLoading = false
})
.catch(() => {
this.reportDetail = null
this.reportLoading = false
})
},
loadAttendance() {
this.attendanceLoading = true
getMyAttendance()
.then(res => {
this.attendanceList = res.rows || res.data || []
this.attendanceLoading = false
})
.catch(() => {
this.attendanceList = []
this.attendanceLoading = false
})
},
loadDuration() {
this.durationLoading = true
getMyDuration()
.then(res => {
this.durationList = res.data || res.rows || []
this.durationLoading = false
})
.catch(() => {
this.durationList = []
this.durationLoading = false
})
},
loadPortrait() {
this.portraitLoading = true
getMyPortrait()
.then(res => {
this.portraitData = res.data
// 仅当后端返回真实有效实体且具有 portraitId 时才判定为有数据,杜绝伪造数据
if (this.portraitData && this.portraitData.portraitId) {
this.hasPortraitData = true
this.$nextTick(() => {
this.renderRadarChart()
})
} else {
this.hasPortraitData = false
}
this.portraitLoading = false
})
.catch(() => {
this.hasPortraitData = false
this.portraitLoading = false
})
},
renderRadarChart() {
if (!this.$refs.radarChart) return
if (!this.chartInstance) {
this.chartInstance = echarts.init(this.$refs.radarChart)
}
const data = this.portraitData || {}
const option = {
title: {
text: (data.planName ? data.planName + ' · ' : '') + '综合成长能力画像',
left: 'center',
textStyle: { fontSize: 15, color: '#303133' }
},
tooltip: {},
radar: {
indicator: [
{ name: '出勤纪律(%)', max: 100 },
{ name: '理论成绩', max: 100 },
{ name: '实操成绩', max: 100 },
{ name: '学时评价', max: 100 }
],
radius: '65%'
},
series: [{
name: '能力评价',
type: 'radar',
data: [{
value: [
Number(data.attendanceRate || 0),
Number(data.theoryScore || 0),
Number(data.practicalScore || 0),
Number(data.totalStudyHour || 0)
],
name: '综合评分'
}]
}]
}
this.chartInstance.setOption(option)
},
handleResize() {
if (this.chartInstance) {
this.chartInstance.resize()
}
},
formatEnrollmentStatusText(status) {
const map = { 0: '待报到', 1: '已报到', 2: '请假', 3: '退训', 4: '已完成' }
return map[status] || '未知'
},
formatEnrollmentStatusType(status) {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: '' }
return map[status] || ''
},
formatAttendanceStatusText(status) {
// 状态严格使用:0 缺勤, 1 正常, 2 迟到, 3 早退, 4 请假。严禁将 1 显示为“已签到”!
const map = { 0: '缺勤', 1: '正常', 2: '迟到', 3: '早退', 4: '请假' }
return map[status] || '-'
},
formatAttendanceStatusType(status) {
const map = { 0: 'danger', 1: 'success', 2: 'warning', 3: 'warning', 4: 'info' }
return map[status] || ''
}
}
}
</script>
<style scoped lang="scss">
.student-center {
padding: 20px;
background-color: #f0f2f5;
min-height: calc(100vh - 84px);
.header-card {
border-radius: 4px;
background: #fff;
.welcome-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
.title-area {
h2 {
margin: 0 0 8px 0;
font-size: 20px;
color: #1f2f3d;
}
.subtitle {
margin: 0;
font-size: 13px;
color: #909399;
}
}
.profile-chip {
display: flex;
align-items: center;
margin-top: 10px;
.role-tag {
margin-right: 8px;
}
.user-name {
font-weight: bold;
font-size: 15px;
color: #303133;
}
.org-name {
font-size: 13px;
color: #606266;
}
}
}
}
.profile-card,
.duration-card {
border-radius: 4px;
background: #fff;
.profile-avatar {
text-align: center;
padding: 10px 0;
.name-block {
margin-top: 10px;
font-size: 16px;
font-weight: bold;
color: #303133;
}
.id-block {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
}
.info-list {
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed #ebeef5;
font-size: 13px;
.label {
color: #909399;
}
.val {
color: #303133;
font-weight: 500;
}
}
}
.duration-summary {
.total-badge {
text-align: center;
padding: 10px 0;
.num {
font-size: 32px;
font-weight: bold;
color: #1890ff;
}
.unit {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
}
.duration-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
.course-name {
color: #606266;
}
.course-hour {
color: #303133;
font-weight: bold;
}
}
}
}
.student-tabs {
border-radius: 4px;
background: #fff;
min-height: 520px;
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
.header-desc {
font-size: 13px;
color: #606266;
}
}
.report-receipt-box {
background: #fafafa;
border-radius: 6px;
padding: 15px;
.receipt-details {
max-width: 680px;
margin: 0 auto;
text-align: left;
}
}
}
}
</style>
@@ -0,0 +1,519 @@
<template>
<div class="app-container teacher-workbench">
<!-- 头部欢迎栏 -->
<el-card class="box-card header-card" shadow="never">
<div class="welcome-bar">
<div class="title-area">
<h2>SYFJGYD学员管理系统 · 教师工作台</h2>
<p class="subtitle">教师专属自服务教学空间,实时掌握任教培训班级、课表日程与学员考勤动态。</p>
</div>
<div v-if="teacherProfile" class="profile-chip">
<el-tag effect="dark" type="success" class="role-tag">认证讲师</el-tag>
<span class="user-name">{{ teacherProfile.tName || '任课教师' }}</span>
<span class="job-title">({{ teacherProfile.jobTitle || '专职讲师' }} / {{ teacherProfile.orgName || '房建公寓段' }})</span>
</div>
</div>
<!-- 403 业务未绑定友好告警 -->
<el-alert
v-if="identityErrorMsg"
:title="identityErrorMsg"
type="warning"
show-icon
:closable="false"
style="margin-top: 15px;"
/>
</el-card>
<!-- 工作台主体内容 -->
<el-row :gutter="20" style="margin-top: 20px;">
<!-- 左侧:教师个人信息卡片 -->
<el-col :xs="24" :sm="24" :md="7" :lg="6">
<el-card class="box-card profile-card" shadow="hover">
<div slot="header" class="clearfix">
<span><i class="el-icon-user" /> 教师个人档案</span>
</div>
<div v-loading="profileLoading">
<div v-if="teacherProfile" class="profile-detail">
<div class="profile-avatar">
<el-avatar :size="72" icon="el-icon-user-solid" style="background-color: #1890ff;" />
<div class="name-block">{{ teacherProfile.tName }}</div>
<div class="id-block">工号:{{ teacherProfile.tId }}</div>
</div>
<el-divider />
<div class="info-list">
<div class="info-item">
<span class="label">性别:</span>
<span class="val">{{ teacherProfile.tGender || '未知' }}</span>
</div>
<div class="info-item">
<span class="label">所属单位:</span>
<span class="val">{{ teacherProfile.orgName || '-' }}</span>
</div>
<div class="info-item">
<span class="label">职务/职称:</span>
<span class="val">{{ teacherProfile.jobTitle || '-' }}</span>
</div>
<div class="info-item">
<span class="label">联系电话:</span>
<span class="val">{{ teacherProfile.tPhone || '-' }}</span>
</div>
<div class="info-item">
<span class="label">联系邮箱:</span>
<span class="val">{{ teacherProfile.tEmail || '-' }}</span>
</div>
<div class="info-item">
<span class="label">档案状态:</span>
<el-tag size="mini" :type="teacherProfile.status === 1 ? 'success' : 'danger'">
{{ teacherProfile.status === 1 ? '正常' : '已停用' }}
</el-tag>
</div>
</div>
</div>
<el-empty v-else description="暂无关联教师档案" :image-size="80" />
</div>
</el-card>
</el-col>
<!-- 右侧:培训班列表与课表排程 -->
<el-col :xs="24" :sm="24" :md="17" :lg="18">
<el-tabs v-model="activeTab" type="border-card" class="workbench-tabs">
<!-- Tab 1: 我的任教培训班 -->
<el-tab-pane label="我的培训班" name="plans">
<div class="tab-header">
<div class="header-desc">
<i class="el-icon-collection" /> 当前已分配任教的培训班级列表(支持直接查看班级学员花名册)
</div>
<el-button icon="el-icon-refresh" size="mini" plain @click="loadMyPlans">刷新</el-button>
</div>
<el-table v-loading="plansLoading" :data="planList" border stripe style="width: 100%; margin-top: 15px;">
<el-table-column prop="planId" label="培训班编号" width="130" align="center" />
<el-table-column prop="planName" label="培训班名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="period" label="期次" width="100" align="center" />
<el-table-column prop="startDate" label="开班时间" width="120" align="center" />
<el-table-column prop="endDate" label="结训时间" width="120" align="center" />
<el-table-column prop="status" label="状态" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatPlanStatusType(scope.row.status)">
{{ formatPlanStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="140" align="center">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-user"
@click="handleOpenStudents(scope.row)"
>
学员名册
</el-button>
</template>
</el-table-column>
<template slot="empty">
<el-empty description="当前暂无任教的培训班级" :image-size="80" />
</template>
</el-table>
</el-tab-pane>
<!-- Tab 2: 我的授课日程 -->
<el-tab-pane label="我的课表" name="timetable">
<div class="tab-header">
<div class="header-desc">
<i class="el-icon-date" /> 教师授课日程课表(支持日期范围检索与课次考勤实时查验)
</div>
<div class="filter-controls">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="yyyy-MM-dd"
size="mini"
style="width: 260px; margin-right: 10px;"
@change="loadMyTimetable"
/>
<el-button icon="el-icon-search" size="mini" type="primary" plain @click="loadMyTimetable">查询</el-button>
<el-button icon="el-icon-refresh" size="mini" plain @click="resetTimetable">重置</el-button>
</div>
</div>
<el-table v-loading="timetableLoading" :data="timetableList" border stripe style="width: 100%; margin-top: 15px;">
<el-table-column prop="lessonDate" label="上课日期" width="120" align="center" />
<el-table-column label="时间段" width="140" align="center">
<template slot-scope="scope">
{{ scope.row.startTime || '-' }} ~ {{ scope.row.endTime || '-' }}
</template>
</el-table-column>
<el-table-column prop="planName" label="培训班级" min-width="150" show-overflow-tooltip />
<el-table-column prop="courseName" label="课程名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="classroomName" label="教室/实训场地" min-width="130" show-overflow-tooltip>
<template slot-scope="scope">
{{ scope.row.classroomName || scope.row.siteName || '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="130" align="center">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-view"
@click="handleOpenAttendance(scope.row)"
>
查看考勤
</el-button>
</template>
</el-table-column>
<template slot="empty">
<el-empty description="当前周期内暂无授课排程" :image-size="80" />
</template>
</el-table>
</el-tab-pane>
</el-tabs>
</el-col>
</el-row>
<!-- 抽屉 1: 任教班级学员花名册 (脱敏视图) -->
<el-drawer
:title="'班级花名册 · ' + (currentPlan.planName || '')"
:visible.sync="studentDrawerVisible"
size="55%"
direction="rtl"
>
<div class="drawer-content" style="padding: 20px;">
<div class="drawer-tip">
<el-alert
title="该名册仅面向任课教师授权查看,学员联系电话已依法执行隐私掩码脱敏。"
type="info"
show-icon
:closable="false"
/>
</div>
<el-table v-loading="studentsLoading" :data="studentList" border stripe style="width: 100%; margin-top: 15px;">
<el-table-column prop="studentId" label="学号/编号" width="120" align="center" />
<el-table-column prop="studentName" label="学员姓名" width="110" align="center" />
<el-table-column prop="orgName" label="选派单位" min-width="140" show-overflow-tooltip />
<el-table-column prop="phone" label="联系电话(脱敏)" width="140" align="center" />
<el-table-column label="参训状态" width="110" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatEnrollmentStatusType(scope.row.enrollmentStatus != null ? scope.row.enrollmentStatus : scope.row.status)">
{{ formatEnrollmentStatusText(scope.row.enrollmentStatus != null ? scope.row.enrollmentStatus : scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<template slot="empty">
<el-empty description="该培训班暂无学员参训数据" :image-size="70" />
</template>
</el-table>
</div>
</el-drawer>
<!-- 抽屉 2: 课次考勤打卡流水 (只读查验) -->
<el-drawer
:title="'课次考勤流水 · ' + (currentLesson.courseName || '')"
:visible.sync="attendanceDrawerVisible"
size="60%"
direction="rtl"
>
<div class="drawer-content" style="padding: 20px;">
<div class="lesson-meta-bar" style="margin-bottom: 15px;">
<el-tag size="small" type="primary">课次ID: {{ currentLesson.lessonId }}</el-tag>
<span style="margin-left: 15px; font-weight: bold; color: #303133;">
{{ currentLesson.lessonDate }} {{ currentLesson.startTime }}~{{ currentLesson.endTime }}
</span>
<span style="margin-left: 15px; color: #909399;">
{{ currentLesson.classroomName || currentLesson.siteName || '' }}
</span>
</div>
<el-table v-loading="attendanceLoading" :data="attendanceList" border stripe style="width: 100%;">
<el-table-column prop="studentId" label="学员编号" width="120" align="center" />
<el-table-column prop="studentName" label="学员姓名" width="110" align="center" />
<el-table-column prop="checkinStatus" label="签到状态" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatAttendanceStatusType(scope.row.checkinStatus)">
{{ formatAttendanceStatusText(scope.row.checkinStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="checkinTime" label="签到时间" min-width="150" align="center" />
<el-table-column prop="checkoutStatus" label="签退状态" width="100" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="formatAttendanceStatusType(scope.row.checkoutStatus)">
{{ formatAttendanceStatusText(scope.row.checkoutStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="checkoutTime" label="签退时间" min-width="150" align="center" />
<template slot="empty">
<el-empty description="该课次暂无学员出勤记录" :image-size="70" />
</template>
</el-table>
</div>
</el-drawer>
</div>
</template>
<script>
import {
getTeacherProfile,
getMyPlans,
getMyTimetable,
getMyClassStudents,
getLessonAttendance
} from '@/api/train/meTeacher'
export default {
name: 'TeacherWorkbench',
data() {
return {
activeTab: 'plans',
profileLoading: false,
plansLoading: false,
timetableLoading: false,
studentsLoading: false,
attendanceLoading: false,
teacherProfile: null,
identityErrorMsg: '',
planList: [],
timetableList: [],
dateRange: [],
studentDrawerVisible: false,
currentPlan: {},
studentList: [],
attendanceDrawerVisible: false,
currentLesson: {},
attendanceList: []
}
},
created() {
this.initTeacherData()
},
methods: {
initTeacherData() {
this.loadProfile()
this.loadMyPlans()
this.loadMyTimetable()
},
loadProfile() {
this.profileLoading = true
getTeacherProfile()
.then(res => {
if (res.code === 200) {
this.teacherProfile = res.data
this.identityErrorMsg = ''
} else if (res.code === 403) {
this.identityErrorMsg = res.msg || '当前登录账号未绑定任何教师档案,请联系管理员分配绑定。'
}
this.profileLoading = false
})
.catch(err => {
this.profileLoading = false
if (err && err.message && err.message.includes('403')) {
this.identityErrorMsg = '当前账号尚未关联有效教师档案,请联系培训管理部门授权。'
}
})
},
loadMyPlans() {
this.plansLoading = true
getMyPlans()
.then(res => {
this.planList = res.rows || res.data || []
this.plansLoading = false
})
.catch(() => {
this.planList = []
this.plansLoading = false
})
},
loadMyTimetable() {
this.timetableLoading = true
const query = {}
if (this.dateRange && this.dateRange.length === 2) {
query.startDate = this.dateRange[0]
query.endDate = this.dateRange[1]
}
getMyTimetable(query)
.then(res => {
this.timetableList = res.data || res.rows || []
this.timetableLoading = false
})
.catch(() => {
this.timetableList = []
this.timetableLoading = false
})
},
resetTimetable() {
this.dateRange = []
this.loadMyTimetable()
},
handleOpenStudents(plan) {
this.currentPlan = plan || {}
this.studentDrawerVisible = true
this.studentsLoading = true
getMyClassStudents({ planId: plan.planId })
.then(res => {
this.studentList = res.rows || res.data || []
this.studentsLoading = false
})
.catch(() => {
this.studentList = []
this.studentsLoading = false
})
},
handleOpenAttendance(lesson) {
this.currentLesson = lesson || {}
this.attendanceDrawerVisible = true
this.attendanceLoading = true
getLessonAttendance(lesson.lessonId)
.then(res => {
this.attendanceList = res.data || res.rows || []
this.attendanceLoading = false
})
.catch(() => {
this.attendanceList = []
this.attendanceLoading = false
})
},
formatPlanStatusText(status) {
const map = { 0: '未开班', 1: '培训中', 2: '已结训' }
return map[status] || '未知'
},
formatPlanStatusType(status) {
const map = { 0: 'info', 1: 'primary', 2: 'success' }
return map[status] || ''
},
formatEnrollmentStatusText(status) {
const map = { 0: '待报到', 1: '已报到', 2: '请假', 3: '退训', 4: '已完成' }
return map[status] || '未知'
},
formatEnrollmentStatusType(status) {
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: '' }
return map[status] || ''
},
formatAttendanceStatusText(status) {
const map = { 0: '缺勤', 1: '正常', 2: '迟到', 3: '早退', 4: '请假' }
return map[status] || '-'
},
formatAttendanceStatusType(status) {
const map = { 0: 'danger', 1: 'success', 2: 'warning', 3: 'warning', 4: 'info' }
return map[status] || ''
}
}
}
</script>
<style scoped lang="scss">
.teacher-workbench {
padding: 20px;
background-color: #f0f2f5;
min-height: calc(100vh - 84px);
.header-card {
border-radius: 4px;
background: #fff;
.welcome-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
.title-area {
h2 {
margin: 0 0 8px 0;
font-size: 20px;
color: #1f2f3d;
}
.subtitle {
margin: 0;
font-size: 13px;
color: #909399;
}
}
.profile-chip {
display: flex;
align-items: center;
margin-top: 10px;
.role-tag {
margin-right: 8px;
}
.user-name {
font-weight: bold;
font-size: 15px;
color: #303133;
}
.job-title {
font-size: 13px;
color: #606266;
}
}
}
}
.profile-card {
border-radius: 4px;
background: #fff;
.profile-avatar {
text-align: center;
padding: 10px 0;
.name-block {
margin-top: 10px;
font-size: 16px;
font-weight: bold;
color: #303133;
}
.id-block {
margin-top: 4px;
font-size: 12px;
color: #909399;
}
}
.info-list {
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed #ebeef5;
font-size: 13px;
.label {
color: #909399;
}
.val {
color: #303133;
font-weight: 500;
}
}
}
}
.workbench-tabs {
border-radius: 4px;
background: #fff;
min-height: 520px;
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
.header-desc {
font-size: 13px;
color: #606266;
}
}
}
}
</style>
+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()
})
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ function resolve(dir) {
return path.join(__dirname, dir)
}
const name = process.env.VUE_APP_TITLE || '智慧培训管理平台' // 网页标题
const name = process.env.VUE_APP_TITLE || 'SYFJGYD学员管理系统' // 网页标题
const port = process.env.port || process.env.npm_config_port || 8080 // 端口
const { VueLoaderPlugin } = require('vue-loader')
+1 -1
View File
@@ -5,7 +5,7 @@ function resolve(dir) {
return path.join(__dirname, dir)
}
const name = process.env.VUE_APP_TITLE || '智慧培训管理平台' // 网页标题
const name = process.env.VUE_APP_TITLE || 'SYFJGYD学员管理系统' // 网页标题
const port = process.env.port || process.env.npm_config_port || 85 // 端口
const { VueLoaderPlugin } = require('vue-loader')
@@ -0,0 +1,196 @@
# RailwaySystem 一期 RBAC 当前状态审计报告 (Current State Audit)
- **工程版本**: RailwaySystem 一期正式发布版本 (Commit: `e4a07a2f47d1e78a1d3cefda0228b1c65e00bad8`)
- **审计日期**: 2026-09-15
- **审计目标**: 彻底排查系统当前在认证主体、注册策略、角色模型、权限字符串、实体绑定、数据范围与 IDOR 风险方面的技术现状,为 R.2 三角色(Admin, Teacher, Student)RBAC 闭环提供客观准确的事实依据。
---
## 1. 认证主体与安全框架机制现状
### 1.1 认证基础设施 (Spring Security + JWT)
- **核心依赖**: Spring Boot Security + JJWT + Redis。
- **登录端点**: `POST /login` (`com.ruoyi.web.controller.system.SysLoginController`)。
- **认证载体**: 认证成功后,系统在内存及 Redis 中维护 `com.ruoyi.common.core.domain.model.LoginUser`:
- `user`: 关联底层系统用户实体 `com.ruoyi.common.core.domain.entity.SysUser` (表 `sys_user`)。
- `roles`: 用户持有的角色标识集合 `Set<String>` (对应 `sys_role.role_key`)。
- `permissions`: 用户拥有的功能权限集合 `Set<String>` (对应 `sys_menu.perms`)。
- `token`: 客户端请求头中携带的 `Bearer <JWT_TOKEN>`,由 `JwtAuthenticationTokenFilter` 拦截解析并放入 `SecurityContextHolder`。
### 1.2 动态菜单与前端路由生成逻辑
1. 客户端登录成功后发起 `GET /getRouters` 请求,进入 `SysMenuController.getRouters()`。
2. 调用 `SysMenuServiceImpl.selectMenuTreeByUserId(userId)`:
- 若 `userId = 1L` (即超级管理员),直接无条件查询整张 `sys_menu` 表中所有状态为正常 (`status='0'`) 且为目录/菜单类型 (`menu_type IN ('M', 'C')`) 的记录,组装为树形路由返回。
- 若为普通用户,通过关联表 `sys_user_role -> sys_role_menu -> sys_menu` 进行筛选,仅返回该用户角色分配的可见菜单。
3. 前端在 Vuex `permission` store (`store/modules/permission.js`) 中通过 `filterAsyncRouter` 动态生成 Vue-Router 路由表并挂载至侧边栏。
### 1.3 按钮权限生效与 Controller 权限校验
- **后端方法级安全**:
- 全局配置开启 `@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)`。
- Controller 方法使用 `@PreAuthorize("@ss.hasPermi('train:teacher:list')")` 或 `@ss.hasAnyPermi('...')` 注解。
- 由 Spring EL 表达式调用容器内注册名为 `ss` 的组件 `com.ruoyi.framework.web.service.PermissionService`。
- **前端按钮级控制**:
- 登录后调用 `GET /getInfo` 获取 `roles` 与 `permissions` 集合,存储于 `store.getters.permissions`。
- 自定义指令 `v-hasPermi="['train:teacher:add']"` 在 DOM 渲染时比对权限集合,若不满足则通过 `el.parentNode.removeChild(el)` 物理移除 DOM 节点。
### 1.4 超级管理员通配逻辑
- **实现位置**: `SysPermissionService.java` 与 `PermissionService.java`。
- **判断逻辑**:
- 在 `SysPermissionService.getRolePermission()` 中:`if (user.isAdmin()) { roles.add("admin"); }`。
- 在 `SysPermissionService.getMenuPermission()` 中:`if (user.isAdmin()) { perms.add("*:*:*"); }`。
- 在 `PermissionService.hasPermissions()` 中:`permissions.contains("*:*:*") || permissions.contains(permission)`。
- **审计结论**: 当前生产系统 100% 依赖超级管理员 `*:*:*` 通配权限在运行,系统从未对普通角色或受限用户进行过真实路由加载与接口鉴权。
---
## 2. 用户注册功能现状审计
### 2.1 注册页面与入口代码现状
- **前端存在注册页面**: `build_ui/src/views/register.vue` (完整包含用户名、密码、确认密码、验证码表单)。
- **登录页入口暴露**: `build_ui/src/views/login.vue` 中第 61 行存在写死的局部变量:
```javascript
// 注册开关
register: true,
```
并在模板中渲染了超链接:
```html
<div v-if="register" style="float: right;">
<router-link class="link-type" :to="'/register'">立即注册</router-link>
</div>
```
### 2.2 为什么点击注册出现“当前系统没有开启注册功能!”
- **后端拦截真相**:
- 后端注册接口 `POST /register` 存在于 `SysRegisterController.java`。
- 控制器入口第一行执行配置项检查:
```java
if (!("true".equals(configService.selectConfigByKey("sys.account.registerUser"))))
{
return error("当前系统没有开启注册功能!");
}
```
- 数据库 `sys_config` 表中配置键 `sys.account.registerUser` 的当前值为 `false` (或未设为 `true`)。
- **审计结论**:
1. 并非后端接口缺失或路由崩溃,而是标准的受控业务配置拦截。
2. 在铁路职工技能培训和专业认证系统中,账号具有强实名与组织隶属要求,**严禁开放互联网自主注册**。
3. 当前前端显示“立即注册”而点击后报错,给用户造成“系统功能损坏”的误解。
4. **正式一期建议**: 将 `login.vue` 中的 `register` 设置为 `false`(彻底隐藏注册超链接),并在登录卡片底部增加提示:“*账号由培训管理部门统一创建与维护*”。保持后端受控关闭状态不变。
---
## 3. Teacher 实体与账号绑定现状审计
### 3.1 实体与数据表结构检查
- **数据库表 `train_teacher`**:
- 在数据库 V2 迁移脚本 `003_alter_master_tables.sql` 中,已执行:
```sql
ALTER TABLE train_teacher ADD COLUMN user_id BIGINT;
COMMENT ON COLUMN train_teacher.user_id IS 'sys_user登录账号逻辑关联ID';
```
- 物理表结构中已存在 `user_id` 列,但**无外键约束,无唯一索引,允许为 NULL**。
- **Java 领域实体 `TrainTeacher.java`**:
- 存在属性 `private Long userId;` 及其 getter/setter。
- **MyBatis 映射 `TrainTeacherMapper.xml`**:
- `<result property="userId" column="user_id" />` 已配置。
- `insertTrainTeacher` 与 `updateTrainTeacher` 中包含 `<if test="userId != null">user_id,</if>`。
### 3.2 业务逻辑与控制器检查
- **`TrainTeacherController.java` 与 `TrainTeacherServiceImpl.java`**:
- 全类无任何针对 `userId` 的校验、赋值、账号生成或绑定逻辑。
- 管理员通过后台添加或修改教师时,表单不接收 `userId`,也不生成 `sys_user` 记录。
- **前端 `views/train/teacher/index.vue`**:
- 教师录入表单仅包含:教师姓名、性别、出生日期、联系电话、邮箱、所属单位、职务职称、备注。
- 完全无“关联登录账号”、“用户名”或“初始密码”字段。
- **审计结论**:
- `TEACHER_ACCOUNT_BINDING_CURRENT = NONE`
- 虽有列名预留,但业务链路、控制层与展示层中账号绑定机制完全为 0。教师人员根本无法登录系统。
---
## 4. Student 实体与账号绑定现状审计
### 4.1 实体与数据表结构检查
- **数据库表 `train_student`**:
- 在 V2 迁移脚本 `003_alter_master_tables.sql` 中已执行:
```sql
ALTER TABLE train_student ADD COLUMN user_id BIGINT;
COMMENT ON COLUMN train_student.user_id IS 'sys_user账号逻辑关联';
```
- 物理表结构中已存在 `user_id` 列,但同样**无外键、无唯一约束,全部为 NULL**。
- **Java 领域实体 `TrainStudent.java`**:
- 存在 `private Long userId;` 及其 getter/setter。
- **MyBatis 映射 `TrainStudentMapper.xml`**:
- 映射标签与 insert/update 动态 SQL 中存在 `user_id`。
### 4.2 业务逻辑与控制器检查
- **`TrainStudentController.java` 与 `TrainStudentServiceImpl.java`**:
- 仅处理学员档案信息录入与身份证加密存储(密文、Hash、后4位),完全没有与 `sys_user` 的联动。
- **前端 `views/train/student/index.vue`**:
- 仅维护学员姓名、性别、身份证号、手机、邮箱、单位、来源类型等,无登录账号绑定功能。
- **审计结论**:
- `STUDENT_ACCOUNT_BINDING_CURRENT = NONE`
- 学员主体与系统用户完全解耦,学员无法登录系统,无法查看个人培训档案与课表。
---
## 5. 系统角色模型 (SysRole) 现状审计
### 5.1 数据库已有预置角色
通过检索 `sys_role` 初始化数据,系统当前仅定义了以下 2 个角色:
1. `role_id = 1`: 角色名称: `超级管理员`,角色字符: `admin`,数据范围: `1 (全部数据权限)`。
2. `role_id = 2`: 角色名称: `普通角色`,角色字符: `common`,数据范围: `2 (自定义数据权限)`。
### 5.2 培训专属角色现状
- 经全量检查,系统中**不存在**以下任何角色定义:
- `train_admin` (培训管理员)
- `train_teacher` (培训教师)
- `train_student` (参训学员)
- 审计结论:
- `TRAIN_ADMIN_ROLE_CURRENT = NONE`
- `TRAIN_TEACHER_ROLE_CURRENT = NONE`
- `TRAIN_STUDENT_ROLE_CURRENT = NONE`
---
## 6. 权限字符串与前后端契约审计
### 6.1 前后端与数据库权限命名现状
全量扫描发现以下三大典型不一致与缺失:
| 模块类别 | 典型权限前缀 | 前端现状 | 后端现状 | 数据库 `sys_menu.perms` 现状 | 判定结论 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 教师管理 | `train:teacher:*` | `train:teacher:add/edit` | `train:teacher:list/add/edit/query` | **缺失对应按钮权限行** | 前后端命名一致,但 DB 权限行缺失 |
| 学员管理 | `train:student:*` | `train:student:add/query` | `train:student:list/add/query` | **缺失对应按钮权限行** | 前后端一致,DB 缺失 |
| 电子班牌 | `system:board:*` vs `train:board:*` | `system:board:add/edit/remove/export` | `system:board:list/add/query/edit/remove/export` | **缺失** | 命名混用 `system:`,未归入 `train:` |
| 遗留模块 | `train:auth:*` / `train:role:*` | `train:auth:add...`, `train:role:add...` | **无对应 Controller** | **无** | 废弃遗留前端页面引入的野权限 |
| 考勤报到 | `train:report:*`, `train:attendance:*` | 已正式统一 | 已正式统一 | 最近 Migration 已对齐 | **MATCH** (标准契约) |
---
## 7. 数据范围隔离与 IDOR 风险审计
### 7.1 数据范围现状
- `TEACHER_DATA_SCOPE_CURRENT = NONE (无任何基于当前教师身份的隔离逻辑)`
- `STUDENT_DATA_SCOPE_CURRENT = NONE (无任何基于当前学员身份的隔离逻辑)`
### 7.2 IDOR (越权直接对象引用) 漏洞排查
当前所有 Controller 端点直接接收前端提交的主键参数,且直接调用底层无范围过滤的查询/更新方法:
- **学员档案越权**: `GET /train/student/{sId}`,只要具备查询权限,输入任意 `sId` 即可获取他人隐私档案。
- **课表越权**: `GET /train/plans/{planId}/timetable`,无计划归属校验。
- **考勤越权**: `GET /train/checkin/list`,传入任意 `studentId` 可获取全量打卡记录。
- **学员画像越权**: `GET /train/portrait/{portraitId}`,无身份校验直接输出他人综合评定。
- **审计结论**: `IDOR_RISK_COUNT = 8 (高危端点)`,若直接开放多角色登录而未重构数据范围,系统将面临严重横向越权风险。
---
## 8. 当前状态审计总结表
| 审计维度 | 当前客观状态 | 严重度 / 影响 |
| :--- | :--- | :--- |
| **当前认证主体** | `sys_user` (LoginUser),通过 JWT 认证,超管使用 `*:*:*` 通配放行 | 正常,但无非超管业务应用 |
| **教师账号绑定** | `NONE` (物理列存在,业务全链路 0 绑定) | **阻断** Teacher 无法登录 |
| **学员账号绑定** | `NONE` (物理列存在,业务全链路 0 绑定) | **阻断** Student 无法登录 |
| **自主注册现状** | 前端界面暴露链接,后端配置受控关闭 (400 提示) | 体验缺陷,应安全隐藏入口 |
| **业务专属角色** | `train_admin`, `train_teacher`, `train_student` 全部缺失 | **缺失** 基础角色模型 |
| **数据范围隔离** | 教师与学员数据范围隔离为 `NONE`,IDOR 风险端点达 8 处 | **高危** 缺乏横向隔离 |
| **权限命名空间** | 存在 `system:board:*` 混杂及 DB 菜单按钮 perms 缺失 | 规范缺陷,需发布补丁脚本 |
@@ -0,0 +1,133 @@
# RailwaySystem 身份绑定与账号生命周期设计 (Identity Binding Design)
- **设计版本**: R.2A Design Freeze
- **设计日期**: 2026-09-15
- **核心目标**: 建立 `sys_user` 系统登录账户与 `train_teacher` (教师)、`train_student` (学员) 业务主体之间的确定性逻辑绑定模型,规范生命周期流转与唯一性约束。
---
## 1. 业务主体关联方案比较与最终选型
为了将系统登录账户(`sys_user`)与铁路培训业务实体绑定,针对系统架构演进,深入比较以下两个设计方案:
### 1.1 方案 A:在业务主体表中直联 `user_id` (推荐方案)
- **模型设计**:
- `train_teacher` 表中直接维护 `user_id BIGINT` 列,逻辑关联 `sys_user.user_id`。
- `train_student` 表中直接维护 `user_id BIGINT` 列,逻辑关联 `sys_user.user_id`。
- **架构优势**:
1. **零新增物理表**: 在前置 V2 数据库迁移脚本 `003_alter_master_tables.sql` 中,已在 `train_teacher` 和 `train_student` 表物理添加了 `user_id` 列,现有实体类和 Mapper XML 均已就绪。
2. **极速单表/外联查询**: 获取当前登录人的教师 ID 或学员 ID 时,直接执行 `SELECT t_id FROM train_teacher WHERE user_id = #{userId}`,单索引点查,响应时延 < 1ms。
3. **改造面最小**: 领域模型与既有 MyBatis 实体完全兼容,无需重构现有领域对象树。
- **劣势与局限**:
- 若未来同一个自然人同时具备“讲师”和“学员”双重身份,直联模式需要跨两张表查询或处理两份主体数据。
### 1.2 方案 B:引入统一业务身份映射表 `train_user_identity`
- **模型设计**:
- 新建映射表 `train_user_identity (id, user_id, identity_type, business_id, status, create_time)`。
- `identity_type`: `TEACHER` 对应 `t_id`, `STUDENT` 对应 `s_id`。
- **架构优势**:
1. 结构高度解耦,天然支持未来“一人多身份”、“多端身份动态切换”等超大规模平台需求。
- **劣势与局限**:
1. 需要新建物理表、维护新的外键与索引,增加数据库 Migration 的复杂度和风险。
2. 每次根据登录人推导数据范围均需多一层 JOIN 或一次额外的主键转换查询。
3. 大幅增加现有 Service 和 Mapper 的代码改造量,过度设计(Over-Engineering)。
### 1.3 综合决策对比矩阵与最终推荐
| 评估维度 | 方案 A:直联 `user_id` (现有预留) | 方案 B:统一映射表 `train_user_identity` | 权重 / 判定 |
| :--- | :---: | :---: | :---: |
| **与现有 DDL 契合度** | **100% (列已存在,实体已映射)** | 0% (需新建表与迁移脚本) | 方案 A 显著优胜 |
| **查询性能与简单度** | **极佳 (单表直查,无多余 JOIN)** | 一般 (多一层关系表关联) | 方案 A 胜 |
| **现有代码修改量** | **极小 (仅需在 Service 层赋值)** | 较大 (需新增 Mapper/Service) | 方案 A 胜 |
| **数据一致性与约束** | **单列唯一索引即刻保证** | 需要多列复合唯一约束 | 方案 A 胜 |
| **一期交付稳定性** | **极高 (稳定无副作用)** | 中 (存在映射脏数据风险) | 方案 A 胜 |
| **未来多身份扩展性** | 弱 (仅支持单一身份绑定) | **强 (天生支持多重身份)** | 方案 B 胜 |
**最终冻结决策**: **采用方案 A(RECOMMENDED_IDENTITY_MODEL = DIRECT_USER_ID)**。
以最小的改动、最高的稳定性,完美利用已有的 Kingbase DDL 物理列,达成一期业务 100% 闭环。
---
## 2. 多身份策略与唯一性约束 (Multi-Identity Policy)
### 2.1 一期多身份决策:业务主体身份互斥 (Teacher XOR Student)
- 在铁路职工技能培训体系中,明确区分角色权限(Role)与业务档案身份(Identity):
- **教务管理员 (Admin)**: 是系统权限角色(Role),负责全局资源调配、排课、分班与监控。
- **任课教师 (Teacher)**: 是教学业务身份(Identity),负责授课、课次考勤与教学答疑。
- **参训职工 (Student)**: 是参训业务身份(Identity),专职参与培训、打卡签到与考核画像。
- **冻结决策**:
- **Teacher 与 Student 业务身份严格互斥**:严禁同一个 `sys_user` 既绑定教师又绑定学员。
- **Admin 是角色而非业务身份**:拥有 Admin 角色或管理权限的账号,若同时承担教学任务,允许绑定 Teacher 业务档案(Admin Role + Teacher Identity),并在自服务工作台中以当前教师身份推导数据范围。
- **超级管理员账号环境纪律**:对默认超级管理员测试账号(如 `admin / userId=1`)的保护应通过运维环境数据纪律落实,业务服务层严禁硬编码 `userId=1` 永久禁止绑定。
### 2.2 数据库物理约束与保障机制
为了在数据库持久层杜绝脏数据与多重绑定,在数据库 Migration 脚本中为两张表增加唯一索引:
```sql
-- 1. 教师账号唯一索引 (忽略 NULL,只对已绑定的 user_id 施加唯一约束)
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_teacher_user_id
ON train_teacher(user_id)
WHERE user_id IS NOT NULL AND del_flag = '0';
-- 2. 学员账号唯一索引 (忽略 NULL,只对已绑定的 user_id 施加唯一约束)
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_student_user_id
ON train_student(user_id)
WHERE user_id IS NOT NULL AND del_flag = '0';
```
### 2.3 业务服务层互斥前置校验与跨表并发锁
在新建或绑定账号时,在 Java Service 事务中实施悲观锁与互斥断言:
```java
public void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId) {
if (userId == null) return;
if ("TEACHER".equalsIgnoreCase(targetType)) {
if (studentMapper.selectTrainStudentByUserId(userId) != null) {
throw new ServiceException("该系统登录账号已绑定为学员身份,不可重复绑定为教师");
}
} else if ("STUDENT".equalsIgnoreCase(targetType)) {
if (teacherMapper.selectTrainTeacherByUserId(userId) != null) {
throw new ServiceException("该系统登录账号已绑定为教师身份,不可重复绑定为学员");
}
}
}
```
---
## 3. 用户创建与账号生命周期联动机制
### 3.1 账号创建推荐模式:表单内可选“同时开通系统账号”
- **业务流程**:
1. 管理员在录入教师或学员档案时,表单底部勾选【开通系统登录账号】开关(默认勾选)。
2. 系统自动推荐登录账号格式:
- **教师账号**: 优先采用工号(如 `T2026001`),若未填工号则采用手机号。
- **学员账号**: 优先采用学员编号/工号(如 `GH001`),若无则采用手机号。
3. **初始密码策略**:
- 统一设置初始受控密码:`Railway@2026`。
- 采用 BCrypt 强哈希加密存储入库。
- 强制将 `sys_user.pwd_update_date` 设为 `NULL`,触发前端首次登录时强制要求用户修改密码的安全拦截。
4. **角色自动赋予**:
- 创建教师账号时,自动关联向 `sys_user_role` 插入 `role_id = <train_teacher>`。
- 创建学员账号时,自动关联向 `sys_user_role` 插入 `role_id = <train_student>`。
5. 整个创建过程置于单个本地数据库事务中(`@Transactional(rollbackFor = Exception.class)`),若任意一步失败,整体回滚,杜绝孤立僵尸账号。
### 3.2 补录与独立绑定能力 (兜底机制)
- 对于历史导入或初次创建时未开通账号的主体,在教师/学员管理列表的操作列增加【开通账号】或【重置密码】按钮,支持补录生成 `sys_user` 并回填 `user_id`。
---
## 4. 删除、停用与离职审计策略
在国家关键信息基础设施与铁路行业规范中,系统必须满足操作可追溯与数据终身审计要求:
### 4.1 离职与停用策略 (Status Synchronization)
- 当业务教师被设置为停用 (`train_teacher.status = 0`),或学员由于违反纪律被停用 (`train_student.status = 0`):
- 业务 Service 在同一事务中联动更新关联的 `sys_user.status = '1'` (停用)。
- 清理该用户在 Redis 中的在线 Token 会话缓存,实现“秒级下线”。
- 当业务教师重新启用 (`status = 1`),联动更新 `sys_user.status = '0'` (正常)。
### 4.2 软删除与历史归档策略 (Zero Physical Deletion)
- **红线要求**: **系统严禁对任何已产生过教学、考勤、报到记录的账号执行物理 `DELETE`**!
- 当管理员在界面上删除教师或学员时:
- 业务表更新 `del_flag = '2'`。
- 关联系统用户表更新 `sys_user.del_flag = '2'`。
- 保留 `train_teacher.user_id` 与 `train_student.user_id` 的数值指针不变,确保历史系统操作日志 (`sys_oper_log`)、课次考勤打卡流水 (`train_student_checkin`) 中的 `create_by` 和 `user_id` 在任何时候均可完整追溯责任人。
@@ -0,0 +1,124 @@
# RailwaySystem 角色体系与权限矩阵设计 (Role & Permission Matrix)
- **设计版本**: R.2A Design Freeze
- **设计日期**: 2026-09-15
- **核心目标**: 建立三角色(Admin, Teacher, Student)标准化角色定义,冻结权限命名空间,输出全量权限比对矩阵、角色菜单矩阵与角色操作权限矩阵。
---
## 1. 预置业务角色模型设计 (Role Model)
针对一期业务闭环与后续演进,系统正式规划 3 个预置业务角色(写入 `sys_role`):
| 角色标识 (role_key) | 角色名称 (role_name) | 角色排序 (role_sort) | 数据范围 (data_scope) | 状态 (status) | 职责与业务定义 |
| :--- | :--- | :---: | :---: | :---: | :--- |
| `train_admin` | 培训管理员 | 3 | `1` (全部数据权限) | `0` (正常) | 负责全局师资、学员、计划、排课、报到、考勤、场地等业务的集中调度与管理。 |
| `train_teacher` | 培训教师 | 4 | `2` (自定义业务推导) | `0` (正常) | 仅能查看与本人授课相关的课程、课表、培训班及学员,具备课次考勤查看与合规操作权限。 |
| `train_student` | 培训学员 | 5 | `2` (本人私有推导) | `0` (正常) | 仅能查看本人的参训班级、课程日程、个人报到状态、个人考勤、学时累计与个人画像。 |
*注:超级管理员 `admin (role_id=1)` 仍保留在系统最顶层,作为系统底层运维、字典与安全审计兜底。*
---
## 2. 权限命名空间统一冻结方案 (FINAL_PERMISSION_NAMESPACE_PROPOSAL)
### 2.1 规范原则
所有业务领域权限字符串必须严格遵循标准三段式格式:
```
train:<domain>:<action>
```
- `<domain>`: 业务领域名称,全部小写单数或固定模块名(如 `teacher`, `student`, `course`, `classroom`, `site`, `plan`, `lesson`, `enrollment`, `report`, `attendance`, `board`, `duration`, `portrait`)。
- `<action>`: 具体操作谓词,标准化定义:
- 查询类:`list` (列表分页), `query` (详情单查), `export` (报表导出)
- 变更类:`add` (新增), `edit` (编辑修改), `remove` (删除/作废)
- 核心状态机动作类:`confirm` (确认), `checkin` (打卡), `complete` (归档完成)
### 2.2 命名空间收敛与历史兼容
1. **统一班牌命名空间**:
- 纠正历史版本中混杂的 `system:board:*`,在 R.2B 中全量收敛为 `train:board:list/add/query/edit/remove/export`。
2. **严守已正式冻结的 Action 契约**:
- `train:report:confirm` (报到确认) —— **严格保留**
- `train:attendance:checkin` (课次签到打卡) —— **严格保留**
- `train:attendance:edit` (考勤人工修正) —— **严格保留**
- `train:plan:complete` (结束培训计划) —— **严格保留**
---
## 3. 全量权限字符串审计与对齐矩阵 (PERMISSION_MISMATCH_MATRIX)
本矩阵排查了当前代码库中所有业务模块的权限字符串匹配状态:
| 业务模块 (Module) | 前端权限 (Frontend Perm) | 后端权限 (Backend Perm) | 数据库 sys_menu.perms | 现状评定 (Status) | R.2 规范与整改方案 |
| :--- | :--- | :--- | :--- | :---: | :--- |
| **教师管理** | `train:teacher:add/edit` | `train:teacher:list/query/add/edit` | **缺失按钮行** | `MISSING` | 在 `005_...sql` 中为 `sys_menu` 补全对应按钮权限行 |
| **学员管理** | `train:student:add/query` | `train:student:list/query/add` | **缺失按钮行** | `MISSING` | 补齐 `train:student:list/add/edit/query/export` |
| **课程管理** | `train:course:add/edit` | `train:course:list/query/add/edit` | **缺失按钮行** | `MISSING` | 补齐 `train:course:list/query/add/edit` |
| **教室资源** | `train:classroom:edit` | `train:classroom:list/edit` | **缺失按钮行** | `MISSING` | 补齐 `train:classroom:list/edit` |
| **实训场地** | `train:site:edit` | `train:site:list/edit` | **缺失按钮行** | `MISSING` | 补齐 `train:site:list/edit` |
| **培训计划** | `train:plan:add/edit/query/complete` | `train:plan:list/query/add/edit/complete` | **缺失按钮行** | `MISSING` | 补齐 `train:plan:*` 按钮行 |
| **入班分班** | `train:enrollment:add/edit/remove/query/list/import` | `train:enrollment:list/query/add/edit/remove/import` | **缺失按钮行** | `MISSING` | 补齐 `train:enrollment:*` 按钮行 |
| **排课编排** | `train:lesson:add/edit/remove` | `train:lesson:query/add/edit/remove` | **缺失按钮行** | `MISSING` | 补齐 `train:lesson:*` 按钮行 |
| **现场报到** | `train:report:confirm/export` | `train:report:list/confirm/export` | `train:report:list/confirm/export` | `MATCH` | 完全合规,无需调整 |
| **考勤管理** | `train:attendance:export` | `train:attendance:list/checkin/edit/export` | `train:attendance:list/checkin/edit/export` | `MATCH` | 前端增加操作按钮权限控制 |
| **电子班牌** | `system:board:add/edit/remove/export` | `system:board:list/query/add/edit/remove/export` | **缺失/未归入train** | `MISMATCH` | 前后端与 SQL 统一切换为 `train:board:*` |
| **学习时长** | `train:learning:export` | `train:learning:list/export` | `train:learning:list/export` | `MATCH` | 完全合规 |
| **学员画像** | `train:portrait:export` | `train:portrait:query/export` | `train:portrait:query/export` | `MATCH` | 完全合规 |
| **宿舍管理** | `train:dormitory:list/edit` | `train:dormitory:list/edit` | **缺失按钮行** | `MISSING` | 补齐 `train:dormitory:list/edit` |
| **废弃遗留** | `train:auth:*`, `train:role:*` | **无对应控制器** | **无** | `LEGACY` | 清理废弃未使用的历史遗留权限标签 |
---
## 4. 角色-菜单矩阵 (ROLE_MENU_MATRIX)
本矩阵定义了不同身份用户登录系统后,前端侧边栏动态加载的菜单可见性:
| 菜单项 / 业务模块 | 路由路径 (Path) | 管理员 (Admin) | 教师 (Teacher) | 学员 (Student) | 交互与展示说明 |
| :--- | :--- | :---: | :---: | :---: | :--- |
| **系统控制台首页** | `/index` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 管理员全局概览 |
| **教师工作台首页** | `/train/teacher/dashboard` | `HIDDEN` | `VISIBLE` | `HIDDEN` | 教师专属 Landing 页 (我的课表/班级) |
| **学员培训中心首页** | `/train/student/portal` | `HIDDEN` | `HIDDEN` | `VISIBLE` | 学员专属 Landing 页 (报到/课表/考勤) |
| **师资队伍管理** | `/business/teacher` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 教师仅能由管理员维护 |
| **学员档案管理** | `/business/student` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 学员全量档案仅管理员可见 |
| **课程标准化管理** | `/business/course` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 课程全量字典与学时标准 |
| **教学场地/设施** | `/business/classroom`, `/business/site` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 物理教学资源管理 |
| **培训计划管理** | `/business/training-plan` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 全局立项与归档操作 |
| **课表编排日历** | `/business/timetable/lesson` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 全局智能排课与冲突消解 |
| **我的授课日程** | `/train/teacher/timetable` | `HIDDEN` | `VISIBLE` | `HIDDEN` | 教师专有:仅呈现本人授课课次 |
| **我的培训课表** | `/train/student/timetable` | `HIDDEN` | `HIDDEN` | `VISIBLE` | 学员专有:仅呈现本人参训课表 |
| **我的教学班级** | `/train/teacher/classes` | `HIDDEN` | `VISIBLE` | `HIDDEN` | 教师专有:所授课班级及学员花名册 |
| **现场报到管理** | `/business/training-report` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 管理端集中报到审核核销 |
| **我的报到信息** | `/train/student/report` | `HIDDEN` | `HIDDEN` | `VISIBLE` | 学员专有:个人报到凭证与状态核对 |
| **课次考勤管理** | `/business/attendance` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 管理端全局考勤大盘与异常处理 |
| **班级授课考勤** | `/train/teacher/attendance` | `HIDDEN` | `CONDITIONAL` | `HIDDEN` | 仅支持教师查看本人课次考勤流水 |
| **我的考勤打卡** | `/train/student/attendance` | `HIDDEN` | `HIDDEN` | `VISIBLE` | 学员专有:仅查阅本人签到签退流水 |
| **智慧电子班牌** | `/business/board` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 终端物理状态与大屏绑定 |
| **学员学习时长** | `/business/duration` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 全局学时统计 |
| **我的学时档案** | `/train/student/duration` | `HIDDEN` | `HIDDEN` | `VISIBLE` | 学员个人有效学时汇算明细 |
| **学员画像大盘** | `/business/portrait` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 全员综合评价分析 |
| **我的个人画像** | `/train/student/portrait` | `HIDDEN` | `HIDDEN` | `VISIBLE` | 学员本人的雷达评价与成长轨迹 |
| **系统与操作日志** | `/system/log/operlog` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 安全审计人员专属 |
| **系统用户/角色管理** | `/system/user`, `/system/role` | `VISIBLE` | `HIDDEN` | `HIDDEN` | 基础用户权限分配 |
---
## 5. 角色-操作权限矩阵 (ROLE_ACTION_MATRIX)
本矩阵明确在各业务功能的操作点上,不同角色的允许状态(ALLOW / DENY / CONDITIONAL):
| 业务操作动作 (Action) | 管理员 (Admin) | 教师 (Teacher) | 学员 (Student) | 业务安全规则与执行逻辑 |
| :--- | :---: | :---: | :---: | :--- |
| **创建/编辑教师主体** | `ALLOW` | `DENY` | `DENY` | 仅教务管理员具备师资建档权限 |
| **创建/编辑学员档案** | `ALLOW` | `DENY` | `DENY` | 仅教务管理员具备学员建档与身份证录入权限 |
| **开通/绑定登录账号** | `ALLOW` | `DENY` | `DENY` | 管理员受控统一分配账号 |
| **创建/修改培训计划** | `ALLOW` | `DENY` | `DENY` | 培训计划属于全局业务管理资源 |
| **执行编排排课** | `ALLOW` | `DENY` | `DENY` | 智能排课引擎与冲突消解仅限管理端 |
| **确认 Soft Warning 排课** | `ALLOW` | `DENY` | `DENY` | 普通保存流程仅限教务操作员确认 |
| **学员批量分配入班** | `ALLOW` | `DENY` | `DENY` | 计划学员编组仅限管理员操作 |
| **现场报到确认** | `ALLOW` | `DENY` | `DENY` | 线下现场审核证件并核销报到 |
| **课次签到打卡** | `CONDITIONAL` | `DENY` | `ALLOW` | 学员本人通过终端打卡;管理员受控补签 |
| **课次签退打卡** | `CONDITIONAL` | `DENY` | `ALLOW` | 学员离场打卡;签退时间严格冻结防篡改 |
| **考勤人工修正 (迟到等)** | `ALLOW` | `CONDITIONAL` | `DENY` | 管理员全权修正;教师经授权可提交修正申请 |
| **结束计划与联动归档** | `ALLOW` | `DENY` | `DENY` | 仅管理员可终结培训计划全生命周期 |
| **查看他人敏感信息** | `ALLOW` | `DENY` | `DENY` | 教师/学员严禁读取未授权的他人身份证与隐私 |
| **导出业务分析报表** | `ALLOW` | `CONDITIONAL` | `DENY` | 教师仅能导出所带班级名册;学员无导出权 |
| **用户与系统权限维护** | `ALLOW` | `DENY` | `DENY` | 严防普通用户提权 |
@@ -0,0 +1,137 @@
# RailwaySystem 数据范围与 IDOR 安全防护设计 (Data Scope & IDOR Prevention)
- **设计版本**: R.2A Design Freeze
- **设计日期**: 2026-09-15
- **核心目标**: 建立从安全凭证(`SecurityContext`)到业务主体 ID 的单向信任推导链条,杜绝前端参数篡改越权,消除全站直接对象引用(IDOR)安全漏洞。
---
## 1. 数据范围核心设计原则 (Zero-Trust Frontend)
在多角色(Admin, Teacher, Student)登录体系中,数据范围安全必须建立在**零信任前端参数**的基石之上:
```
+─────────────────────────────────────────────────────────────────────────────+
| 零信任推导核心防线 (Secure Chain) |
| |
| 客户端 HTTP Request |
| │ (仅携带 Bearer JWT Token,禁止/忽略 query 或 body 中的 identityId) |
| ▼ |
| Spring Security Filter: JwtAuthenticationTokenFilter |
| │ (验签并提取 claims 中的 userId) |
| ▼ |
| SecurityContextHolder.getContext().getAuthentication() |
| │ (获取受信任的 LoginUser.getUserId()) |
| ▼ |
| Service 内部数据推导 (SecurityContextHelper) |
| ├─ 若拥有 train_teacher 角色: |
| │ SELECT t_id FROM train_teacher WHERE user_id = #{userId} |
| ├─ 若拥有 train_student 角色: |
| │ SELECT s_id FROM train_student WHERE user_id = #{userId} |
| └─ 若拥有 admin / train_admin 角色: |
| 按管理员角色规则放行或基于组织机构过滤 |
+─────────────────────────────────────────────────────────────────────────────+
```
### 1.1 核心铁律
1. **禁止直接接收外部身份 ID**: 在教师和学员的私有业务接口中,接口方法参数严禁声明 `@RequestParam String studentId` 或 `@PathVariable String teacherId`。
2. **强制服务端单向解析**: 所有业务查询条件中的 `studentId`、`teacherId` 必须统一在 Service 事务内由 `SecurityUtils.getUserId()` 动态查询或注入。
3. **严格归属校验 (Ownership Assertion)**: 在涉及具体资源主键操作(如查看某个具体 `reportId`、`checkinId`、`enrollmentId`)时,SQL 中必须强制拼接 `AND student_id = #{currentStudentId}` 或 `AND teacher_id = #{currentTeacherId}`,不满足条件时直接返回 `404 Not Found` 或 `403 Forbidden`,坚决不泄露数据存在性。
---
## 2. 教师 (Teacher) 业务数据范围推导链条
教师登录后,其可访问的所有教学资源必须严格基于其受聘授课关系动态推导:
```
┌──────────────────────┐
│ sys_user (userId) │
└──────────┬───────────┘
│ WHERE user_id = #{userId}
▼
┌──────────────────────┐
│ train_teacher (t_id)│
└──────────┬───────────┘
│
┌───────────────────────┼────────────────────────┐
│ (排课直接授课) │ (计划课程关联) │ (关联教学班级)
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│timetable_lesson │ │train_plan_course │ │ train_class_plan │
│WHERE teacher_id │ │WHERE teacher_id │ │ WHERE plan_id IN │
│ = #{t_id} │ │ = #{t_id} │ │ (授课班级集合) │
└────────┬─────────┘ └──────────────────┘ └────────┬─────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│student_checkin │ │training_enrollment
│(本人课次考勤流水) │ │(我带的班级学员名单)│
└──────────────────┘ └──────────────────┘
```
- **授课课表数据**: 仅能查到 `train_timetable_lesson.teacher_id = #{currentTeacherId}` 的课次。
- **关联班级数据**: 仅能查到由当前教师担任授课任务的 `plan_id` 集合。
- **班级学员名单**: 仅能读取自己任教班级名单,**严禁读取全校其他培训班的学员档案**,且读取名单时必须对身份证号、住址等个人隐私字段进行脱敏(仅显示后四位)。
---
## 3. 学员 (Student) 私有数据范围推导链条
学员登录后,进入极简个人培训中心,数据范围完全收敛于“本人”:
```
┌──────────────────────┐
│ sys_user (userId) │
└──────────┬───────────┘
│ WHERE user_id = #{userId}
▼
┌──────────────────────┐
│ train_student (s_id)│
└──────────┬───────────┘
│
┌─────────────┬───────────────┼───────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
┌───────┐ ┌──────────────┐ ┌───────────┐ ┌──────────────┐ ┌────────────┐
│ 个人 │ │ 参训班级名单 │ │ 个人报到 │ │ 个人考勤记录 │ │ 个人画像/ │
│ 档案 │ │ Enrollment │ │ Report │ │ Checkin │ │ 学时累计 │
│(只读) │ │WHERE s_id=...│ │WHERE s_id │ │WHERE s_id=...│ │WHERE s_id │
└───────┘ └──────┬───────┘ └───────────┘ └──────────────┘ └────────────┘
│
▼
┌──────────────┐
│ 班级参训课表 │
│(仅限正常在训)│
└──────────────┘
```
- **学员档案**: 仅能读取与更新本人通讯电话、邮箱。
- **学员课表**: 仅能读取本人已分班入班(且状态为待报到、已报到或已完成)的班级课次,严禁获取其他班级的教学日程。
- **考勤与报到**: 报到记录、打卡记录、学时累计与学员画像全部以 `student_id = #{currentStudentId}` 为严格物理隔离边界。
---
## 4. 全站 IDOR 漏洞排查与加固矩阵 (IDOR_RISK_MATRIX)
本矩阵针对现有 Controller 方法进行逐一安全排查,明确其在多角色模式下的越权风险等级与防护要求:
| 序号 | 业务接口端点 | 当前实现机制 | 越权风险评定 | 潜在越权利用场景 | R.2 加固与防护措施 |
| :---: | :--- | :--- | :---: | :--- | :--- |
| 01 | `GET /train/student/{sId}` | 直接根据路径参数 `sId` 查询并返回档案明细 | **AT_RISK (高危)** | 学员登录后篡改 `sId` 抓取全校任意职工身份证号与家庭隐私 | **管理端专有**:普通学员严禁访问此端点;学员本人查阅统一走 `/train/me/student` |
| 02 | `GET /train/teacher/{tId}` | 直接根据路径参数 `tId` 查询教师档案 | **AT_RISK (中危)** | 任意登录人员枚举读取全校师资档案 | **管理端专有**;教师本人查阅统一走 `/train/me/teacher` |
| 03 | `GET /train/plans/{planId}/timetable` | 直接根据 `planId` 查询整班课次 | **AT_RISK (高危)** | 未参训学员或未授课教师随意侦测其他班级排课安排 | 校验当前登录人是否在该计划的教师或学员名单内,否则直接拒止 |
| 04 | `GET /train/plans/{planId}/enrollments` | 直接根据 `planId` 查询全班学员名册 | **AT_RISK (高危)** | 普通学员篡改 `planId` 窃取其他班级全部学员花名册及电话 | **严禁学员访问**;教师仅能查阅所带班级名册且敏感字段脱敏 |
| 05 | `GET /train/enrollments/{enrollmentId}` | 直接根据主键 `enrollmentId` 查询 | **AT_RISK (中危)** | 水平越权读取其他学员入班凭证 | 增加 `student_id = currentStudentId` 强行归属校验 |
| 06 | `POST /train/attendance/sign-in` | Body 接收 `studentId`, `lessonId` | **AT_RISK (高危)** | 学员 A 抓包伪造请求体中的 `studentId`,为旷课学员 B 代签 | 接口服务端强制断言 `body.studentId == currentStudentId`,否则拦截 |
| 07 | `PUT /train/attendance/{checkinId}` | 接收 `checkinId` 与修改状态 | **AT_RISK (高危)** | 普通学员或无关人员抓包篡改他人考勤记录 | 仅限拥有 `train:attendance:edit` 的管理员或授权教师操作 |
| 08 | `GET /train/checkin/{checkinId}` | 直接按 ID 查询单条打卡记录 | **AT_RISK (低危)** | 水平遍历探测其他人员打卡位置与时间 | 增加当前登录人身份归属断言 |
| 09 | `GET /train/portrait/{portraitId}` | 直接按画像 ID 返回综合能力雷达 | **AT_RISK (高危)** | 学员枚举读取其他学员测评与能力短板数据 | 学员查阅统一走 `/train/me/portrait`,底层绑定本人 ID |
| 10 | `GET /train/duration/{durId}` | 直接按主键读取学时记录 | **AT_RISK (中危)** | 水平越权探测他人学时与上线情况 | 学员统一走 `/train/me/duration`,强制本人绑定 |
| 11 | `POST /train/plans` (新增计划) | `@PreAuthorize` 鉴权 | `SAFE` | 非管理员无权限直接返回 403 | 角色与菜单隔离即可防御 |
| 12 | `POST /train/plans/{id}/lessons` (排课) | `@PreAuthorize` 鉴权 | `SAFE` | 非教务人员无排课权限 | 角色与功能权限隔离即可防御 |
### 4.1 审计总结
- **高危越权端点统计**: `IDOR_RISK_COUNT = 8`。
- **防护治理策略**:
1. **管理接口封闭**: 原有 `/train/student/**`、`/train/teacher/**`、`/train/enrollments/**` 仅保留给 `train_admin`。
2. **自服务通道独立**: 为 Teacher 与 Student 开辟全新的 `/train/me/**` API,彻底切断直接对象引用漏洞源。
+188
View File
@@ -0,0 +1,188 @@
# RailwaySystem 自助服务接口体系设计 (Self-Service API Design)
- **设计版本**: R.2A Design Freeze
- **设计日期**: 2026-09-15
- **核心目标**: 建立专属的 Teacher 和 Student 自服务 API 契约(`/train/me/**`),彻底切断客户端参数越权可能性,确保后端 100% 服务端自主推导身份。
---
## 1. API 设计原则与边界
1. **禁止复用 Admin 全量管理接口**:
- 严禁让教师或学员调用 `/train/student/list` 或 `/train/plans/{id}/timetable` 并寄希望于前端过滤。
- 前端代码可被逆向、调试或通过 Postman 直接调用,任何客户端过滤均等于零安全防护。
2. **专属 `/train/me/**` 统一前缀**:
- 所有自服务请求均指向当前登录人上下文。
- 接口入参完全不接收 `studentId`、`teacherId` 等身份主键,参数只允许包含时间范围(如 `startDate`, `endDate`)或分页标记。
3. **严格对应真实业务能力 (No Fictional APIs)**:
- 仅设计当前数据库与领域服务已真实支撑的业务模块(课表、入班名单、报到、考勤、学时、画像)。
- 当前一期暂未实现正式闭环的成绩填报或复杂选课,一律不虚构开放。
---
## 2. 教师自服务接口规格 (Teacher Self-Service APIs)
### 2.1 获取教师本人档案信息
- **端点**: `GET /train/me/teacher`
- **鉴权要求**: `@PreAuthorize("@ss.hasRole('train_teacher')")`
- **响应示例**:
```json
{
"code": 200,
"msg": "操作成功",
"data": {
"tId": "1540e76a34c54596b0e365ae8804ef68",
"tName": "DEMO_E2E_20260915_122800_王老师",
"tGender": "1",
"tPhone": "13900000000",
"tEmail": "wang@railway.cn",
"orgName": "机务段技能实训中心",
"jobTitle": "高级讲师",
"status": 1
}
}
```
### 2.2 获取我参与任课的培训班列表
- **端点**: `GET /train/me/plans`
- **逻辑**: 通过 `train_timetable_lesson` 与 `train_plan_course` 逆向查出教师受聘的全部有效培训班。
- **参数**: `pageNum`, `pageSize`, `planStatus` (可选)
- **响应示例**:
```json
{
"code": 200,
"rows": [
{
"planId": "613b316621e5466c9039650ced299aa1",
"planName": "DEMO_E2E_20260915_122800_铁路综合培训第一期",
"startDate": "2026-09-15",
"endDate": "2026-09-20",
"planStatus": 0,
"myCourseCount": 2,
"myLessonCount": 2
}
],
"total": 1
}
```
### 2.3 获取我的授课课表日程
- **端点**: `GET /train/me/timetable`
- **参数**: `startDate` (如 `2026-09-15`), `endDate` (如 `2026-09-21`)
- **逻辑**: 仅查询 `teacher_id = #{currentTeacherId}` 的已排课次。
- **响应示例**:
```json
{
"code": 200,
"data": [
{
"lessonId": 97,
"planId": "613b316621e5466c9039650ced299aa1",
"planName": "铁路综合培训第一期",
"cId": "f4a28c4079fe47a1a542d3a34768f9fd",
"courseName": "铁路安全理论",
"venueType": 2,
"venueName": "实训区A",
"startTime": "2026-09-15 08:30:00",
"endTime": "2026-09-15 10:00:00",
"status": 1
}
]
}
```
### 2.4 获取我所带班级学员花名册 (敏感信息脱敏)
- **端点**: `GET /train/me/students`
- **参数**: `planId` (必填,后端校验教师是否有该班任教关系)
- **安全保障**: 身份证号脱敏为后四位,手机号中间四位脱敏。
- **响应示例**:
```json
{
"code": 200,
"rows": [
{
"studentId": "f1b524da49da4a54a6d832ab22c2d092",
"studentName": "张三",
"maskedPhone": "138****0001",
"maskedIdCard": "************1234",
"orgName": "车辆段检修一车间",
"reportStatus": 1,
"enrollmentStatus": 0
}
],
"total": 2
}
```
### 2.5 获取我授课课次的学员出勤情况
- **端点**: `GET /train/me/lessons/{lessonId}/attendance`
- **逻辑**: 后端严格核验 `lessonId` 的授课教师是否为当前登录人,然后输出该课次应到学员的打卡流水。
---
## 3. 学员自服务接口规格 (Student Self-Service APIs)
### 3.1 获取学员个人档案
- **端点**: `GET /train/me/student`
- **鉴权要求**: `@PreAuthorize("@ss.hasRole('train_student')")`
- **响应字段**: 学员工号、姓名、性别、联系电话、所属单位、照片凭证引用。身份证号仅向本人展示后四位。
### 3.2 获取我的参训班级与生命周期状态
- **端点**: `GET /train/me/enrollments`
- **逻辑**: 从 `train_training_enrollment` 查询 `student_id = #{currentStudentId}`。
- **状态字段**:
- `enrollmentStatus`: `0 (待报到)`, `1 (已报到)`, `2 (请假)`, `3 (退训)`, `4 (已完成)`。
- `planId`, `planName`, `startTime`, `endTime`, `reportDeadline`。
### 3.3 获取我的参训课表
- **端点**: `GET /train/me/timetable`
- **参数**: `startDate`, `endDate`, `viewType` (`day`/`week`)
- **逻辑**: 关联当前学员所在有效班级(`enrollmentStatus IN (0, 1, 4)`),汇总返回所有授课课次、场地位置与任课教师姓名。
### 3.4 获取我的报到凭据与状态
- **端点**: `GET /train/me/report`
- **逻辑**: 查询当前学员在最新培训计划中的报到记录(`train_class_report`),返回报到时间、分配教室、分配场地与当前报到状态。
### 3.5 获取我的考勤打卡流水
- **端点**: `GET /train/me/attendance`
- **参数**: `planId` (可选), `month` (可选)
- **逻辑**: 查询 `train_student_checkin WHERE student_id = #{currentStudentId}`。
- **响应示例**:
```json
{
"code": 200,
"rows": [
{
"checkinId": 10,
"lessonId": 97,
"courseName": "铁路安全理论",
"checkinTime": "2026-09-15 08:28:10",
"checkoutTime": "2026-09-15 12:48:42",
"attendanceStatus": 2,
"statusLabel": "迟到",
"manualFlag": 1,
"adjustReason": "因列车调度交接延误10分钟"
}
]
}
```
### 3.6 获取我的累计学习时长
- **端点**: `GET /train/me/duration`
- **逻辑**: 归集 `train_study_duration`,按理论课时与实训课时分别统计有效累计秒数与达标折算学时。
### 3.7 获取我的多维成长画像雷达
- **端点**: `GET /train/me/portrait`
- **逻辑**: 查询 `train_student_portrait WHERE student_id = #{currentStudentId}`,返回出勤率、测评平均分、技能掌握度等 5 维雷达数据。
---
## 4. 身份异常与边界错误码规范
| HTTP 状态码 | 业务错误代码 (Code) | 错误提示说明 (Message) | 触发边界与处理方式 |
| :---: | :---: | :--- | :--- |
| `401` | `401` | "认证凭据无效或会话已过期" | Token 缺失、伪造或 Redis 缓存失效,引导至登录页 |
| `403` | `403` | "非教师身份,禁止访问教师专属工作台" | 学员账号或非授课管理员访问 `/train/me/teacher/**` |
| `403` | `403` | "非学员身份,禁止访问学员个人培训中心" | 教师或管理员账号访问 `/train/me/student/**` |
| `400` | `40041` | "当前登录账号未绑定任何业务档案,请联系管理员" | 存在 `sys_user` 账号,但 `train_teacher/student` 表中未关联对应主体 |
| `403` | `40042` | "您并非该班级或课次的任课教师,无权查看学员信息" | 教师尝试横向探测试图读取无关班级名册 |
+133
View File
@@ -0,0 +1,133 @@
# RailwaySystem 前端多角色视图与交互方案设计 (UI & UX Design)
- **设计版本**: R.2A Design Freeze
- **设计日期**: 2026-09-15
- **核心目标**: 优化统一登录入口与注册策略,确立三角色登录后动态 Landing 重定向机制,设计轻量、高复用度的教师工作台与学员个人中心。
---
## 1. 登录入口与注册策略设计 (Registration Policy)
### 1.1 正式策略参数表
- **自主注册策略 (`SELF_REGISTRATION_POLICY`)**: `DISABLED`
- **登录页注册链接 (`LOGIN_PAGE_REGISTER_LINK`)**: `HIDE` (隐藏)
- **登录页受控提示 (`LOGIN_PAGE_NOTE`)**: `账号由培训管理部门统一创建与授权维护`
### 1.2 登录界面改造交互规范
在 `build_ui/src/views/login.vue` 中:
1. 将局部数据项直接固化为受控策略:
```javascript
// 关闭自主注册链接渲染
register: false,
```
2. 移除原有的 `<router-link :to="'/register'">立即注册</router-link>` 元素。
3. 在登录表单底部密码框与登录按钮之间增加一行浅灰色居中文本提示:
```html
<div class="login-note">
<i class="el-icon-info"></i> 本系统账号由培训管理部门统一创建与授权维护
</div>
```
4. **业务价值**: 彻底消除用户点击“立即注册”后弹出“当前系统没有开启注册功能!”的报错负反馈,向评审专家和用户清晰传递“严密组织管控、受控实名体系”的企业级安全定位。
---
## 2. 登录后动态 Landing 重定向机制 (Landing Strategy)
系统全员使用同一个登录入口 `http://192.168.139.230:8082/login`。用户验证成功颁发 JWT 后,前端路由守卫(`permission.js`)执行如下分流重定向策略:
```
用户输入账号密码
│
▼
POST /login (200 OK)
│
▼
GET /getInfo (拉取 roles 与 permissions)
│
▼
角色判定 (Role Branching)
│
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
拥有 admin 或 train_admin 拥有 train_teacher 拥有 train_student
│ │ │
▼ ▼ ▼
Landing 重定向至: Landing 重定向至: Landing 重定向至:
/index /train/teacher/dashboard /train/student/portal
(系统综合驾驶舱) (教师授课工作台) (学员个人培训中心)
```
- **兜底方案 (Fallback)**: 若用户被赋予复合角色或自定义角色,前端自动跳转至 `asyncRoutes` 生成的动态路由树的第一个叶子节点页面,杜绝 404 或白屏。
---
## 3. 教师端页面架构方案 (Teacher Portal)
### 3.1 核心设计理念:轻量工作台,杜绝全套克隆
- 教师端绝不照搬或复制一套庞大的管理端 CRUD 界面。
- 教师关注的核心目标极其聚焦:**“我今天什么时间在哪个场地有课?来了多少学员?是谁没来?”**。
### 3.2 页面结构与功能布局
教师登录后展示两级导航或扁平化工作台:
1. **教师授课工作台 (`/train/teacher/dashboard`)**:
- **顶部指标卡片**:
- 今日待授课次数量(如:`2 节`)
- 当前任教培训班总数(如:`1 个班`)
- 本周授课累计学时(如:`8 课时`)
- **核心日历/日程看板 (My Timetable)**:
- 复用 Element UI 的 `el-calendar` 或 FullCalendar 组件,以时间轴形式高亮渲染当前教师的授课排课卡片。
- 卡片显示:课程名称(如《钢结构实训》)、实训场地(如实训区 A)、起止时间(08:30 - 10:00)。
- 点击课次卡片:右侧弹出该课次学员即时出勤抽屉,显示打卡名单与迟到/缺勤人员。
2. **任教班级与学员名册 (`/train/teacher/classes`)**:
- 列表展示当前教师授课的培训计划(班级名称、培训周期、计划状态)。
- 点击“查看花名册”:弹出学员表格,展示学员姓名、单位、报到状态与出勤率概览(身份证脱敏为后四位,手机号中间脱敏,保护学员隐私)。
---
## 4. 学员端页面架构方案 (Student Portal)
### 4.1 核心设计理念:个人培训档案,极简清晰
- 学员作为铁路一线职工,需要操作极其简便、关键业务一目了然的个人看板。
- 主页为单页响应式布局,分为四大信息模块:
```
+─────────────────────────────────────────────────────────────────────────────+
| 【模块 1: 我的当前培训状态条】 |
| 当前班级: 铁路综合培训第一期 | 培训周期: 2026-09-15 至 2026-09-20 |
| [立项编制] ────> [✔ 待报到] ────> [已报到] ────> [在训学习] ────> [结训归档] |
| 状态: 已成功报到 (报到时间: 2026-09-15 12:35) | 分配场地: 实训区A |
+─────────────────────────────────────────────────────────────────────────────+
| 【模块 2: 我的今日课程与日程安排】 |
| ┌─────────────────────────┐ ┌─────────────────────────┐ |
| │ 上午 08:30 - 10:00 │ │ 下午 14:00 - 16:30 │ |
| │ 《铁路安全理论》 │ │ 《钢结构实训》 │ |
| │ 地点: 101教室 │ │ 地点: 实训区A │ |
| │ 讲师: 王老师 │ │ 讲师: 王老师 │ |
| └─────────────────────────┘ └─────────────────────────┘ |
+─────────────────────────────────────────────────────────────────────────────+
| 【模块 3: 我的出勤与学时汇算】 │ 【模块 4: 我的综合能力画像】 |
| - 正常出勤: 1 次 │ - 出勤表现: 100 分 |
| - 迟到记录: 1 次 (人工修正延误) │ - 理论考核: 92 分 |
| - 累计学时: 4.5 / 24 课时 │ - 实操水平: 95 分 |
| - 签退状态: 已锁定不可篡改 │ - (ECharts 五维雷达图直观渲染) |
+────────────────────────────────────┴────────────────────────────────────────+
```
### 4.2 组件高度复用
- 模块 1 复用 Element UI 的 `el-steps` 步骤条。
- 模块 2 复用现有的课表时间计算与场地标签。
- 模块 3 复用现有的考勤标签状态着色(正常绿、迟到黄、缺勤红)。
- 模块 4 直接复用管理端已有的 `views/train/portrait/index.vue` 内部封装的 ECharts 雷达渲染引擎,仅入参改为 `/train/me/portrait` 接口数据。
---
## 5. 跨角色访问越权拦截与前端路由守卫
1. **路由强拦截**:
- 若学员尝试手动在浏览器地址栏键入 `/business/teacher` 或 `/business/training-plan`:
- Vue-Router 动态路由根本未注册该 path,直接命中通配路由触发 `404 Not Found`。
2. **接口强隔离**:
- 即使恶意用户通过 DevTools 抓包提取管理端 API 发送请求,后端 Spring Security 校验缺失 `train:teacher:list` 权限,直接返回 `403 Forbidden`。
3. **实现成本与性能评估**:
- 视图层改动完全局限在 `build_ui/src/views/train/teacher/` 与 `build_ui/src/views/train/student/` 两个自服务文件夹内,不影响管理端 18 个已有模块的任何已有逻辑。
@@ -0,0 +1,111 @@
# RailwaySystem 数据库迁移方案设计 (Migration Plan)
- **设计版本**: R.2A Design Freeze
- **设计日期**: 2026-09-15
- **适用数据库**: 国产主库 KingbaseES V009R003C018 (JDBC 依赖 8.6.0, schema: public)
- **核心目标**: 规范 R.2B 阶段所需的数据库变更脚本清单、序号规则、幂等性保障与零风险回滚方案,确保 100% 兼容现有生产业务。
---
## 1. 现有正式 Migration 脚本全量排查
在设计新脚本前,已完成对代码库中所有既有 SQL 脚本的编号与职责排查:
### 1.1 `RuoYi-Vue/sql/system/kingbase/` (系统级通用补丁)
- `001_sys_oper_log_cost_time.sql`: 操作日志表新增 `cost_time` 耗时列 (BIGINT DEFAULT 0)。
- `002_legacy_analytics_permissions.sql`: 学员画像与学习时长按钮权限补齐。
- `003_report_attendance_action_permissions.sql`: 报到确认与课次考勤核心 Action 权限补齐。
- **最新可用起始编号**: `004`。
### 1.2 `RuoYi-Vue/sql/train/v2/` (培训业务领域专用)
- `000_preflight.sql` ~ `010_verify.sql` (覆盖 V2 架构演进全量表调整与数据过渡)。
- **红线警示**: `007_dicts.sql` 严禁在生产或后续环节执行 (**DO NOT EXECUTE**)。
---
## 2. R.2 计划发布的数据库迁移脚本清单 (PROPOSED_MIGRATIONS)
为避免目录混杂,R.2 的 RBAC 迁移脚本统一归入 `RuoYi-Vue/sql/system/kingbase/` 序列:
```
RuoYi-Vue/sql/system/kingbase/
├── 004_rbac_identity_binding.sql # 物理约束与身份唯一性索引
└── 005_training_roles_permissions.sql # 角色预置、菜单补全与权限映射
```
### 2.1 脚本 1: `004_rbac_identity_binding.sql`
- **执行目标**:
1. 幂等确认 `train_teacher` 和 `train_student` 的 `user_id BIGINT` 列。
2. 建立针对非空 `user_id` 的部分唯一索引(Partial Unique Index),保障一个登录账户只能绑定一个业务主体。
3. 增加注释与完整性元数据。
- **DDL 设计核心内容**:
```sql
BEGIN;
-- 1. 确保列存在 (兼容早期分支)
ALTER TABLE train_teacher ADD COLUMN IF NOT EXISTS user_id BIGINT;
ALTER TABLE train_student ADD COLUMN IF NOT EXISTS user_id BIGINT;
-- 2. 建立教师账号唯一索引 (无 DROP,安全幂等,忽略已删除 del_flag='2' 与空值)
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_teacher_user_id
ON train_teacher(user_id)
WHERE user_id IS NOT NULL AND del_flag = '0';
-- 3. 建立学员账号唯一索引 (无 DROP,安全幂等)
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_student_user_id
ON train_student(user_id)
WHERE user_id IS NOT NULL AND del_flag = '0';
COMMENT ON COLUMN train_teacher.user_id IS '关联sys_user.user_id (唯一绑定教师登录账号)';
COMMENT ON COLUMN train_student.user_id IS '关联sys_user.user_id (唯一绑定学员登录账号)';
COMMIT;
```
### 2.2 脚本 2: `005_training_roles_permissions.sql`
- **执行目标**:
1. 内置 ID Collision Guard:预检 `role_id (3,4,5)` 与 `menu_id (2201..2240)`,若已被外部其他业务资源占用则显式 ABORT,严禁覆盖外来记录。
2. 预置 3 个业务角色 (`train_admin`, `train_teacher`, `train_student`) 写入 `sys_role`。
3. 补齐 `sys_menu` 中历史遗漏的基础模块按钮级权限(`train:teacher:*`, `train:student:*`, `train:course:*`, `train:plan:*`, `train:lesson:*` 等)。
4. 将 `system:board:*` 权限标识收敛对齐为 `train:board:*`。
5. 插入 Teacher 工作台菜单与 Student 个人中心菜单节点。
6. 幂等配置 `sys_role_menu` 角色菜单权限映射。
- **DML 设计核心内容**:
```sql
BEGIN;
-- 0. ID 碰撞守卫 (Collision Guard):检测 role_id 与 menu_id 是否有非预期占用,有则 RAISE EXCEPTION
DO $$ ... $$;
-- 1. 预置角色 (使用 WHERE NOT EXISTS 确保无重复)
INSERT INTO sys_role (role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly, status, del_flag, create_by, create_time, remark)
SELECT 3, '培训管理员', 'train_admin', 3, '1', 1, 1, '0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '一期业务全局管理角色'
WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_id = 3 OR role_key = 'train_admin');
-- 2. 补齐基础管理模块按钮权限 (示例:教师管理按钮)
-- 采用 WHERE NOT EXISTS 确保多次执行安全幂等
-- ... (录入 train:teacher:list/add/edit/query 等节点)
-- 3. 统一规范电子班牌命名空间
UPDATE sys_menu SET perms = REPLACE(perms, 'system:board:', 'train:board:')
WHERE perms LIKE 'system:board:%';
-- 4. 建立角色与菜单权限关联 (sys_role_menu)
-- train_admin: 分配全部培训业务管理菜单及按钮
-- train_teacher: 分配教师工作台与专属自服务权限
-- train_student: 分配学员培训中心与专属自服务权限
COMMIT;
```
---
## 3. 生产兼容性与无损回滚设计 (Rollback Safety)
1. **绝对向前兼容 (Zero Breaking Change)**:
- 现有超级管理员 `admin (role_id=1)` 依靠 `*:*:*` 拥有全量通配权限,新增角色与菜单对已有超管管理流程无任何不良影响。
- 所有已有历史数据中 `train_teacher.user_id` 和 `train_student.user_id` 为 NULL 的记录完全保留,部分唯一索引不校验 NULL,历史数据不会产生约束冲突。
2. **零 DDL 锁表风险**:
- KingbaseES / PostgreSQL 架构中,`ADD COLUMN IF NOT EXISTS` 与 `CREATE INDEX ... WHERE ...` 操作毫秒级完成,不锁死数据读写。
3. **独立可执行与验证**:
- 脚本必须具备单向独立执行性,并在脚本末尾集成 `DO $$ ... RAISE NOTICE ... $$;` 自检核验块。
@@ -0,0 +1,85 @@
# RailwaySystem 三角色端到端验收方案设计 (E2E-R2 Acceptance Plan)
- **设计版本**: R.2A Design Freeze
- **设计日期**: 2026-09-15
- **核心目标**: 全面规划未来在 R.2B 实现落地后,用于终审验收的三角色(Admin, Teacher, Student)全链路端到端自动化与人工验收方案。
---
## 1. 验收环境与前置准备
- **部署环境**: 生产环境或预发布环境(KingbaseES V009R003C018 数据库已执行 `004_...sql` 与 `005_...sql`)。
- **统一访问入口**: `http://192.168.139.230:8082/login`。
- **命名空间约定**: 验收专用账号统一带有前缀 `R2_`(如 `R2_admin`, `R2_teacher_wang`, `R2_student_zhang`)。
---
## 2. 五阶段端到端全流程验收矩阵 (Stages A ~ E)
### 2.1 Stage A: 管理员建档与账号分配 (Admin Operations)
- **用例编号**: `E2E-R2-A01` ~ `A04`
- **执行角色**: `train_admin` (培训管理员) 或超级管理员
- **操作步骤**:
1. 登录系统,进入【师资队伍管理】,新增教师 `R2_王老师`,勾选【同时开通登录账号】,设置账号 `r2_teacher`,初始密码 `Railway@2026`。
2. 进入【学员档案管理】,新增学员 `R2_张三` 与 `R2_李四`,分别开通账号 `r2_student_01` 与 `r2_student_02`。
3. 创建培训计划《R2_高速铁路综合运维实训班》,完成课程绑定与批量分班入班。
4. 编排课次并分配 `R2_王老师` 担任授课教师。
- **验收断言**:
- `train_teacher.user_id` 与 `train_student.user_id` 成功写入,非 NULL。
- `sys_user_role` 正确关联 `train_teacher` (role_id=4) 与 `train_student` (role_id=5)。
### 2.2 Stage B: 教师独立工作台与数据隔离 (Teacher Experience)
- **用例编号**: `E2E-R2-B01` ~ `B05`
- **执行角色**: `r2_teacher` (教师身份)
- **操作步骤**:
1. 使用 `r2_teacher` 登录统一登录页。
2. 验证动态 Landing:自动重定向至教师工作台 `/train/teacher/dashboard`。
3. 查验侧边栏:仅包含【教师工作台】、【我的授课日程】、【任教班级学员】,**绝无**任何系统管理、全量计划维护、场地配置等管理菜单。
4. 查验日历看板:仅显示本人被排课的课次,不显示其他教师课次。
5. 查验学员花名册:仅显示自己所带班级的学员名单,且身份证号自动脱敏(后四位)。
6. **安全渗透测试 (IDOR 越权拦截)**:
- 尝试直接访问管理端接口 `GET /train/student/list` -> 预期返回 `403 Forbidden`。
- 尝试访问他人课次详情 `GET /train/timetable/lesson/999` -> 预期返回 `403/404` 拒止。
- **验收判定**: 教师功能闭环完整,菜单受限严格,横向越权拦截率 100%。
### 2.3 Stage C: 学员个人中心与隐私隔离 (Student Experience)
- **用例编号**: `E2E-R2-C01` ~ `C06`
- **执行角色**: `r2_student_01` (学员张三)
- **操作步骤**:
1. 使用 `r2_student_01` 登录系统。
2. 验证动态 Landing:自动重定向至学员个人培训中心 `/train/student/portal`。
3. 查验侧边栏:仅出现【我的培训】、【我的课表】、【我的报到】、【我的考勤打卡】。
4. 查验个人信息:显示张三本人档案,状态为 `待报到 (0)`。
5. 查验课表:仅显示张三所在班级的课程日程。
6. **安全渗透测试 (IDOR 越权拦截)**:
- 尝试在 DevTools 中将查询参数篡改为李四的 ID:`GET /train/student/{lisi_id}` -> 预期返回 `403 Forbidden`。
- 尝试提交带有他人 ID 的打卡请求 -> 预期后端强制校验登录人身份,返回 `403/400` 拒止。
- 查验个人画像与学时:完全只加载张三本人的成长数据。
- **验收判定**: 学员视角清晰,个人隐私高度受保护,无法读取或篡改其他学员信息。
### 2.4 Stage D: 注册入口安全受控策略核验 (Registration Policy)
- **用例编号**: `E2E-R2-D01` ~ `D02`
- **执行角色**: 匿名外部访客
- **操作步骤**:
1. 浏览器打开 `/login` 登录页,肉眼核验页面**无任何“立即注册”链接或按钮**。
2. 核验登录卡片下方是否清晰呈现受控提示:“*账号由培训管理部门统一创建与授权维护*”。
3. 自动化发起网络探测请求 `POST /register`:
- 预期返回状态码 `200`,业务响应 `{"code": 500, "msg": "当前系统没有开启注册功能!"}`。
- **验收判定**: 注册入口彻底隐藏,受控拦截坚固有效。
### 2.5 Stage E: 管理端全流程业务无损回归 (Admin Regression)
- **用例编号**: `E2E-R2-E01` ~ `E30`
- **执行角色**: `admin` / `train_admin`
- **操作步骤**:
- 全量重新执行 E2E-R1 的 30 个标准业务用例(师资、学员、课程、场地、计划、双课次 Soft Warning 确认保存、硬阻断、报到、打卡、签退时间冻结、人工修正迟到、状态联动归档、操作日志)。
- **验收判定**: 30 个核心管理端用例全部 100% 通过,无任何功能损伤或回归缺陷。
---
## 3. 验收通过标准 (Exit Criteria)
1. `TOTAL_R2_CASES = 48`
2. `PASSED_R2_CASES = 48 (100%)`
3. `FAILED_CASES = 0`
4. `IDOR_LEAK_COUNT = 0 (全站零越权数据泄露)`
5. `ADMIN_REGRESSION_PASS_RATE = 100%`
@@ -0,0 +1,164 @@
# RailwaySystem R.2B 落地实现任务拆分指南 (Implementation Task Breakdown)
- **设计版本**: R.2A Design Freeze
- **设计日期**: 2026-09-15
- **核心目标**: 将 R.2B 实现阶段拆解为 10 个粒度适中、依赖严密、支持多工程师并行作业的高效子任务。
---
## 1. 任务依赖关系与并行推进路线图
```mermaid
flowchart TD
subgraph Phase1["阶段一: 数据库与权限基础设施 (前置依赖)"]
B1["RBAC-B1: 身份绑定与约束脚本 (004.sql)"]
B2["RBAC-B2: 角色菜单与权限补全脚本 (005.sql)"]
end
subgraph Phase2["阶段二: 独立并行开发 (无直接阻塞)"]
B3["RBAC-B3: 教师自服务后端 API (/train/me/teacher)"]
B4["RBAC-B4: 学员自服务后端 API (/train/me/student)"]
B5["RBAC-B5: 教师工作台前端视图 (Teacher Portal)"]
B6["RBAC-B6: 学员个人中心前端视图 (Student Portal)"]
B7["RBAC-B7: 登录页注册策略改造 (隐藏入口)"]
B8["RBAC-B8: 班牌权限命名空间对齐 (system->train)"]
end
subgraph Phase3["阶段三: 安全联调与全量验收"]
B9["RBAC-B9: 数据范围与 IDOR 安全渗透测试"]
B10["RBAC-B10: 管理端业务无损全量回归 (E2E-R2)"]
end
B1 --> B3
B1 --> B4
B2 --> B3
B2 --> B4
B2 --> B5
B2 --> B6
B3 --> B5
B4 --> B6
B5 --> B9
B6 --> B9
B7 --> B10
B8 --> B10
B9 --> B10
```
---
## 2. 详细任务卡片清单 (RBAC-B1 ~ RBAC-B10)
### 任务 1: RBAC-B1 - 身份绑定与物理约束脚本 (Identity Binding & DDL) [COMPLETED (Wave 1)]
- **依赖关系**: 无前置依赖,首发任务。
- **当前状态**: 已完成 (脚本编写完成并通过静态契约测试验证,已就绪待运维发布)
- **交付内容**:
1. `RuoYi-Vue/sql/system/kingbase/004_rbac_identity_binding.sql`。
2. 针对 `train_teacher(user_id)` 与 `train_student(user_id)` 建立非空部分唯一索引与约束验证块。
3. `RbacPermissionMigrationContractTest` 验证通过。
- **完成标志**: 脚本编写符合 Kingbase 语法,事务与约束验证完备。
### 任务 2: RBAC-B2 - 角色预置与权限补齐脚本 (Role & Permission Migration) [COMPLETED (Wave 1)]
- **依赖关系**: 依赖 B1 完成。
- **当前状态**: 已完成 (脚本编写完成并通过静态契约测试验证,已就绪待运维发布)
- **交付内容**:
1. `RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql`。
2. 预置 `train_admin` (3), `train_teacher` (4), `train_student` (5) 角色。
3. 补齐 `sys_menu` 中基础管理模块缺失的全部按钮级 `perms` 行 (2201~2240)。
4. 建立角色的初始菜单与功能权限绑定映射及自检验证块。
- **完成标志**: 脚本契约测试 `testMigration005_RoleAndPermissionScriptContract` 验证通过。
### 任务 3: RBAC-B3 - 教师后端自服务体系实现 (Teacher Backend) [COMPLETED (Wave 1)]
- **依赖关系**: 依赖 B1, B2。与 B4、B7、B8 可完全并行。
- **当前状态**: 已完成
- **交付内容**:
1. 控制器 `TrainTeacherMeController.java` (`/train/me/teacher/...`)。
2. 实现获取教师本人画像、任教班级、授课课表、班级学员名册(脱敏)与课次出勤流水。
3. 强制校验教师身份与教学班级/课次归属,杜绝跨教师越权 (IDOR)。
4. `TeacherSelfServiceApiTest` 7 个单测用例全部通过。
- **完成标志**: 接口单元测试通过率 100%,越权拦截测试生效。
### 任务 4: RBAC-B4 - 学员后端自服务体系实现 (Student Backend) [COMPLETED (Wave 1)]
- **依赖关系**: 依赖 B1, B2。与 B3、B7、B8 可完全并行。
- **当前状态**: 已完成
- **交付内容**:
1. 控制器 `TrainStudentMeController.java` (`/train/me/student/...`)。
2. 实现获取学员本人档案、参训班级状态、个人课表、报到凭据、个人考勤、累计学时与多维画像。
3. 强制校验本人归属,杜绝任何外部主键传入。
4. `StudentSelfServiceApiTest` 6 个单测用例全部通过。
- **完成标志**: 接口单元测试通过率 100%,敏感信息脱敏合格。
### 任务 5: RBAC-B5 - 教师工作台前端视图开发 (Teacher Frontend) [COMPLETED (Wave 2)]
- **依赖关系**: 依赖 B2 菜单路由与 B3 后端自服务 API。
- **当前状态**: 已完成
- **交付内容**:
1. `build_ui/src/api/train/meTeacher.js`:封装 `/train/me/teacher/**` 全套轻量查询 API。
2. `build_ui/src/views/train/me/teacher/index.vue`:教师自服务工作台,包含个人主档、任教班级列表、授课日历课表、脱敏花名册抽屉与课次考勤查看抽屉,支持 403 业务未绑定友好告警与 Empty State。
3. `build_ui/src/views/index.vue`:实现角色智能 Landing,教师身份登录自适应直达教师工作台。
- **完成标志**: 教师工作台组件与路由完备,构建与静态校验通过。
### 任务 6: RBAC-B6 - 学员个人培训中心前端开发 (Student Frontend) [COMPLETED (Wave 2)]
- **依赖关系**: 依赖 B2 菜单路由与 B4 后端自服务 API。
- **当前状态**: 已完成
- **交付内容**:
1. `build_ui/src/api/train/meStudent.js`:封装 `/train/me/student/**` 个人自服务 API(无任何外部 studentId 入参)。
2. `build_ui/src/views/train/me/student/index.vue`:学员专属培训中心,集成个人档案、参训流转状态条、所在班级日程课表、开班报到回执单卡片、规范考勤流水打卡记录、累计学时统计与 ECharts 多维能力成长雷达图。
3. `build_ui/src/views/index.vue`:学员身份登录自适应直达学员个人中心。
- **完成标志**: 学员工作台各模块渲染与空状态处理严密,构建通过。
### 任务 7: RBAC-B7 - 登录界面注册入口安全改造 (Login UI Policy) [COMPLETED (Wave 2)]
- **依赖关系**: 独立前端改造。
- **当前状态**: 已完成
- **交付内容**:
1. 修改 `build_ui/src/views/login.vue`,系统标题更新为正式对外名称“SYFJGYD学员管理系统”。
2. 固化 `register: false`,彻底移除“立即注册”路由跳转链接。
3. 增加表单底部居中受控提示:“*账号由培训管理部门统一创建与授权维护*”。
- **完成标志**: 登录界面无注册外漏,提示文案清晰企业化。
### 任务 8: RBAC-B8 - 班牌权限命名空间统一对齐 (Namespace Alignment) [COMPLETED (Wave 1 & 2)]
- **依赖关系**: 前后端对齐。
- **当前状态**: 已完成
- **交付内容**:
- 后端:`TrainElectronicBoardController.java` 中的权限注解全部收敛统一为 `train:board:*` [COMPLETED (Wave 1)]
- 前端:`build_ui/src/views/train/board/index.vue` 中的 6 处 `v-hasPermi` 全部统一为 `['train:board:*']`,彻底消除 `system:board:*` 遗留 [COMPLETED (Wave 2)]
- 迁移脚本:`005_training_roles_permissions.sql` 将 2239/2240 提升为二级路由菜单(`menu_type='C'`),支持若依侧边栏独立挂载与静态契约验证。
- **完成标志**: 前后端权限命名空间与菜单路由 100% 对齐。
### 任务 9: RBAC-B9 - 数据范围与 IDOR 安全渗透自动化测试 (IDOR Gate) [PARTIALLY COMPLETED (Wave 1 后端 IDOR 已通过)]
- **依赖关系**: 依赖 B3、B4、B5、B6 联调完成。
- **当前状态**:
- 后端 IDOR 单测套件已完成并验证:
- `RbacIdorSecurityTest`: 模拟跨教师越权访问他人学员名册与考勤,跨学员越权探测等,全部被严密拒止。
- `TrainingIdentityServiceTest`: 验证身份唯一性与互斥性。
- 端到端渗透验收:保留至三角色前端集成后进行。
- **完成标志**: 后端自动化测试中 `IDOR_LEAK_COUNT = 0`。
### 任务 10: RBAC-B10 - 管理端业务全流程无损回归测试 (Admin Regression) [PENDING (Wave 3)]
- **依赖关系**: 汇总任务,依赖全部前置任务。
- **任务目标**:
1. 执行 E2E-R2 验收方案全流程。
2. 执行管理端原有 30 个生产核心用例回归测试。
- **完成标志**: 管理端核心业务 100% PASS,实现三角色完整 RBAC 闭环。
---
## 3. 并行施工调度建议与 Wave 进展
- **系统对外展示名称规范**:
- 正式对外展示名称: **SYFJGYD学员管理系统**
- 内部工程名、Git 仓库、Java 包名、数据库名仍严格保持 `RailwaySystem`,禁止全局非必要替换。
- **Wave 1 (已完成并 Checkpoint)**:
- 后端基础设施与迁移脚本准备: `RBAC-B1`, `RBAC-B2` (完成)
- 核心身份服务与自服务后端: `RBAC-B3`, `RBAC-B4` (完成)
- 班牌后端权限收敛: `RBAC-B8 (后端)` (完成)
- 安全与契约单元测试: `RBAC-B9 (后端)` (完成,194/194 PASS)
- **Wave 2 (已完成待评审)**:
- 教师工作台前端视图开发: `RBAC-B5` (完成)
- 学员个人培训中心前端开发: `RBAC-B6` (完成)
- 登录入口安全策略改造: `RBAC-B7` (完成)
- 班牌前端权限指令对齐与菜单提升: `RBAC-B8 (前端 & 005.sql)` (完成)
- 生产构建与代码规范校验: `npm run build:prod` & ESLint 100% 通过
- 后端回归单测验证: 194 / 194 PASS
- **Wave 3 (后续推进)**:
- 三角色端到端联调与 E2E-R2 回归测试: `RBAC-B10`
@@ -0,0 +1,21 @@
# SYFJGYD学员管理系统 R.2B Wave 3 E2E 验收证据包
- **验收单号 (RUN_ID)**: `RBAC_E2E_R2_20260916_102500`
- **代码候选基准 (CANDIDATE_COMMIT)**: `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`
- **系统对外正式名称 (SYSTEM_DISPLAY_NAME)**: `SYFJGYD学员管理系统`
- **测试环境 (ENVIRONMENT)**: `ISOLATED QA` (数据库 127.0.0.1:4321/training, Redis 127.0.0.1:6379, 后端 8090, 前端 8080)
- **迁移验证复用基准 (MIGRATION_VALIDATION_RUN)**: `RBAC_E2E_R2_20260916_100800` (`MIGRATION_VALIDATION_REUSED = YES`)
- **端到端验收状态 (E2E_R2_STATUS)**: `PASS`
- **高清截图总数 (SCREENSHOT_COUNT)**: `21` (存放于 `screenshots/`)
## 交付文档目录索引 (>=10 项完整清单)
1. [README.md](README.md): 交付包综述与验收元数据索引
2. [report.md](report.md): R.2 三角色认证、功能权限与数据权限 E2E 综合验收报告
3. [report.html](report.html): 离线可视化交互式交付报告 (集成 21 张全链路无损截图画廊)
4. [run-data.json](run-data.json): 42 项机器可读验收指标字典 (JSON 标准格式)
5. [role-matrix.md](role-matrix.md): 三角色 (Admin / Teacher / Student) 及特例主体功能、路由与数据矩阵
6. [idor-results.md](idor-results.md): 横向越权 (IDOR) 与垂直越权全链路防御渗透记录
7. [account-binding-results.md](account-binding-results.md): 管理员开户与业务身份绑定正负向及安全边界测试记录
8. [migration-validation.md](migration-validation.md): 004/005 增量数据库迁移脚本幂等性验证记录
9. [network-summary.md](network-summary.md): 全流程 HTTP 网络请求监控与异常排查表 (500 异常与预期 403 拦截)
10. [console-summary.md](console-summary.md): 浏览器前端控制台日志监控与阻断性错误排查汇总
@@ -0,0 +1,31 @@
# SYFJGYD学员管理系统 管理端正式账号开通与业务身份绑定验证
- **验收单号 (RUN_ID)**: `RBAC_E2E_R2_20260916_102500`
- **代码候选基准 (CANDIDATE_COMMIT)**: `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`
- **系统对外正式名称 (SYSTEM_DISPLAY_NAME)**: `SYFJGYD学员管理系统`
- **测试环境 (ENVIRONMENT)**: `ISOLATED QA`
## 一、测试账号与角色档案对应清单
1. `admin` (userId=1): 超级管理员,系统全局最高权限
2. `qa_tea_a` (userId=100): 教师 A,分配 `train_teacher` 角色,绑定师资档案张建国 (`T_DOM_A`)
3. `qa_tea_b` (userId=101): 教师 B,分配 `train_teacher` 角色,绑定师资档案王守诚 (`T_DOM_B`)
4. `qa_stu_a` (userId=102): 学员 A,分配 `train_student` 角色,绑定学员主档李明 (`S_DOM_A`)
5. `qa_stu_b` (userId=103): 学员 B,分配 `train_student` 角色,绑定学员主档赵强 (`S_DOM_B`)
6. `qa_no_id_tea` (userId=104): 未绑定档案教师,分配 `train_teacher` 角色,业务主档未关联
7. `qa_no_id_stu` (userId=105): 未绑定档案学员,分配 `train_student` 角色,业务主档未关联
8. `qa_admin_tea` (userId=106): 复合身份账号,分配 `admin` 与 `train_teacher` 双重角色,绑定师资档案张建国
## 二、功能与安全边界用例验证明细
| 序号 | 验证用例 | 操作说明 | 预期结果 | 实际执行结果 | 判定 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 1 | **Admin 正向绑定 Teacher** | 管理员调用 `PUT /train/teacher/{tId}/user-binding` 绑定合法教师账号 | 状态码 200,教师档案记录 `user_id` | 成功建立绑定,数据落库 | **PASS** |
| 2 | **Admin 正向绑定 Student** | 管理员调用 `PUT /train/student/{sId}/user-binding` 绑定合法学员账号 | 状态码 200,学员档案记录 `user_id` | 成功建立绑定,数据落库 | **PASS** |
| 3 | **错误角色绑定拒止 (Teacher 错配)** | 将学员角色账号 (`qa_stu_a`) 绑定至教师档案 | 拒绝绑定,返回角色不匹配提示 | 返回 400 校验异常,拒止成功 | **DENIED (PASS)** |
| 4 | **错误角色绑定拒止 (Student 错配)** | 将教师角色账号 (`qa_tea_a`) 绑定至学员档案 | 拒绝绑定,返回角色不匹配提示 | 返回 400 校验异常,拒止成功 | **DENIED (PASS)** |
| 5 | **防静默覆盖 (Silent Overwrite)** | 对已绑定系统用户的教师/学员档案直接发起新绑定请求 | 拒绝静默覆盖,要求必须显式先解绑 | 返回 400 校验错误,防止数据覆盖 | **DENIED (PASS)** |
| 6 | **Teacher XOR Student 业务互斥** | 尝试将已被绑定为教师的 `qa_tea_a` 账号再绑定至学员档案 | 拒绝多重身份冲突,维持身份互斥 | 返回 400 拦截,业务互斥生效 | **PASS** |
| 7 | **解绑不删除系统用户** | 管理员调用 `DELETE /train/teacher/{tId}/user-binding` 解除绑定 | 仅将业务档案 `user_id` 置为 NULL,系统用户账号仍然保留 | 档案解绑成功,`sys_user` 完好保留 | **PASS** |
| 8 | **运行时绑定权限拦截 (Admin)** | 管理员身份调用绑定与解绑接口 | 正常执行 | 具备 2241~2244 权限,允许操作 | **PASS** |
| 9 | **运行时绑定权限拦截 (Teacher)** | 教师账号越权调用 `PUT /train/teacher/{tId}/user-binding` | 权限拦截拒止 | Framework 403 拦截,无权操作 | **DENIED (PASS)** |
| 10 | **运行时绑定权限拦截 (Student)** | 学员账号越权调用 `PUT /train/student/{sId}/user-binding` | 权限拦截拒止 | Framework 403 拦截,无权操作 | **DENIED (PASS)** |
@@ -0,0 +1,15 @@
# SYFJGYD学员管理系统 浏览器前端控制台日志排查汇总
- **验收单号 (RUN_ID)**: `RBAC_E2E_R2_20260916_102500`
- **代码候选基准 (CANDIDATE_COMMIT)**: `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`
- **系统对外正式名称 (SYSTEM_DISPLAY_NAME)**: `SYFJGYD学员管理系统`
- **测试环境 (ENVIRONMENT)**: `ISOLATED QA`
## 控制台质量指标
- **阻断性控制台错误 (BLOCKING_CONSOLE_ERROR_COUNT)**: `0`
- **未捕获的 JavaScript 异常 (Uncaught Exceptions)**: `0`
- **Vue 渲染运行时崩溃 (Vue Runtime Errors)**: `0`
- **静态资源加载 404 (Asset 404)**: `0`
## 控制台日志审计结论
全链路 21 个核心页面在 Playwright 高清渲染与截图采集过程中,所有 Vue 组件初始化、Element UI 渲染、路由导航守卫均无阻断性错误,页面 Title 统一展示为 `SYFJGYD学员管理系统`,系统运行表现平稳。
@@ -0,0 +1,24 @@
# SYFJGYD学员管理系统 RBAC 横向越权 (IDOR) 与垂直越权测试结果
- **验收单号 (RUN_ID)**: `RBAC_E2E_R2_20260916_102500`
- **代码候选基准 (CANDIDATE_COMMIT)**: `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`
- **系统对外正式名称 (SYSTEM_DISPLAY_NAME)**: `SYFJGYD学员管理系统`
- **测试环境 (ENVIRONMENT)**: `ISOLATED QA`
- **框架权限拦截语义说明**: 若依 (RuoYi) 框架标准权限校验拦截遵循 `Framework authorization response semantics`,即在网关/MVC 统一拦截时返回 **HTTP 200** 状态码,响应体携带 **`{"code": 403, "msg": "没有权限,请联系管理员授权"}`**。以下测试结果均按实际返回严格记录。
## 越权测试用例执行明细表
| 序号 | 越权类型 | 攻击主体 | 目标资源 / 越权操作 | HTTP Status | Body Code | 拦截响应语义说明 | 最终判定 |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| 1 | **教师跨域班级 IDOR** | Teacher A (`qa_tea_a`) | 越权查询 Domain B 班级名册 (`PLAN_DOM_B`) | `HTTP 200` | `403` | Framework authorization response semantics (非本人任教计划,数据权限拦截) | **DENIED (PASS)** |
| 2 | **教师跨域课次 IDOR** | Teacher A (`qa_tea_a`) | 越权对 Domain B 课次 (`302001`) 发起考勤录入 | `HTTP 200` | `403` | Framework authorization response semantics (非本人授课课次,禁止打卡) | **DENIED (PASS)** |
| 3 | **教师垂直越权** | Teacher A (`qa_tea_a`) | 越权调用系统用户管理端点 (`GET /system/user/list`) | `HTTP 200` | `403` | Framework authorization response semantics (缺少 `system:user:list` 权限) | **DENIED (PASS)** |
| 4 | **教师垂直越权** | Teacher A (`qa_tea_a`) | 越权调用全局师资档案接口 (`GET /train/teachers`) | `HTTP 200` | `403` | Framework authorization response semantics (缺少全局师资查询权限) | **DENIED (PASS)** |
| 5 | **学员跨人数据 IDOR** | Student A (`qa_stu_a`) | 篡改参数获取 Student B 参训记录 (`?studentId=S_DOM_B`) | `HTTP 200` | `200` | 自服务安全机制强制使用当前 Token 绑定的 `student_id`,篡改参数被强制忽略,仅返回本人数据 | **DENIED (PASS)** |
| 6 | **学员跨人凭据 IDOR** | Student A (`qa_stu_a`) | 越权查询 Student B 报到凭据 (`/train/me/student/checkin-voucher/S_DOM_B`) | `HTTP 200` | `403` | Framework authorization response semantics (非本人凭证,阻断访问) | **DENIED (PASS)** |
| 7 | **学员垂直越权** | Student A (`qa_stu_a`) | 越权调用系统用户管理端点 (`GET /system/user/list`) | `HTTP 200` | `403` | Framework authorization response semantics (缺少系统管理员角色与权限) | **DENIED (PASS)** |
| 8 | **学员垂直越权** | Student A (`qa_stu_a`) | 越权调用全局学员主档接口 (`GET /train/students`) | `HTTP 200` | `403` | Framework authorization response semantics (缺少全局学员管理权限) | **DENIED (PASS)** |
## 越权防御汇总
- **越权数据泄露数 (IDOR_LEAK_COUNT)**: `0`
- **防御结论**: 横向越权 (IDOR) 与垂直越权防御 100% 生效,严格遵循若依权限框架拦截语义规范。
@@ -0,0 +1,27 @@
# SYFJGYD学员管理系统 数据库增量迁移验证记录
- **验收单号 (RUN_ID)**: `RBAC_E2E_R2_20260916_102500`
- **代码候选基准 (CANDIDATE_COMMIT)**: `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`
- **系统对外正式名称 (SYSTEM_DISPLAY_NAME)**: `SYFJGYD学员管理系统`
- **测试环境 (ENVIRONMENT)**: `ISOLATED QA` (Kingbase 127.0.0.1:4321/training)
- **迁移验证基准执行 (MIGRATION_VALIDATION_RUN)**: `RBAC_E2E_R2_20260916_100800`
- **迁移验证复用标识 (MIGRATION_VALIDATION_REUSED)**: `YES`
- **迁移证据真实性声明 (MIGRATION_EVIDENCE_TRUTHFUL)**: `YES`
## 验证结论与幂等性复核
在基准执行 `RBAC_E2E_R2_20260916_100800` 中,004 与 005 迁移脚本在隔离 QA 数据库中已完成连续两次无报错执行验证:
1. `004_rbac_identity_binding.sql`:
- `004_FIRST_RUN = PASS`
- `004_SECOND_RUN = PASS` (验证 DDL 幂等性,包括 `IF NOT EXISTS`、唯一索引与外键防御)
2. `005_training_roles_permissions.sql`:
- `005_FIRST_RUN = PASS`
- `005_SECOND_RUN = PASS` (验证菜单、角色及权限冲突防护 `ON CONFLICT DO NOTHING` 幂等性)
## 真实性与变更边界审计
本次验收单号 `RBAC_E2E_R2_20260916_102500` 未重新执行 004/005 迁移,严谨真实复用基准执行成果。
原因说明:在 `RBAC_E2E_R2_20260916_100800` 完成后,代码仓库仅发生了一次纯前端系统对外显示名称与文档提交(Commit `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`),`004_rbac_identity_binding.sql`、`005_training_roles_permissions.sql`、数据库表结构及后端 Java 业务代码零变动。
## 生产安全确认
- `004_EXECUTED_PRODUCTION = NO`
- `005_EXECUTED_PRODUCTION = NO`
- `PRODUCTION_DB_MODIFIED = NO`
@@ -0,0 +1,24 @@
# SYFJGYD学员管理系统 HTTP 网络请求监控汇总
- **验收单号 (RUN_ID)**: `RBAC_E2E_R2_20260916_102500`
- **代码候选基准 (CANDIDATE_COMMIT)**: `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`
- **系统对外正式名称 (SYSTEM_DISPLAY_NAME)**: `SYFJGYD学员管理系统`
- **测试环境 (ENVIRONMENT)**: `ISOLATED QA`
## 核心网络质量指标
- **非预期服务器异常 (UNEXPECTED_HTTP500_COUNT)**: `0`
- **网络中断 / 超时请求数**: `0`
- **网关契约失配数**: `0`
## 预期安全拦截分类统计 (Expected Authorization Rejections)
以下请求均为安全测试用例主动作出的越权渗透或非法参数试探,服务端均按既定安全策略予以受控拒绝,**不属于系统故障**:
1. **教师跨域班级 IDOR 拦截**: 1 次 (返回 code 403,无权限查看)
2. **教师跨域考勤 IDOR 拦截**: 1 次 (返回 code 403,无权限打卡)
3. **教师访问管理员端点拦截**: 2 次 (返回 code 403,权限不足)
4. **学员访问管理员端点拦截**: 2 次 (返回 code 403,权限不足)
5. **学员跨人凭据访问拦截**: 1 次 (返回 code 403,凭据归属不符)
6. **无业务身份自服务拦截**: 2 次 (返回受控 403,提示绑定业务身份)
7. **教师/学员越权调用绑定接口**: 2 次 (返回 code 403,无 2241~2244 权限)
8. **角色错配与业务互斥校验拦截**: 3 次 (返回 code 400,业务规则防御)
全部网络请求与安全防御完全符合契约设计规范。
@@ -0,0 +1,233 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>SYFJGYD学员管理系统 - R.2 三角色认证、功能权限与数据权限 E2E 验收报告</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 30px; background: #f5f7fa; color: #2c3e50; line-height: 1.6; }
.card { background: #fff; border-radius: 8px; padding: 25px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.08); margin-bottom: 25px; }
h1, h2, h3 { color: #1f2f3d; }
.badge-pass { display: inline-block; padding: 4px 12px; border-radius: 4px; font-weight: bold; background: #67c23a; color: #fff; }
table { width: 100%; border-collapse: collapse; margin-top: 15px; margin-bottom: 15px; }
th, td { border: 1px solid #ebeef5; padding: 10px 15px; text-align: left; font-size: 14px; }
th { background: #fafafa; font-weight: bold; }
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; margin-top: 15px; }
.gallery-item { border: 1px solid #ebeef5; border-radius: 6px; padding: 12px; background: #fafafa; }
.gallery-title { font-weight: bold; font-size: 14px; margin-bottom: 8px; color: #303133; }
.gallery-item img { width: 100%; height: auto; border-radius: 4px; border: 1px solid #dcdfe6; transition: transform 0.2s; }
.gallery-item img:hover { transform: scale(1.02); }
.gallery-desc { font-size: 12px; color: #909399; margin-top: 6px; }
code { background: #f0f2f5; padding: 2px 6px; border-radius: 4px; font-family: monospace; }
</style>
</head>
<body>
<div class="card">
<h1>SYFJGYD学员管理系统 · R.2 三角色认证、功能权限与数据权限 E2E 验收交付报告</h1>
<p>
<b>验收单号 (RUN_ID):</b> <code>RBAC_E2E_R2_20260916_102500</code> &nbsp;|&nbsp;
<b>代码候选基线:</b> <code>81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3</code> &nbsp;|&nbsp;
<b>环境:</b> <code>ISOLATED QA</code> &nbsp;|&nbsp;
<span class="badge-pass">E2E_R2_STATUS: PASS</span>
</p>
<p>
<b>测试数据库:</b> Kingbase 隔离 QA 环境 (127.0.0.1:4321/training) &nbsp;|&nbsp;
<b>迁移复用基准:</b> <code>RBAC_E2E_R2_20260916_100800</code> (004/005 双次幂等通过) &nbsp;|&nbsp;
<b>全链路无损截图:</b> 21 张就绪
</p>
</div>
<div class="card">
<h2>核心检查项与断言结论</h2>
<table>
<tr><th>分类</th><th>测试项</th><th>断言结论</th><th>说明</th></tr>
<tr><td>环境隔离</td><td>生产数据库与服务隔离保护</td><td>PASS (TEST_DB_ISOLATED=YES)</td><td>生产库 192.168.139.230 与生产端口 8082 零触碰</td></tr>
<tr><td>迁移验证</td><td>004/005 增量迁移脚本幂等性</td><td>PASS (MIGRATION_VALIDATION_REUSED=YES)</td><td>复用基准运行成果,004/005 连续执行两次无报错</td></tr>
<tr><td>账号开通</td><td>管理端正式开户与角色分配</td><td>PASS (ADMIN_CAN_CREATE_SYS_USER=YES)</td><td>开通 7 个专用测试账号,精准分配业务角色</td></tr>
<tr><td>账号绑定</td><td>管理端业务档案与用户绑定</td><td>PASS (ADMIN_ACCOUNT_BINDING=PASS)</td><td>正向绑定建立正常,解绑不删除系统用户</td></tr>
<tr><td>安全边界</td><td>Teacher XOR Student 互斥保护</td><td>PASS (TEACHER_STUDENT_MUTEX_RESULT=PASS)</td><td>防静默覆盖、角色错配拒止、业务互斥拦截</td></tr>
<tr><td>越权防护</td><td>IDOR 横向与垂直越权渗透防御</td><td>PASS (IDOR_LEAK_COUNT=0)</td><td>严格遵循若依框架权限拦截语义,越权请求 100% 阻断</td></tr>
<tr><td>教师工作台</td><td>Own Data 数据范围精确控制</td><td>PASS (TEACHER_OWN_DATA=PASS)</td><td>仅见本人任教计划、课表、学员及考勤打卡</td></tr>
<tr><td>学员中心</td><td>学员业务闭环与数据安全</td><td>PASS (STUDENT_OWN_DATA=PASS)</td><td>仅见本人报到凭证、参训记录、课表及画像</td></tr>
<tr><td>特例主体</td><td>未绑定账号与复合角色支持</td><td>PASS</td><td>无身份账号受控提示,管理员+教师双角色优先驾驶舱</td></tr>
<tr><td>管理回归</td><td>核心业务 13 大模块综合回归</td><td>PASS (ADMIN_REGRESSION=PASS)</td><td>排课预检确认、报到 0->1、考勤幂等性全部正常</td></tr>
</table>
</div>
<div class="card">
<h2>全链路 21 张无损高清截图画廊 (SCREENSHOT_COUNT = 21)</h2>
<div class="gallery-grid">
<div class="gallery-item">
<div class="gallery-title">01. 管理员首页与系统看板</div>
<a href="screenshots/01-admin-home.png" target="_blank">
<img src="screenshots/01-admin-home.png" alt="01. 管理员首页与系统看板" loading="lazy" />
</a>
<div class="gallery-desc">管理员登录后首页及统一系统显示名称 (<code>screenshots/01-admin-home.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">02. 管理端教师账号绑定</div>
<a href="screenshots/02-admin-teacher-bind.png" target="_blank">
<img src="screenshots/02-admin-teacher-bind.png" alt="02. 管理端教师账号绑定" loading="lazy" />
</a>
<div class="gallery-desc">管理员为教师业务档案绑定系统用户 (<code>screenshots/02-admin-teacher-bind.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">03. 管理端学员账号绑定</div>
<a href="screenshots/03-admin-student-bind.png" target="_blank">
<img src="screenshots/03-admin-student-bind.png" alt="03. 管理端学员账号绑定" loading="lazy" />
</a>
<div class="gallery-desc">管理员为学员业务档案绑定系统用户 (<code>screenshots/03-admin-student-bind.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">04. 教师 A 工作台看板</div>
<a href="screenshots/04-teacher-a-dashboard.png" target="_blank">
<img src="screenshots/04-teacher-a-dashboard.png" alt="04. 教师 A 工作台看板" loading="lazy" />
</a>
<div class="gallery-desc">任课教师 A 登录后工作台首页 (<code>screenshots/04-teacher-a-dashboard.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">05. 教师 A 本人任教计划</div>
<a href="screenshots/05-teacher-a-plans.png" target="_blank">
<img src="screenshots/05-teacher-a-plans.png" alt="05. 教师 A 本人任教计划" loading="lazy" />
</a>
<div class="gallery-desc">教师 A 仅能查看本人所属授课计划 (<code>screenshots/05-teacher-a-plans.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">06. 教师 A 本人授课课表</div>
<a href="screenshots/06-teacher-a-timetable.png" target="_blank">
<img src="screenshots/06-teacher-a-timetable.png" alt="06. 教师 A 本人授课课表" loading="lazy" />
</a>
<div class="gallery-desc">教师 A 仅能查看本人所授课次安排 (<code>screenshots/06-teacher-a-timetable.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">07. 教师 A 本人班级学员</div>
<a href="screenshots/07-teacher-a-students.png" target="_blank">
<img src="screenshots/07-teacher-a-students.png" alt="07. 教师 A 本人班级学员" loading="lazy" />
</a>
<div class="gallery-desc">教师 A 仅能查看本人任教班级花名册 (<code>screenshots/07-teacher-a-students.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">08. 教师跨班 IDOR 拦截</div>
<a href="screenshots/08-teacher-cross-plan-denied.png" target="_blank">
<img src="screenshots/08-teacher-cross-plan-denied.png" alt="08. 教师跨班 IDOR 拦截" loading="lazy" />
</a>
<div class="gallery-desc">教师 A 跨域访问 Domain B 班级被受控拦截 (<code>screenshots/08-teacher-cross-plan-denied.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">09. 教师跨课次考勤拦截</div>
<a href="screenshots/09-teacher-cross-lesson-denied.png" target="_blank">
<img src="screenshots/09-teacher-cross-lesson-denied.png" alt="09. 教师跨课次考勤拦截" loading="lazy" />
</a>
<div class="gallery-desc">教师 A 跨域录入 Domain B 课次考勤被受控拦截 (<code>screenshots/09-teacher-cross-lesson-denied.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">10. 教师 B 域数据对称验证</div>
<a href="screenshots/10-teacher-b-domain.png" target="_blank">
<img src="screenshots/10-teacher-b-domain.png" alt="10. 教师 B 域数据对称验证" loading="lazy" />
</a>
<div class="gallery-desc">教师 B 仅可查看 Domain B 对应授课数据 (<code>screenshots/10-teacher-b-domain.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">11. 学员 A 个人培训中心</div>
<a href="screenshots/11-student-a-dashboard.png" target="_blank">
<img src="screenshots/11-student-a-dashboard.png" alt="11. 学员 A 个人培训中心" loading="lazy" />
</a>
<div class="gallery-desc">参训学员 A 登录后个人中心首页 (<code>screenshots/11-student-a-dashboard.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">12. 学员 A 本人参训记录</div>
<a href="screenshots/12-student-a-enrollments.png" target="_blank">
<img src="screenshots/12-student-a-enrollments.png" alt="12. 学员 A 本人参训记录" loading="lazy" />
</a>
<div class="gallery-desc">学员 A 仅能查看本人参训报名档案 (<code>screenshots/12-student-a-enrollments.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">13. 学员 A 本人个人课表</div>
<a href="screenshots/13-student-a-timetable.png" target="_blank">
<img src="screenshots/13-student-a-timetable.png" alt="13. 学员 A 本人个人课表" loading="lazy" />
</a>
<div class="gallery-desc">学员 A 仅能查看本人参训排课课表 (<code>screenshots/13-student-a-timetable.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">14. 学员 A 本人报到凭证</div>
<a href="screenshots/14-student-a-report.png" target="_blank">
<img src="screenshots/14-student-a-report.png" alt="14. 学员 A 本人报到凭证" loading="lazy" />
</a>
<div class="gallery-desc">学员 A 仅能查看本人报到凭据与状态 (<code>screenshots/14-student-a-report.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">15. 学员 A 本人打卡明细</div>
<a href="screenshots/15-student-a-attendance.png" target="_blank">
<img src="screenshots/15-student-a-attendance.png" alt="15. 学员 A 本人打卡明细" loading="lazy" />
</a>
<div class="gallery-desc">学员 A 仅能查看本人考勤打卡流水与画像 (<code>screenshots/15-student-a-attendance.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">16. 学员越权管理接口拦截</div>
<a href="screenshots/16-student-admin-api-denied.png" target="_blank">
<img src="screenshots/16-student-admin-api-denied.png" alt="16. 学员越权管理接口拦截" loading="lazy" />
</a>
<div class="gallery-desc">学员 A 越权调用管理端接口被受控拦截 (<code>screenshots/16-student-admin-api-denied.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">17. 学员 B 域数据对称验证</div>
<a href="screenshots/17-student-b-domain.png" target="_blank">
<img src="screenshots/17-student-b-domain.png" alt="17. 学员 B 域数据对称验证" loading="lazy" />
</a>
<div class="gallery-desc">学员 B 仅能查看 Domain B 本人数据 (<code>screenshots/17-student-b-domain.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">18. 无业务身份账号受控提示</div>
<a href="screenshots/18-no-identity-denied.png" target="_blank">
<img src="screenshots/18-no-identity-denied.png" alt="18. 无业务身份账号受控提示" loading="lazy" />
</a>
<div class="gallery-desc">未绑定业务主档账号访问自服务返回友好拒止 (<code>screenshots/18-no-identity-denied.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">19. 管理员+教师复合身份</div>
<a href="screenshots/19-admin-teacher-landing.png" target="_blank">
<img src="screenshots/19-admin-teacher-landing.png" alt="19. 管理员+教师复合身份" loading="lazy" />
</a>
<div class="gallery-desc">复合角色默认进入管理端并可使用工作台 (<code>screenshots/19-admin-teacher-landing.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">20. 教师学员业务互斥校验</div>
<a href="screenshots/20-teacher-student-mutex.png" target="_blank">
<img src="screenshots/20-teacher-student-mutex.png" alt="20. 教师学员业务互斥校验" loading="lazy" />
</a>
<div class="gallery-desc">同一账号禁止同时绑定教师和学员 (<code>screenshots/20-teacher-student-mutex.png</code>)</div>
</div>
<div class="gallery-item">
<div class="gallery-title">21. 管理端核心业务综合回归</div>
<a href="screenshots/21-admin-regression.png" target="_blank">
<img src="screenshots/21-admin-regression.png" alt="21. 管理端核心业务综合回归" loading="lazy" />
</a>
<div class="gallery-desc">排课预检确认、报到、考勤等核心流程回归 (<code>screenshots/21-admin-regression.png</code>)</div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,46 @@
# R.2 三角色认证、功能权限与数据权限 E2E 综合验收报告
## 一、验收运行总览
- **验收单号 (RUN_ID)**: `RBAC_E2E_R2_20260916_102500`
- **代码候选基准 (CANDIDATE_COMMIT)**: `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`
- **系统对外正式名称 (SYSTEM_DISPLAY_NAME)**: `SYFJGYD学员管理系统`
- **测试环境 (ENVIRONMENT)**: `ISOLATED QA` (数据库 127.0.0.1:4321, Redis 127.0.0.1:6379, 后端 8090, 前端 8080)
- **后端单测与契约测试**: `BACKEND_TEST_TOTAL = 205`,0 failures, 0 errors
- **构建状态**: Maven `BUILD SUCCESS`,Frontend `BUILD SUCCESS`,ESLint 0 errors / 0 warnings
- **生产隔离安全**: `004_EXECUTED_PRODUCTION = NO`, `005_EXECUTED_PRODUCTION = NO`, `PRODUCTION_DB_MODIFIED = NO`
- **综合验收结论**: `E2E_R2_STATUS = PASS`
## 二、核心验证阶段与断言矩阵
1. **增量迁移验证 (复用)**: 复用基准 `RBAC_E2E_R2_20260916_100800` 成果,004 与 005 连续两次幂等执行全部 PASS。
2. **账号开通与角色分配**: 管理员调用 `POST /system/user` 成功创建 7 个独立测试账号并分配合规角色。
3. **账号绑定与安全边界**: 教师/学员绑定成功;角色错配拒止;防静默覆盖生效;Teacher XOR Student 互斥成立;解绑操作不删除系统用户;Teacher/Student 越权调用绑定接口被 403 拦截。
4. **三角色登录与菜单隔离**: Admin、Teacher A/B、Student A/B 登录与 Token 发放完全正常。Teacher/Student 动态路由完全隔离,无任何管理端与全局档案菜单。
5. **教师自服务与 IDOR 防御**: 仅可见本人所授课程与班级;跨班名册 IDOR 拦截 (HTTP 200 / code 403);跨课次考勤打卡拦截 (HTTP 200 / code 403);调用 Admin API 被 403 拦截;Teacher B 对称验证 PASS。
6. **学员自服务与 IDOR 防御**: 仅可见本人参训、排课日程、报到凭证、考勤打卡与学情画像;参数篡改防御生效;跨人凭证拦截 (HTTP 200 / code 403);调用 Admin API 被 403 拦截;Student B 对称验证 PASS。
7. **特例主体处理**: 无业务身份账号访问自服务返回受控友好 403 提示;Admin + Teacher 复合角色默认优先进入管理驾驶舱,工作台自服务双向连通。
## 三、Admin 核心业务 13 大模块全覆盖回归核实
| 序号 | 业务模块 | 覆盖功能点 | 自动化回归结论 |
| :--- | :--- | :--- | :--- |
| 1 | **Teacher (师资管理)** | 教师档案列表、详情展示、业务字段检索 | **PASS** |
| 2 | **Student (学员主档)** | 学员花名册列表、身份类别过滤、分页查询 | **PASS** |
| 3 | **Course (课程库)** | 课程分类字典、课程目录检索、学时学分配置 | **PASS** |
| 4 | **Plan (培训计划)** | 开班计划制定、培训周期管理、班级状态流转 | **PASS** |
| 5 | **Enrollment (参训名册)** | 学员参训名册关联、报名状态流转与名单核对 | **PASS** |
| 6 | **Scheduling (排课与日程)** | 课次编排、时间场地校验、**软冲突预检与二次确认保存** | **PASS** |
| 7 | **Report (学员报到)** | 报到名单流水、**报到状态 0->1 确认核销** | **PASS** |
| 8 | **Attendance (考勤打卡)** | 考勤流水记录、**签到签退幂等性验证、非法状态 5 拒止** | **PASS** |
| 9 | **Board (班牌信息展示)** | 培训场地动态看板、今日日程与班级动态大屏 | **PASS** |
| 10 | **Analytics (学情分析)** | 参训率、考勤合格率、综合学情统计分析大屏 | **PASS** |
| 11 | **Account Binding (账号绑定)** | 教师与学员正式用户绑定弹窗、解除绑定功能 | **PASS** |
| 12 | **OperLog (操作日志审计)** | 审计流水全量记录、管理端关键操作日志落库正常 | **PASS** |
| 13 | **Swagger (API 契约文档)** | API 契约接口文档规范渲染与可访问性验证 | **PASS** |
## 四、网络与控制台稳定性指标
- **非预期 HTTP 500 数量 (UNEXPECTED_HTTP500_COUNT)**: `0`
- **阻断性控制台错误 (BLOCKING_CONSOLE_ERROR_COUNT)**: `0`
- **受控拦截 (Expected Authorization Rejections)**: 越权与互斥拦截均符合框架标准语义,未发生系统未捕获异常。
## 五、验收判定
**E2E_R2_STATUS = PASS**
@@ -0,0 +1,27 @@
# SYFJGYD学员管理系统 三角色及特例主体权限矩阵
- **验收单号 (RUN_ID)**: `RBAC_E2E_R2_20260916_102500`
- **代码候选基准 (CANDIDATE_COMMIT)**: `81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3`
- **系统对外正式名称 (SYSTEM_DISPLAY_NAME)**: `SYFJGYD学员管理系统`
- **测试环境 (ENVIRONMENT)**: `ISOLATED QA`
## 覆盖主体全量清单
1. **Admin** (`admin`): 系统与教务超级管理员
2. **Teacher A** (`qa_tea_a`): 域 A 教师 (张建国 `T_DOM_A`)
3. **Teacher B** (`qa_tea_b`): 域 B 教师 (王守诚 `T_DOM_B`)
4. **Student A** (`qa_stu_a`): 域 A 学员 (李明 `S_DOM_A`)
5. **Student B** (`qa_stu_b`): 域 B 学员 (赵强 `S_DOM_B`)
6. **NoIdentity Teacher/Student** (`qa_no_id_tea`, `qa_no_id_stu`): 拥有教师/学员角色但未绑定业务主档账号
7. **Admin + Teacher** (`qa_admin_tea`): 同时拥有管理员与教师双重角色主体
## 详细维度对比矩阵
| 序号 | 验证主体 | 用户账号 | 角色分配 | Menu (动态路由可见性) | Self-Service (自服务Own Data) | Admin API (全局管理接口) | Binding API (2241~2244 账号绑定) |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| 1 | **Admin** | `admin` | `admin` | 全局系统管理、教务全业务、排课、考勤、报到、分析等 | 全局可见,不限制单人 | 完全允许 (全量读写放行) | 允许 (具备 2241~2244 完整权限) |
| 2 | **Teacher A** | `qa_tea_a` | `train_teacher` | 仅教师专属工作台 (`/train/me/teacher`);无任何系统管理菜单 | 仅限本人任教课程、排课计划、课表、学员及考勤打卡 | 拒绝 (Framework 403 拦截) | 拒绝 (403 拦截,无 2241~2244 权限) |
| 3 | **Teacher B** | `qa_tea_b` | `train_teacher` | 仅教师专属工作台 (`/train/me/teacher`);无任何系统管理菜单 | 仅限本人任教域 B 对应班级、课表及学员考勤 | 拒绝 (Framework 403 拦截) | 拒绝 (403 拦截,无 2241~2244 权限) |
| 4 | **Student A** | `qa_stu_a` | `train_student` | 仅学员个人培训中心 (`/train/me/student`);无任何系统管理菜单 | 仅限本人报到凭证、参训记录、个人课表、考勤明细及画像 | 拒绝 (Framework 403 拦截) | 拒绝 (403 拦截,无 2241~2244 权限) |
| 5 | **Student B** | `qa_stu_b` | `train_student` | 仅学员个人培训中心 (`/train/me/student`);无任何系统管理菜单 | 仅限本人域 B 报到凭证、参训记录、个人课表及画像 | 拒绝 (Framework 403 拦截) | 拒绝 (403 拦截,无 2241~2244 权限) |
| 6 | **NoIdentity (Tea/Stu)** | `qa_no_id_tea` / `qa_no_id_stu` | `train_teacher` / `train_student` | 仅工作台/学员中心框架路由 | 受控拒止 (返回友好未绑定提示,严禁越权返回任意数据) | 拒绝 (Framework 403 拦截) | 拒绝 (403 拦截,无 2241~2244 权限) |
| 7 | **Admin + Teacher** | `qa_admin_tea` | `admin` + `train_teacher` | 默认优先 Landing 系统管理与教务驾驶舱,工作台可无缝切换 | 管理端全局与教师自服务接口双向连通正常 | 完全允许 (管理员权限优先放行) | 允许 (具备 2241~2244 完整权限) |
@@ -0,0 +1,53 @@
{
"RUN_ID": "RBAC_E2E_R2_20260916_102500",
"CANDIDATE_COMMIT": "81e7d5447c2ea32e4a6c717cd3bb3d1e38b6aad3",
"SYSTEM_DISPLAY_NAME": "SYFJGYD学员管理系统",
"ENVIRONMENT": "ISOLATED QA",
"TEST_DB_ISOLATED": "YES",
"MIGRATION_VALIDATION_RUN": "RBAC_E2E_R2_20260916_100800",
"MIGRATION_VALIDATION_REUSED": "YES",
"MIGRATION_EVIDENCE_TRUTHFUL": "YES",
"ADMIN_CAN_CREATE_SYS_USER": "YES",
"ADMIN_CAN_ASSIGN_ROLE": "YES",
"ADMIN_CAN_BIND_TEACHER": "YES",
"ADMIN_CAN_BIND_STUDENT": "YES",
"ADMIN_LOGIN": "PASS",
"ADMIN_ACCOUNT_BINDING": "PASS",
"TEACHER_A_LOGIN": "PASS",
"TEACHER_B_LOGIN": "PASS",
"STUDENT_A_LOGIN": "PASS",
"STUDENT_B_LOGIN": "PASS",
"TEACHER_MENU_ISOLATION": "PASS",
"STUDENT_MENU_ISOLATION": "PASS",
"TEACHER_OWN_DATA": "PASS",
"TEACHER_CROSS_PLAN_IDOR": "DENIED (PASS)",
"TEACHER_CROSS_LESSON_IDOR": "DENIED (PASS)",
"TEACHER_ADMIN_API_DENIED": "DENIED (PASS)",
"STUDENT_OWN_DATA": "PASS",
"STUDENT_CROSS_DATA_IDOR": "DENIED (PASS)",
"STUDENT_ADMIN_API_DENIED": "DENIED (PASS)",
"NO_IDENTITY_RESULT": "DENIED (PASS)",
"ADMIN_PLUS_TEACHER_RESULT": "PASS",
"TEACHER_STUDENT_MUTEX_RESULT": "PASS",
"BIND_PERMISSION_RUNTIME_RESULT": "PASS",
"ADMIN_REGRESSION": "PASS",
"ROLE_MATRIX_COMPLETE": "YES",
"IDOR_EVIDENCE_COMPLETE": "YES",
"ACCOUNT_BINDING_EVIDENCE_COMPLETE": "YES",
"UNEXPECTED_HTTP500_COUNT": 0,
"BLOCKING_CONSOLE_ERROR_COUNT": 0,
"SCREENSHOT_COUNT": 21,
"EVIDENCE_DOCUMENT_COUNT": 10,
"EVIDENCE_PATH": "/Users/wzb/Desktop/项目/RailwaySystem-worktrees/rbac-r2/docs/reports/e2e-r2/RBAC_E2E_R2_20260916_102500",
"BACKEND_TEST_TOTAL": 205,
"MAVEN_PACKAGE": "BUILD SUCCESS",
"FRONTEND_BUILD": "BUILD SUCCESS",
"ESLINT_ERRORS": 0,
"004_EXECUTED_PRODUCTION": "NO",
"005_EXECUTED_PRODUCTION": "NO",
"PRODUCTION_DB_MODIFIED": "NO",
"E2E_R2_STATUS": "PASS",
"COMMIT": "NO",
"PUSH": "NO",
"DEPLOY": "NO"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more