From 592a9655e87d984110b8bf9ba9c796c8a24554f1 Mon Sep 17 00:00:00 2001 From: wangzhibiao Date: Tue, 15 Sep 2026 14:26:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=9D=83=E9=99=90=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E6=95=99=E5=B8=88=E5=AD=A6=E5=91=98=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E7=BB=91=E5=AE=9A=E4=B8=8E=E8=87=AA=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../train/TrainElectronicBoardController.java | 12 +- .../train/TrainStudentMeController.java | 173 +++++++ .../train/TrainTeacherMeController.java | 176 +++++++ ...RbacHttpAndAuthorizationSemanticsTest.java | 324 ++++++++++++ .../train/RbacIdorSecurityTest.java | 225 +++++++++ .../RbacPermissionMigrationContractTest.java | 94 ++++ .../train/StudentSelfServiceApiTest.java | 317 ++++++++++++ .../train/TeacherSelfServiceApiTest.java | 280 +++++++++++ .../com/ruoyi/train/domain/TrainTeacher.java | 10 + .../train/mapper/TrainClassReportMapper.java | 5 + .../train/mapper/TrainEnrollmentMapper.java | 8 + .../ruoyi/train/mapper/TrainLessonMapper.java | 25 + .../mapper/TrainStudentCheckinMapper.java | 12 + .../train/mapper/TrainStudentMapper.java | 33 ++ .../mapper/TrainStudentPortraitMapper.java | 4 + .../mapper/TrainStudyDurationMapper.java | 4 + .../train/mapper/TrainTeacherMapper.java | 33 ++ .../train/mapper/TrainUserLockMapper.java | 21 + .../service/ITrainingIdentityService.java | 93 ++++ .../impl/TrainingIdentityServiceImpl.java | 212 ++++++++ .../mapper/train/TrainClassReportMapper.xml | 8 + .../mapper/train/TrainEnrollmentMapper.xml | 22 + .../mapper/train/TrainLessonMapper.xml | 111 +++++ .../train/TrainStudentCheckinMapper.xml | 15 + .../mapper/train/TrainStudentMapper.xml | 22 + .../train/TrainStudentPortraitMapper.xml | 8 + .../mapper/train/TrainStudyDurationMapper.xml | 7 + .../mapper/train/TrainTeacherMapper.xml | 22 + .../impl/EnrollmentAppServiceImplTest.java | 30 ++ .../impl/LegacyAnalyticsV2ServiceTest.java | 5 + .../impl/SchedulingAppServiceTest.java | 24 + .../impl/TrainStudentServiceImplTest.java | 24 + .../impl/TrainingIdentityServiceTest.java | 468 ++++++++++++++++++ .../kingbase/004_rbac_identity_binding.sql | 48 ++ .../005_training_roles_permissions.sql | 310 ++++++++++++ .../rbac-r2/01_identity_binding_design.md | 39 +- docs/development/rbac-r2/06_migration_plan.md | 43 +- .../08_implementation_task_breakdown.md | 99 ++-- 38 files changed, 3277 insertions(+), 89 deletions(-) create mode 100644 RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentMeController.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacHttpAndAuthorizationSemanticsTest.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacIdorSecurityTest.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java create mode 100644 RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainUserLockMapper.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainingIdentityService.java create mode 100644 RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainingIdentityServiceImpl.java create mode 100644 RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainingIdentityServiceTest.java create mode 100644 RuoYi-Vue/sql/system/kingbase/004_rbac_identity_binding.sql create mode 100644 RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql 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`