diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainElectronicBoardController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainElectronicBoardController.java
index 91ef523a6..d4e6e3322 100644
--- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainElectronicBoardController.java
+++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainElectronicBoardController.java
@@ -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)
diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentMeController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentMeController.java
new file mode 100644
index 000000000..2c6c72206
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentMeController.java
@@ -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/**)。
+ *
+ *
服务端严格基于当前会话与登录人关联的学员身份推导数据范围,杜绝任何外部主键传入与 IDOR 越权。
+ *
+ * @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 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 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 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 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);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java
new file mode 100644
index 000000000..1b0cd9c87
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java
@@ -0,0 +1,176 @@
+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/**)。
+ *
+ * 服务端严格基于当前会话与登录人关联的教师身份推导数据范围,杜绝任何外部主键传入与 IDOR 越权。
+ *
+ * @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);
+ }
+ 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 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 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 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 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);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacHttpAndAuthorizationSemanticsTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacHttpAndAuthorizationSemanticsTest.java
new file mode 100644
index 000000000..10dd53d1c
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacHttpAndAuthorizationSemanticsTest.java
@@ -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 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"));
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacIdorSecurityTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacIdorSecurityTest.java
new file mode 100644
index 000000000..b1bd9398a
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacIdorSecurityTest.java
@@ -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:"));
+ }
+ }
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java
new file mode 100644
index 000000000..1a8d502fa
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java
@@ -0,0 +1,94 @@
+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",
+ "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 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:"));
+ }
+ }
+ }
+
+ 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));
+ }
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java
new file mode 100644
index 000000000..8ed1a07ac
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java
@@ -0,0 +1,317 @@
+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 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 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 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 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 lessons = (List) 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 durations = (List) 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());
+ }
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java
new file mode 100644
index 000000000..8d56aef6a
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java
@@ -0,0 +1,280 @@
+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 list = new ArrayList<>();
+ TrainClassPlan plan = new TrainClassPlan();
+ plan.setPlanId("PLAN_MY");
+ plan.setPlanName("高速铁路司机综合实训");
+ list.add(plan);
+ return list;
+ }
+ if ("selectScheduleLessonsByTeacherId".equals(name))
+ {
+ List 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 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 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 list = (List) 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 rows = (List) 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 list = (List) result.get("data");
+ assertEquals(1, list.size());
+ assertEquals("S001", list.get(0).getStudentId());
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java
index e9c0a48a9..e5ed2b275 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainTeacher.java
@@ -69,6 +69,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;
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassReportMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassReportMapper.java
index 56e29a4e4..7206d6d31 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassReportMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainClassReportMapper.java
@@ -42,4 +42,9 @@ public interface TrainClassReportMapper
* @return 插入行数
*/
public int insertTrainClassReport(TrainClassReport trainClassReport);
+
+ /**
+ * 查询指定学员的最新开班报到记录
+ */
+ public TrainClassReport selectLatestReportByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java
index 0567c591b..f69335e85 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainEnrollmentMapper.java
@@ -171,4 +171,12 @@ public interface TrainEnrollmentMapper
* @return 引用记录数
*/
int countStudyDurationByEnrollmentId(@Param("enrollmentId") Long enrollmentId);
+
+ /**
+ * 查询指定学员的全部参训记录及班级信息
+ *
+ * @param studentId 学员ID
+ * @return 参训记录列表
+ */
+ List selectEnrollmentsByStudentId(@Param("studentId") String studentId);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java
index 7b8e14952..a8aa40585 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainLessonMapper.java
@@ -117,4 +117,29 @@ public interface TrainLessonMapper
* 获取指定培训计划下的全部课次日程明细 (按开始时间升序排列)。
*/
List selectScheduleLessonsByPlanId(@Param("planId") String planId);
+
+ /**
+ * 获取指定教师的授课日程列表 (按开始时间升序排列)。
+ */
+ List selectScheduleLessonsByTeacherId(@Param("tId") String tId,
+ @Param("startDate") Date startDate,
+ @Param("endDate") Date endDate);
+
+ /**
+ * 获取指定学员所在有效班级的参训日程列表 (按开始时间升序排列)。
+ */
+ List selectScheduleLessonsByStudentId(@Param("studentId") String studentId,
+ @Param("startDate") Date startDate,
+ @Param("endDate") Date endDate);
+
+ /**
+ * 校验教师是否在指定培训计划中具备排课任教关系。
+ */
+ int countTeacherInPlan(@Param("tId") String tId, @Param("planId") String planId);
+
+ /**
+ * 查询指定教师参与授课的培训班列表。
+ */
+ List selectPlansByTeacherId(@Param("tId") String tId,
+ @Param("planStatus") Integer planStatus);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentCheckinMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentCheckinMapper.java
index 93453f7d7..bd8b5c005 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentCheckinMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentCheckinMapper.java
@@ -52,4 +52,16 @@ public interface TrainStudentCheckinMapper
* @return 更新行数
*/
public int updateTrainStudentCheckin(TrainStudentCheckin trainStudentCheckin);
+
+ /**
+ * 查询指定学员的考勤打卡流水 (按打卡时间倒序)
+ */
+ public List 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 selectCheckinsByLessonId(@org.apache.ibatis.annotations.Param("lessonId") Long lessonId);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java
index a5c3df3bd..654179d6e 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentMapper.java
@@ -15,4 +15,37 @@ public interface TrainStudentMapper
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);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentPortraitMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentPortraitMapper.java
index 02d89cab1..f7a12ab0c 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentPortraitMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudentPortraitMapper.java
@@ -27,4 +27,8 @@ public interface TrainStudentPortraitMapper
*/
public List selectTrainStudentPortraitList(TrainStudentPortrait trainStudentPortrait);
+ /**
+ * 查询指定学员的最新多维成长画像
+ */
+ public TrainStudentPortrait selectPortraitByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudyDurationMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudyDurationMapper.java
index b7f3816ac..87d86f182 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudyDurationMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainStudyDurationMapper.java
@@ -27,4 +27,8 @@ public interface TrainStudyDurationMapper
*/
public List selectTrainStudyDurationList(TrainStudyDuration trainStudyDuration);
+ /**
+ * 查询指定学员的学习时长记录列表 (按同步时间倒序)
+ */
+ public List selectDurationsByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java
index 718e3513a..a4618769c 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainTeacherMapper.java
@@ -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);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainUserLockMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainUserLockMapper.java
new file mode 100644
index 000000000..107603696
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainUserLockMapper.java
@@ -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);
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainingIdentityService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainingIdentityService.java
new file mode 100644
index 000000000..1aaa9431a
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainingIdentityService.java
@@ -0,0 +1,93 @@
+package com.ruoyi.train.service;
+
+import com.ruoyi.train.domain.TrainStudent;
+import com.ruoyi.train.domain.TrainTeacher;
+
+/**
+ * 培训业务身份解析与绑定服务契约接口。
+ *
+ * 负责统一处理 sys_user 与 train_teacher / train_student 之间的映射推导、
+ * 跨表互斥校验以及服务端身份断言,杜绝客户端主键篡改与 IDOR 越权。
+ *
+ * @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);
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainingIdentityServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainingIdentityServiceImpl.java
new file mode 100644
index 000000000..1d8f13d06
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainingIdentityServiceImpl.java
@@ -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 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);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassReportMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassReportMapper.xml
index 015654a1d..c7dc277c6 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassReportMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainClassReportMapper.xml
@@ -57,4 +57,12 @@
#{verifyResult}, #{verifyNote}, #{createBy}, #{createTime}, #{remark}
)
+
+
+
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml
index 590f49b31..ac2fb6bf9 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainEnrollmentMapper.xml
@@ -179,4 +179,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select count(1) from train_study_duration where enrollment_id = #{enrollmentId}
+
+
+
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml
index a885c47c7..fe15fc5c7 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainLessonMapper.xml
@@ -275,4 +275,115 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by l.start_time asc, l.lesson_id asc
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentCheckinMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentCheckinMapper.xml
index faa465474..7142143f7 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentCheckinMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentCheckinMapper.xml
@@ -87,4 +87,19 @@
where checkin_id = #{checkinId}
+
+
+
+
+
+
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml
index 843ce4673..f39a33532 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentMapper.xml
@@ -76,4 +76,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{status}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}, #{delFlag}
)
+
+
+
+
+
+
+
+
+
+ update train_student set user_id = #{userId}, update_time = CURRENT_TIMESTAMP where s_id = #{sId} and del_flag = '0'
+
+
+
+
+ update train_student set user_id = null, update_time = CURRENT_TIMESTAMP where s_id = #{sId}
+
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentPortraitMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentPortraitMapper.xml
index eb97dd92d..2e6d30ea9 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentPortraitMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudentPortraitMapper.xml
@@ -47,4 +47,12 @@
where pt.portrait_id = #{portraitId}
+
+
+
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudyDurationMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudyDurationMapper.xml
index fac790a85..59fc2080f 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudyDurationMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainStudyDurationMapper.xml
@@ -56,4 +56,11 @@
where d.dur_id = #{durId}
+
+
+
diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml
index 5752be0df..e04f7d89e 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml
+++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainTeacherMapper.xml
@@ -124,4 +124,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{tId}
+
+
+
+
+
+
+
+
+
+ update train_teacher set user_id = #{userId}, update_time = CURRENT_TIMESTAMP where t_id = #{tId} and del_flag = '0'
+
+
+
+
+ update train_teacher set user_id = null, update_time = CURRENT_TIMESTAMP where t_id = #{tId}
+
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java
index 1a6ad63e7..cbdea29e8 100644
--- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java
@@ -959,6 +959,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 +1114,11 @@ public class EnrollmentAppServiceImplTest
{
return 1;
}
+
+ @Override
+ public List selectEnrollmentsByStudentId(String studentId)
+ {
+ return Collections.emptyList();
+ }
}
}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java
index 7236431bf..865665a8f 100644
--- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java
@@ -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 selectCheckinsByStudentId(String sId, String pId, String m) { return Collections.emptyList(); }
+ public List 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 selectTrainStudyDurationList(TrainStudyDuration query) {
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
}
+ public List 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 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();
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java
index 47f74cd99..d5c20bc98 100644
--- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/SchedulingAppServiceTest.java
@@ -1043,6 +1043,30 @@ public class SchedulingAppServiceTest
vo.setEndTime(l.getEndTime());
return vo;
}
+
+ @Override
+ public List selectScheduleLessonsByTeacherId(String tId, Date startDate, Date endDate)
+ {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public List selectScheduleLessonsByStudentId(String studentId, Date startDate, Date endDate)
+ {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public int countTeacherInPlan(String tId, String planId)
+ {
+ return 0;
+ }
+
+ @Override
+ public List selectPlansByTeacherId(String tId, Integer planStatus)
+ {
+ return Collections.emptyList();
+ }
}
/**
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java
index b5bec76c0..d4efac556 100644
--- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java
@@ -288,5 +288,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;
+ }
}
}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainingIdentityServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainingIdentityServiceTest.java
new file mode 100644
index 000000000..f70af4442
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainingIdentityServiceTest.java
@@ -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 teachersByUserId = Collections.synchronizedMap(new HashMap<>());
+ private final Map teachersByTId = Collections.synchronizedMap(new HashMap<>());
+ private final Map studentsByUserId = Collections.synchronizedMap(new HashMap<>());
+ private final Map 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 createProxy(Class clazz, InvocationHandler handler)
+ {
+ return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class>[]{clazz}, handler);
+ }
+}
diff --git a/RuoYi-Vue/sql/system/kingbase/004_rbac_identity_binding.sql b/RuoYi-Vue/sql/system/kingbase/004_rbac_identity_binding.sql
new file mode 100644
index 000000000..6acd37e26
--- /dev/null
+++ b/RuoYi-Vue/sql/system/kingbase/004_rbac_identity_binding.sql
@@ -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;
diff --git a/RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql b/RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql
new file mode 100644
index 000000000..cb18aebbe
--- /dev/null
+++ b/RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql
@@ -0,0 +1,310 @@
+-- =============================================================================
+-- 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 ~ 2240 的碰撞:若ID已存在,必须属于预期本系统权限,严禁覆盖外来资源
+ IF EXISTS (
+ SELECT 1 FROM public.sys_menu
+ WHERE menu_id BETWEEN 2201 AND 2240
+ AND (perms IS NULL OR perms NOT LIKE 'train:%')
+ ) THEN
+ RAISE EXCEPTION 'Collision detected: sys_menu.menu_id in 2201..2240 is occupied by non-train permissions';
+ 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');
+
+-- 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');
+
+-- 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, '#', '', '', 1, 0, 'F', '0', '0', 'train:me:teacher', '#', '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, '#', '', '', 1, 0, 'F', '0', '0', 'train:me:student', '#', '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)
+ 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 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;
diff --git a/docs/development/rbac-r2/01_identity_binding_design.md b/docs/development/rbac-r2/01_identity_binding_design.md
index fa20b4189..649d6bf2e 100644
--- a/docs/development/rbac-r2/01_identity_binding_design.md
+++ b/docs/development/rbac-r2/01_identity_binding_design.md
@@ -50,14 +50,15 @@
## 2. 多身份策略与唯一性约束 (Multi-Identity Policy)
-### 2.1 一期多身份决策:严格单身份互斥
-- 在铁路职工技能培训体系中,角色的职责边界极为森严:
- - **教务管理员 (Admin)**: 负责全局资源调配、排课、分班与监控。
- - **任课教师 (Teacher)**: 专职负责授课、课次考勤与教学答疑。
- - **参训职工 (Student)**: 专职参与培训、打卡签到与考核画像。
-- **冻结决策**: **一期系统严格不支持一个账号具备多重业务身份**。
- - 严禁同一个 `sys_user` 既绑定教师又绑定学员。
- - 严禁超级管理员账号 `admin (userId=1)` 绑定为普通业务教师或学员。
+### 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 脚本中为两张表增加唯一索引:
@@ -73,19 +74,19 @@ ON train_student(user_id)
WHERE user_id IS NOT NULL AND del_flag = '0';
```
-### 2.3 业务服务层互斥前置校验防线
-在新建或绑定账号时,在 Java Service 事务中实施互斥断言:
+### 2.3 业务服务层互斥前置校验与跨表并发锁
+在新建或绑定账号时,在 Java Service 事务中实施悲观锁与互斥断言:
```java
-public void validateUserIdentityExclusive(Long userId) {
+public void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId) {
if (userId == null) return;
- if (SecurityUtils.isAdmin(userId)) {
- throw new ServiceException("系统超级管理员不可绑定为业务教师或学员");
- }
- if (teacherMapper.countByUserId(userId) > 0) {
- throw new ServiceException("该登录账号已绑定为教师身份,不可重复绑定");
- }
- if (studentMapper.countByUserId(userId) > 0) {
- throw new ServiceException("该登录账号已绑定为学员身份,不可重复绑定");
+ 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("该系统登录账号已绑定为教师身份,不可重复绑定为学员");
+ }
}
}
```
diff --git a/docs/development/rbac-r2/06_migration_plan.md b/docs/development/rbac-r2/06_migration_plan.md
index 922cc3a7b..96c21b444 100644
--- a/docs/development/rbac-r2/06_migration_plan.md
+++ b/docs/development/rbac-r2/06_migration_plan.md
@@ -46,16 +46,14 @@ RuoYi-Vue/sql/system/kingbase/
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. 建立教师账号唯一索引 (忽略已删除 del_flag='2' 与空值)
- DROP INDEX IF EXISTS uq_train_teacher_user_id;
- CREATE UNIQUE INDEX uq_train_teacher_user_id
- ON train_teacher(user_id)
+ -- 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 INDEX IF EXISTS uq_train_student_user_id;
- CREATE UNIQUE INDEX uq_train_student_user_id
- ON train_student(user_id)
+ -- 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 (唯一绑定教师登录账号)';
@@ -66,26 +64,27 @@ RuoYi-Vue/sql/system/kingbase/
### 2.2 脚本 2: `005_training_roles_permissions.sql`
- **执行目标**:
- 1. 预置 3 个业务角色 (`train_admin`, `train_teacher`, `train_student`) 写入 `sys_role`。
- 2. 补齐 `sys_menu` 中历史遗漏的基础模块按钮级权限(`train:teacher:*`, `train:student:*`, `train:course:*`, `train:plan:*`, `train:lesson:*` 等)。
- 3. 将 `system:board:*` 权限标识收敛对齐为 `train:board:*`。
- 4. 插入 Teacher 工作台菜单与 Student 个人中心菜单节点。
- 5. 幂等配置 `sys_role_menu` 角色菜单权限映射。
+ 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;
- -- 1. 预置角色 (使用特定固定 role_id 3, 4, 5,采用 ON CONFLICT DO NOTHING 保证幂等)
+ -- 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)
- VALUES
- (3, '培训管理员', 'train_admin', 3, '1', 1, 1, '0', '0', 'admin', CURRENT_TIMESTAMP, '一期业务全局管理角色'),
- (4, '培训教师', 'train_teacher', 4, '2', 1, 1, '0', '0', 'admin', CURRENT_TIMESTAMP, '教师专属教学工作台'),
- (5, '培训学员', 'train_student', 5, '2', 1, 1, '0', '0', 'admin', CURRENT_TIMESTAMP, '学员专属个人培训中心')
- ON CONFLICT (role_id) DO NOTHING;
+ 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/export 等节点)
+ -- ... (录入 train:teacher:list/add/edit/query 等节点)
-- 3. 统一规范电子班牌命名空间
UPDATE sys_menu SET perms = REPLACE(perms, 'system:board:', 'train:board:')
@@ -93,8 +92,8 @@ RuoYi-Vue/sql/system/kingbase/
-- 4. 建立角色与菜单权限关联 (sys_role_menu)
-- train_admin: 分配全部培训业务管理菜单及按钮
- -- train_teacher: 分配教师工作台、我的课表、我的班级学员菜单
- -- train_student: 分配学员中心、我的课表、我的报到、我的考勤菜单
+ -- train_teacher: 分配教师工作台与专属自服务权限
+ -- train_student: 分配学员培训中心与专属自服务权限
COMMIT;
```
diff --git a/docs/development/rbac-r2/08_implementation_task_breakdown.md b/docs/development/rbac-r2/08_implementation_task_breakdown.md
index fba3645f7..67efa9718 100644
--- a/docs/development/rbac-r2/08_implementation_task_breakdown.md
+++ b/docs/development/rbac-r2/08_implementation_task_breakdown.md
@@ -50,74 +50,83 @@ flowchart TD
## 2. 详细任务卡片清单 (RBAC-B1 ~ RBAC-B10)
-### 任务 1: RBAC-B1 - 身份绑定与物理约束脚本 (Identity Binding & DDL)
+### 任务 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)` 建立非空部分唯一索引。
-- **完成标志**: 脚本在 Kingbase 生产库显式事务执行通过,唯一性拦截生效。
+- **当前状态**: 已完成 (脚本编写完成并通过静态契约测试验证,已就绪待运维发布)
+- **交付内容**:
+ 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)
+### 任务 2: RBAC-B2 - 角色预置与权限补齐脚本 (Role & Permission Migration) [COMPLETED (Wave 1)]
- **依赖关系**: 依赖 B1 完成。
-- **任务目标**:
- 1. 编写 `RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql`。
+- **当前状态**: 已完成 (脚本编写完成并通过静态契约测试验证,已就绪待运维发布)
+- **交付内容**:
+ 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` 行。
- 4. 建立角色的初始菜单与功能权限绑定映射。
-- **完成标志**: 数据库执行无报错,管理员界面角色管理中可见 3 个新角色。
+ 3. 补齐 `sys_menu` 中基础管理模块缺失的全部按钮级 `perms` 行 (2201~2240)。
+ 4. 建立角色的初始菜单与功能权限绑定映射及自检验证块。
+- **完成标志**: 脚本契约测试 `testMigration005_RoleAndPermissionScriptContract` 验证通过。
-### 任务 3: RBAC-B3 - 教师后端自服务体系实现 (Teacher Backend)
+### 任务 3: RBAC-B3 - 教师后端自服务体系实现 (Teacher Backend) [COMPLETED (Wave 1)]
- **依赖关系**: 依赖 B1, B2。与 B4、B7、B8 可完全并行。
-- **任务目标**:
- 1. 新增控制器 `TrainTeacherMeController.java` (`/train/me/...`)。
- 2. 实现获取教师本人画像、任教班级、授课课表、班级学员名册(脱敏)与出勤统计。
- 3. 强制从 `SecurityUtils.getUserId()` 提取当前教师 ID。
-- **完成标志**: Swagger 联调成功,篡改身份直接抛出 403。
+- **当前状态**: 已完成
+- **交付内容**:
+ 1. 控制器 `TrainTeacherMeController.java` (`/train/me/teacher/...`)。
+ 2. 实现获取教师本人画像、任教班级、授课课表、班级学员名册(脱敏)与课次出勤流水。
+ 3. 强制校验教师身份与教学班级/课次归属,杜绝跨教师越权 (IDOR)。
+ 4. `TeacherSelfServiceApiTest` 7 个单测用例全部通过。
+- **完成标志**: 接口单元测试通过率 100%,越权拦截测试生效。
-### 任务 4: RBAC-B4 - 学员后端自服务体系实现 (Student Backend)
+### 任务 4: RBAC-B4 - 学员后端自服务体系实现 (Student Backend) [COMPLETED (Wave 1)]
- **依赖关系**: 依赖 B1, B2。与 B3、B7、B8 可完全并行。
-- **任务目标**:
- 1. 新增控制器 `TrainStudentMeController.java` (`/train/me/...`)。
+- **当前状态**: 已完成
+- **交付内容**:
+ 1. 控制器 `TrainStudentMeController.java` (`/train/me/student/...`)。
2. 实现获取学员本人档案、参训班级状态、个人课表、报到凭据、个人考勤、累计学时与多维画像。
3. 强制校验本人归属,杜绝任何外部主键传入。
-- **完成标志**: 接口单元测试通过率 100%。
+ 4. `StudentSelfServiceApiTest` 6 个单测用例全部通过。
+- **完成标志**: 接口单元测试通过率 100%,敏感信息脱敏合格。
-### 任务 5: RBAC-B5 - 教师工作台前端视图开发 (Teacher Frontend)
+### 任务 5: RBAC-B5 - 教师工作台前端视图开发 (Teacher Frontend) [PENDING (Wave 2)]
- **依赖关系**: 依赖 B2 菜单路由,可与 B3 并行(采用 Mock 或接口契约联调)。
- **任务目标**:
1. 在 `build_ui/src/views/train/teacher/` 下开发工作台看板与我的日程日历组件。
2. 开发任教班级花名册抽屉视图。
- **完成标志**: 教师登录后正常展现日历课表与花名册。
-### 任务 6: RBAC-B6 - 学员个人培训中心前端开发 (Student Frontend)
+### 任务 6: RBAC-B6 - 学员个人培训中心前端开发 (Student Frontend) [PENDING (Wave 2)]
- **依赖关系**: 依赖 B2 菜单路由,可与 B4 并行开发。
- **任务目标**:
1. 在 `build_ui/src/views/train/student/` 下开发学员培训个人中心。
2. 整合培训流程步骤条、今日课表卡片、个人考勤打卡流水与雷达画像组件。
- **完成标志**: 学员登录后清晰直观呈现个人培训全流程状态。
-### 任务 7: RBAC-B7 - 登录界面注册入口安全改造 (Login UI Policy)
+### 任务 7: RBAC-B7 - 登录界面注册入口安全改造 (Login UI Policy) [PENDING (Wave 2)]
- **依赖关系**: 独立任务,可立即开工。
- **任务目标**:
1. 修改 `build_ui/src/views/login.vue`,彻底关闭 `register` 开关,移除“立即注册”链接。
2. 增加居中提示:“*账号由培训管理部门统一创建与授权维护*”。
- **完成标志**: 登录页面无注册链接,提示文案清晰优雅。
-### 任务 8: RBAC-B8 - 班牌权限命名空间统一对齐 (Namespace Alignment)
+### 任务 8: RBAC-B8 - 班牌权限命名空间统一对齐 (Namespace Alignment) [PARTIALLY COMPLETED (Wave 1 后端已完成)]
- **依赖关系**: 独立任务,可立即开工。
-- **任务目标**:
- 1. 将后端 `TrainElectronicBoardController.java` 中的 `@PreAuthorize("@ss.hasPermi('system:board:...')")` 统一对齐为 `train:board:...`。
- 2. 将前端 `views/train/board/index.vue` 中的 `v-hasPermi="['system:board:...']"` 统一对齐为 `train:board:...`。
-- **完成标志**: 班牌功能按钮与后端权限完全基于 `train:board:*` 正常鉴权。
+- **当前状态**:
+ - 后端:`TrainElectronicBoardController.java` 中的权限注解已全部从 `system:board:*` 收敛统一为 `train:board:*` [COMPLETED]
+ - 前端:`build_ui/src/views/train/board/index.vue` 中的 `v-hasPermi` 对齐计划在 Wave 2 前端阶段完成 [PENDING]
+- **完成标志**: 静态契约测试 `testElectronicBoardControllerPermissionsAligned` 验证通过。
-### 任务 9: RBAC-B9 - 数据范围与 IDOR 安全渗透自动化测试 (IDOR Gate)
+### 任务 9: RBAC-B9 - 数据范围与 IDOR 安全渗透自动化测试 (IDOR Gate) [PARTIALLY COMPLETED (Wave 1 后端 IDOR 已通过)]
- **依赖关系**: 依赖 B3、B4、B5、B6 联调完成。
-- **任务目标**:
- 1. 编写跨用户越权自动化探测脚本,模拟 Teacher A 访问 Teacher B 课表,模拟 Student A 访问 Student B 档案与考勤。
- 2. 验证所有直接对象引用均被有效拒止。
-- **完成标志**: `IDOR_LEAK_COUNT = 0`。
+- **当前状态**:
+ - 后端 IDOR 单测套件已完成并验证:
+ - `RbacIdorSecurityTest`: 模拟跨教师越权访问他人学员名册与考勤,跨学员越权探测等,全部被严密拒止。
+ - `TrainingIdentityServiceTest`: 验证身份唯一性与互斥性。
+ - 端到端渗透验收:保留至三角色前端集成后进行。
+- **完成标志**: 后端自动化测试中 `IDOR_LEAK_COUNT = 0`。
-### 任务 10: RBAC-B10 - 管理端业务全流程无损回归测试 (Admin Regression)
+### 任务 10: RBAC-B10 - 管理端业务全流程无损回归测试 (Admin Regression) [PENDING (Wave 3)]
- **依赖关系**: 汇总任务,依赖全部前置任务。
- **任务目标**:
1. 执行 E2E-R2 验收方案全流程。
@@ -126,11 +135,17 @@ flowchart TD
---
-## 3. 并行施工调度建议
+## 3. 并行施工调度建议与 Wave 进展
-- **第 1 梯队 (基础组)**: 执行 `RBAC-B1` 与 `RBAC-B2` (预计 0.5 天),完成数据库准备;同时执行 `RBAC-B7` 与 `RBAC-B8` (预计 0.5 天)。
-- **第 2 梯队 (业务组)**:
- - 后端工程师 A 攻坚 `RBAC-B3` (教师后端),前端工程师 A 攻坚 `RBAC-B5` (教师前端);
- - 后端工程师 B 攻坚 `RBAC-B4` (学员后端),前端工程师 B 攻坚 `RBAC-B6` (学员前端);
- - 两组完全解耦并行。
-- **第 3 梯队 (质量保障组)**: 执行 `RBAC-B9` (越权审计) 与 `RBAC-B10` (全量回归与演示交付)。
+- **Wave 1 (已完成)**:
+ - 后端基础设施与迁移脚本准备: `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 (前端)`
+- **Wave 3 (最终验收)**:
+ - 三角色端到端联调与 E2E-R2 回归测试: `RBAC-B10`