diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminController.java new file mode 100644 index 000000000..d3f8e8ea9 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminController.java @@ -0,0 +1,120 @@ +package com.ruoyi.web.controller.train; + +import java.util.List; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.train.domain.dto.UserBindingDTO; +import com.ruoyi.train.domain.vo.BindableUserVO; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; + +/** + * 管理员端业务身份系统账号关联 Controller。 + * + *

为管理员提供受控的系统账号与教师/学员业务档案关联与解绑功能, + * 满足 Wave 2.1 / Wave 3 真实三角色生命周期管理流。

+ * + * @author ruoyi + */ +@RestController +@RequestMapping +public class TrainAccountBindingAdminController extends BaseController +{ + @Autowired + private TrainAccountBindingAdminService bindingAdminService; + + // ========================================== + // 教师业务档案账号绑定 + // ========================================== + + /** + * 查询可关联教师身份的系统用户列表 + */ + @PreAuthorize("@ss.hasPermi('train:teacher:bindUser')") + @GetMapping({"/train/teacher/bindable-users", "/train/teachers/bindable-users"}) + public AjaxResult getTeacherBindableUsers(@RequestParam(value = "keyword", required = false) String keyword) + { + List users = bindingAdminService.selectBindableUsers("teacher", keyword); + return success(users); + } + + /** + * 为教师关联系统用户 + */ + @PreAuthorize("@ss.hasPermi('train:teacher:bindUser')") + @Log(title = "教师账号关联", businessType = BusinessType.UPDATE) + @RequestMapping(value = {"/train/teacher/{tId}/user-binding", "/train/teachers/{tId}/user-binding"}, + method = {RequestMethod.PUT, RequestMethod.POST}) + public AjaxResult bindTeacherUser(@PathVariable("tId") String tId, + @Validated @RequestBody UserBindingDTO dto) + { + bindingAdminService.bindTeacherAccount(tId, dto.getUserId()); + return success(); + } + + /** + * 为教师解除系统用户关联 + */ + @PreAuthorize("@ss.hasPermi('train:teacher:unbindUser')") + @Log(title = "教师账号解绑", businessType = BusinessType.UPDATE) + @DeleteMapping({"/train/teacher/{tId}/user-binding", "/train/teachers/{tId}/user-binding"}) + public AjaxResult unbindTeacherUser(@PathVariable("tId") String tId) + { + bindingAdminService.unbindTeacherAccount(tId); + return success(); + } + + // ========================================== + // 学员业务档案账号绑定 + // ========================================== + + /** + * 查询可关联学员身份的系统用户列表 + */ + @PreAuthorize("@ss.hasPermi('train:student:bindUser')") + @GetMapping({"/train/student/bindable-users", "/train/students/bindable-users"}) + public AjaxResult getStudentBindableUsers(@RequestParam(value = "keyword", required = false) String keyword) + { + List users = bindingAdminService.selectBindableUsers("student", keyword); + return success(users); + } + + /** + * 为学员关联系统用户 + */ + @PreAuthorize("@ss.hasPermi('train:student:bindUser')") + @Log(title = "学员账号关联", businessType = BusinessType.UPDATE) + @RequestMapping(value = {"/train/student/{sId}/user-binding", "/train/students/{sId}/user-binding"}, + method = {RequestMethod.PUT, RequestMethod.POST}) + public AjaxResult bindStudentUser(@PathVariable("sId") String sId, + @Validated @RequestBody UserBindingDTO dto) + { + bindingAdminService.bindStudentAccount(sId, dto.getUserId()); + return success(); + } + + /** + * 为学员解除系统用户关联 + */ + @PreAuthorize("@ss.hasPermi('train:student:unbindUser')") + @Log(title = "学员账号解绑", businessType = BusinessType.UPDATE) + @DeleteMapping({"/train/student/{sId}/user-binding", "/train/students/{sId}/user-binding"}) + public AjaxResult unbindStudentUser(@PathVariable("sId") String sId) + { + bindingAdminService.unbindStudentAccount(sId); + return success(); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/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/TrainStudentController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java index 6534454b3..a94193ea2 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainStudentController.java @@ -2,16 +2,6 @@ package com.ruoyi.web.controller.train; import java.util.List; import javax.validation.Valid; -import com.ruoyi.common.annotation.Log; -import com.ruoyi.common.core.controller.BaseController; -import com.ruoyi.common.core.domain.AjaxResult; -import com.ruoyi.common.core.page.TableDataInfo; -import com.ruoyi.common.enums.BusinessType; -import com.ruoyi.train.domain.dto.StudentCreateDTO; -import com.ruoyi.train.domain.query.StudentQuery; -import com.ruoyi.train.domain.vo.StudentDetailVO; -import com.ruoyi.train.domain.vo.StudentItemVO; -import com.ruoyi.train.service.ITrainStudentService; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,17 +9,34 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.github.pagehelper.PageInfo; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.train.domain.dto.StudentCreateDTO; +import com.ruoyi.train.domain.query.StudentQuery; +import com.ruoyi.train.domain.vo.StudentAdminVO; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.service.ITrainStudentService; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; /** ISSUE-01-02 学员主档 P0 API。 */ @RestController -@RequestMapping("/train/student") +@RequestMapping({"/train/student", "/train/students"}) public class TrainStudentController extends BaseController { private final ITrainStudentService trainStudentService; + private final TrainAccountBindingAdminService bindingAdminService; - public TrainStudentController(ITrainStudentService trainStudentService) + public TrainStudentController(ITrainStudentService trainStudentService, + TrainAccountBindingAdminService bindingAdminService) { this.trainStudentService = trainStudentService; + this.bindingAdminService = bindingAdminService; } @PreAuthorize("@ss.hasPermi('train:student:list')") @@ -38,7 +45,13 @@ public class TrainStudentController extends BaseController { startPage(); List list = trainStudentService.selectStudentList(query); - return getDataTable(list); + List adminList = bindingAdminService.toStudentAdminList(list); + TableDataInfo rspData = new TableDataInfo(); + rspData.setCode(HttpStatus.SUCCESS); + rspData.setMsg("查询成功"); + rspData.setRows(adminList); + rspData.setTotal(new PageInfo(list).getTotal()); + return rspData; } @PreAuthorize("@ss.hasPermi('train:student:query')") @@ -46,7 +59,11 @@ public class TrainStudentController extends BaseController public AjaxResult getInfo(@PathVariable("sId") String sId) { StudentDetailVO detail = trainStudentService.selectStudentDetail(sId); - return success(detail); + if (detail == null) + { + return success((Object) null); + } + return success(bindingAdminService.toStudentAdminVO(detail)); } @PreAuthorize("@ss.hasPermi('train:student:add')") diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/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/TrainTeacherController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java index 842f89551..799586378 100644 --- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherController.java @@ -11,7 +11,9 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.github.pagehelper.PageInfo; import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.constant.HttpStatus; import com.ruoyi.common.core.controller.BaseController; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.page.PageDomain; @@ -21,7 +23,9 @@ import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.vo.TeacherAdminVO; import com.ruoyi.train.service.ITrainTeacherService; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; /** * 教师信息Controller @@ -30,7 +34,7 @@ import com.ruoyi.train.service.ITrainTeacherService; * @date 2026-09-14 */ @RestController -@RequestMapping("/train/teacher") +@RequestMapping({"/train/teacher", "/train/teachers"}) public class TrainTeacherController extends BaseController { private static final Pattern PHONE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); @@ -38,6 +42,9 @@ public class TrainTeacherController extends BaseController @Autowired private ITrainTeacherService trainTeacherService; + @Autowired + private TrainAccountBindingAdminService bindingAdminService; + /** * 查询教师信息列表 (GET /train/teacher/list) */ @@ -53,7 +60,13 @@ public class TrainTeacherController extends BaseController } startPage(); List list = trainTeacherService.selectTrainTeacherList(trainTeacher); - return getDataTable(list); + List adminList = bindingAdminService.toTeacherAdminList(list); + TableDataInfo rspData = new TableDataInfo(); + rspData.setCode(HttpStatus.SUCCESS); + rspData.setMsg("查询成功"); + rspData.setRows(adminList); + rspData.setTotal(new PageInfo(list).getTotal()); + return rspData; } /** @@ -68,7 +81,7 @@ public class TrainTeacherController extends BaseController { return AjaxResult.error(40401, "教师不存在"); } - return success(teacher); + return success(bindingAdminService.toTeacherAdminVO(teacher)); } /** diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java new file mode 100644 index 000000000..c201a0f66 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainTeacherMeController.java @@ -0,0 +1,177 @@ +package com.ruoyi.web.controller.train; + +import java.util.Date; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import com.ruoyi.common.constant.HttpStatus; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainStudentCheckin; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.query.EnrollmentQuery; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainStudentCheckinMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.service.ITrainingIdentityService; + +/** + * 教师专属自服务控制器 (/train/me/teacher/**)。 + * + *

服务端严格基于当前会话与登录人关联的教师身份推导数据范围,杜绝任何外部主键传入与 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); + } + teacher.setUserId(null); + return success(teacher); + } + + /** + * 获取我参与任教的培训班列表 + */ + @PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')") + @GetMapping("/plans") + public TableDataInfo getMyPlans(@RequestParam(value = "planStatus", required = false) Integer planStatus) + { + String tId = identityService.assertTeacherIdentity(); + startPage(); + List 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/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java new file mode 100644 index 000000000..28ad2d124 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/service/train/TrainAccountBindingAdminService.java @@ -0,0 +1,460 @@ +package com.ruoyi.web.service.train; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.bean.BeanUtils; +import com.ruoyi.system.service.ISysRoleService; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.vo.BindableUserVO; +import com.ruoyi.train.domain.vo.StudentAdminVO; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.domain.vo.TeacherAdminVO; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.service.ITrainingIdentityService; + +/** + * 管理员端业务身份账号绑定编排服务。 + * + *

提供受控候选系统用户检索、角色校验、防静默覆盖保护, + * 并复用底层 {@link ITrainingIdentityService} 执行跨表互斥与并发锁绑定。

+ * + * @author ruoyi + */ +@Service +public class TrainAccountBindingAdminService +{ + private final ITrainingIdentityService identityService; + private final ISysUserService userService; + private final ISysRoleService roleService; + private final TrainTeacherMapper teacherMapper; + private final TrainStudentMapper studentMapper; + + public TrainAccountBindingAdminService(ITrainingIdentityService identityService, + ISysUserService userService, + ISysRoleService roleService, + TrainTeacherMapper teacherMapper, + TrainStudentMapper studentMapper) + { + this.identityService = identityService; + this.userService = userService; + this.roleService = roleService; + this.teacherMapper = teacherMapper; + this.studentMapper = studentMapper; + } + + /** + * 查询候选可绑定系统用户列表 (最小受控字段) + * + * @param targetType 目标身份类型 ("TEACHER" / "STUDENT") + * @param keyword 模糊搜索关键字 (匹配 userName 或 nickName) + * @return 候选用户视图列表 + */ + public List selectBindableUsers(String targetType, String keyword) + { + String expectedRoleKey = "TEACHER".equalsIgnoreCase(targetType) ? "train_teacher" : "train_student"; + SysUser queryUser = new SysUser(); + queryUser.setDelFlag("0"); + if (StringUtils.isNotEmpty(keyword)) + { + queryUser.setUserName(keyword.trim()); + } + List userList = userService.selectUserList(queryUser); + List result = new ArrayList<>(); + + for (SysUser user : userList) + { + // 如果搜索关键字不匹配用户名也不匹配用户昵称,则跳过 + if (StringUtils.isNotEmpty(keyword)) + { + String kw = keyword.trim().toLowerCase(); + boolean matchName = user.getUserName() != null && user.getUserName().toLowerCase().contains(kw); + boolean matchNick = user.getNickName() != null && user.getNickName().toLowerCase().contains(kw); + if (!matchName && !matchNick) + { + continue; + } + } + + BindableUserVO vo = new BindableUserVO(); + vo.setUserId(user.getUserId()); + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + vo.setStatus(user.getStatus()); + + List roles = roleService.selectRolesByUserId(user.getUserId()); + List roleKeys = roles != null + ? roles.stream().map(SysRole::getRoleKey).collect(Collectors.toList()) + : new ArrayList<>(); + vo.setRoleKeys(roleKeys); + + boolean hasTargetRole = roleKeys.contains(expectedRoleKey); + vo.setHasRole(hasTargetRole); + + // 检查当前用户是否已被绑定 + TrainTeacher boundTeacher = teacherMapper.selectTrainTeacherByUserId(user.getUserId()); + TrainStudent boundStudent = studentMapper.selectTrainStudentByUserId(user.getUserId()); + + boolean canBind = true; + String reason = ""; + + if (!"0".equals(user.getStatus())) + { + canBind = false; + reason = "账号已被停用"; + } + else if (!hasTargetRole) + { + canBind = false; + reason = "TEACHER".equalsIgnoreCase(targetType) + ? "未分配教师角色(train_teacher)" + : "未分配学员角色(train_student)"; + } + else if (boundTeacher != null) + { + vo.setBoundType("TEACHER"); + vo.setBoundBusinessId(boundTeacher.gettId()); + canBind = false; + reason = "已被教师(" + boundTeacher.gettName() + " / " + boundTeacher.gettId() + ")绑定"; + } + else if (boundStudent != null) + { + vo.setBoundType("STUDENT"); + vo.setBoundBusinessId(boundStudent.getsId()); + canBind = false; + reason = "已被学员(" + boundStudent.getsName() + " / " + boundStudent.getsId() + ")绑定"; + } + + vo.setCanBind(canBind); + vo.setReason(reason); + result.add(vo); + } + + return result; + } + + /** + * 管理员为教师绑定系统用户 (防静默覆盖、严格校验角色与账号状态) + */ + @Transactional(rollbackFor = Exception.class) + public void bindTeacherAccount(String tId, Long userId) + { + if (userId == null) + { + throw new ServiceException("绑定的系统用户ID不能为空"); + } + TrainTeacher teacher = teacherMapper.selectTrainTeacherByTId(tId); + if (teacher == null) + { + throw new ServiceException("教师业务档案不存在"); + } + if (teacher.getUserId() != null) + { + if (teacher.getUserId().equals(userId)) + { + return; // 幂等支持 + } + throw new ServiceException("该教师档案已关联系统账号(userId=" + teacher.getUserId() + "),请先解除关联后再绑定新账号"); + } + + // 严格校验目标用户有效性与 train_teacher 角色 + validateUserRole(userId, "train_teacher", "教师"); + + // 复用底层原子互斥绑定 + identityService.bindTeacherUser(tId, userId); + } + + /** + * 管理员为教师解绑系统用户 + */ + @Transactional(rollbackFor = Exception.class) + public void unbindTeacherAccount(String tId) + { + TrainTeacher teacher = teacherMapper.selectTrainTeacherByTId(tId); + if (teacher == null) + { + throw new ServiceException("教师业务档案不存在"); + } + if (teacher.getUserId() == null) + { + return; // 幂等支持 + } + identityService.unbindTeacherUser(tId); + } + + /** + * 管理员为学员绑定系统用户 (防静默覆盖、严格校验角色与账号状态) + */ + @Transactional(rollbackFor = Exception.class) + public void bindStudentAccount(String sId, Long userId) + { + if (userId == null) + { + throw new ServiceException("绑定的系统用户ID不能为空"); + } + TrainStudent student = studentMapper.selectTrainStudentBySId(sId); + if (student == null) + { + throw new ServiceException("学员业务档案不存在"); + } + if (student.getUserId() != null) + { + if (student.getUserId().equals(userId)) + { + return; // 幂等支持 + } + throw new ServiceException("该学员档案已关联系统账号(userId=" + student.getUserId() + "),请先解除关联后再绑定新账号"); + } + + // 严格校验目标用户有效性与 train_student 角色 + validateUserRole(userId, "train_student", "学员"); + + // 复用底层原子互斥绑定 + identityService.bindStudentUser(sId, userId); + } + + /** + * 管理员为学员解绑系统用户 + */ + @Transactional(rollbackFor = Exception.class) + public void unbindStudentAccount(String sId) + { + TrainStudent student = studentMapper.selectTrainStudentBySId(sId); + if (student == null) + { + throw new ServiceException("学员业务档案不存在"); + } + if (student.getUserId() == null) + { + return; // 幂等支持 + } + identityService.unbindStudentUser(sId); + } + + /** + * 批量为教师列表装配账号绑定管理视图 (单次批量查询 + 内存映射,杜绝 N+1) + * + * @param list 原始教师列表 + * @return 管理端教师视图列表 + */ + public List toTeacherAdminList(List list) + { + if (list == null || list.isEmpty()) + { + return new ArrayList<>(); + } + + // 1. 批量收集非空 userId + Set userIds = list.stream() + .map(TrainTeacher::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 2. 单次批量查询系统用户并构建映射 Map + Map userMap; + if (!userIds.isEmpty()) + { + List users = userService.selectUserByUserIds(userIds); + userMap = users.stream().collect(Collectors.toMap(SysUser::getUserId, u -> u, (k1, k2) -> k1)); + } + else + { + userMap = Collections.emptyMap(); + } + + // 3. 内存 O(1) 组装 + List result = new ArrayList<>(list.size()); + for (TrainTeacher teacher : list) + { + TeacherAdminVO vo = new TeacherAdminVO(); + BeanUtils.copyBeanProp(vo, teacher); + if (teacher.getUserId() != null) + { + vo.setAccountBindingStatus(1); + SysUser user = userMap.get(teacher.getUserId()); + if (user != null) + { + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + } + } + else + { + vo.setAccountBindingStatus(0); + } + result.add(vo); + } + return result; + } + + /** + * 为单个教师对象装配账号绑定管理视图 + * + * @param teacher 原始教师对象 + * @return 管理端教师视图对象 + */ + public TeacherAdminVO toTeacherAdminVO(TrainTeacher teacher) + { + if (teacher == null) + { + return null; + } + TeacherAdminVO vo = new TeacherAdminVO(); + BeanUtils.copyBeanProp(vo, teacher); + if (teacher.getUserId() != null) + { + vo.setAccountBindingStatus(1); + SysUser user = userService.selectUserById(teacher.getUserId()); + if (user != null) + { + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + } + } + else + { + vo.setAccountBindingStatus(0); + } + return vo; + } + + /** + * 批量为学员列表装配账号绑定管理视图 (单次批量查询 + 内存映射,杜绝 N+1) + * + * @param list 原始学员列表 + * @return 管理端学员视图列表 + */ + public List toStudentAdminList(List list) + { + if (list == null || list.isEmpty()) + { + return new ArrayList<>(); + } + + // 1. 批量收集 sId + Set sIds = list.stream() + .map(StudentItemVO::getsId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // 2. 单次批量查询学员的关联 userId 映射 + Map studentUserMap; + if (!sIds.isEmpty()) + { + List students = studentMapper.selectTrainStudentsBySIds(sIds); + studentUserMap = students.stream() + .filter(s -> s.getUserId() != null) + .collect(Collectors.toMap(TrainStudent::getsId, TrainStudent::getUserId, (k1, k2) -> k1)); + } + else + { + studentUserMap = Collections.emptyMap(); + } + + // 3. 批量查询关联的系统用户 + Set userIds = new HashSet<>(studentUserMap.values()); + Map userMap; + if (!userIds.isEmpty()) + { + List users = userService.selectUserByUserIds(userIds); + userMap = users.stream().collect(Collectors.toMap(SysUser::getUserId, u -> u, (k1, k2) -> k1)); + } + else + { + userMap = Collections.emptyMap(); + } + + // 4. 内存 O(1) 组装 + List result = new ArrayList<>(list.size()); + for (StudentItemVO item : list) + { + StudentAdminVO vo = new StudentAdminVO(); + BeanUtils.copyBeanProp(vo, item); + Long uid = studentUserMap.get(item.getsId()); + if (uid != null) + { + vo.setUserId(uid); + vo.setAccountBindingStatus(1); + SysUser user = userMap.get(uid); + if (user != null) + { + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + } + } + else + { + vo.setAccountBindingStatus(0); + } + result.add(vo); + } + return result; + } + + /** + * 为单个学员详情装配账号绑定管理视图 + * + * @param detail 学员详情对象 + * @return 管理端学员视图对象 + */ + public StudentAdminVO toStudentAdminVO(StudentDetailVO detail) + { + if (detail == null) + { + return null; + } + StudentAdminVO vo = new StudentAdminVO(); + BeanUtils.copyBeanProp(vo, detail); + TrainStudent student = studentMapper.selectTrainStudentBySId(detail.getsId()); + if (student != null && student.getUserId() != null) + { + vo.setUserId(student.getUserId()); + vo.setAccountBindingStatus(1); + SysUser user = userService.selectUserById(student.getUserId()); + if (user != null) + { + vo.setUserName(user.getUserName()); + vo.setNickName(user.getNickName()); + } + } + else + { + vo.setAccountBindingStatus(0); + } + return vo; + } + + private void validateUserRole(Long userId, String requiredRoleKey, String roleLabel) + { + SysUser user = userService.selectUserById(userId); + if (user == null || "2".equals(user.getDelFlag())) + { + throw new ServiceException("指定的系统用户不存在或已删除"); + } + if (!"0".equals(user.getStatus())) + { + throw new ServiceException("指定的系统用户已被停用,禁止关联业务身份"); + } + List roles = roleService.selectRolesByUserId(userId); + boolean hasRole = roles != null && roles.stream().anyMatch(r -> requiredRoleKey.equals(r.getRoleKey())); + if (!hasRole) + { + throw new ServiceException("所选系统账号(" + user.getUserName() + ")未分配" + roleLabel + "角色(" + requiredRoleKey + "),请先在用户管理中分配对应角色"); + } + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/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..88ad7aa43 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/RbacPermissionMigrationContractTest.java @@ -0,0 +1,152 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +/** + * R.2B RBAC 数据库迁移与命名空间对齐静态契约测试 (004 / 005 SQL & Controller Annotation)。 + * + * @author ruoyi + */ +public class RbacPermissionMigrationContractTest +{ + @Test + public void migration004HasPartialUniqueIndexesAndNoDrop() throws Exception + { + String sql = new String(Files.readAllBytes(findMigration("004_rbac_identity_binding.sql")), StandardCharsets.UTF_8).toLowerCase(); + assertContains(sql, + "add column if not exists user_id", + "create unique index if not exists uq_train_teacher_user_id", + "create unique index if not exists uq_train_student_user_id", + "where user_id is not null and del_flag = '0'"); + + // 严禁包含实际的 DROP / TRUNCATE DDL 语句 + assertFalse("must not execute drop index", sql.contains("drop index ")); + assertFalse("must not execute drop table", sql.contains("drop table ")); + assertFalse("must not execute truncate table", sql.contains("truncate table ")); + assertFalse("must not set user_id not null", sql.contains("alter column user_id set not null")); + } + + @Test + public void migration005ProvisionsThreeRolesAndCollisionGuard() throws Exception + { + String sql = new String(Files.readAllBytes(findMigration("005_training_roles_permissions.sql")), StandardCharsets.UTF_8).toLowerCase(); + assertContains(sql, + "collision detected", + "train_admin", + "train_teacher", + "train_student", + "train:board:list", + "train:board:query", + "train:board:add", + "train:board:edit", + "train:board:remove", + "train:board:export", + "train:me:teacher", + "train:me:student", + "train:teacher:binduser", + "train:teacher:unbinduser", + "train:student:binduser", + "train:student:unbinduser", + "insert into public.sys_role_menu"); + + // 严禁物理删除系统用户与无谓 DROP + assertFalse("must not delete sys_user", sql.contains("delete from public.sys_user")); + assertFalse("must not execute drop table", sql.contains("drop table ")); + assertFalse("must not execute truncate", sql.contains("truncate table ")); + } + + @Test + public void accountBindingControllersAlignPermissions() + { + // 校验 TrainAccountBindingAdminController 上的教师与学员绑定解绑权限注解 + boolean teacherBindFound = false; + boolean teacherUnbindFound = false; + boolean studentBindFound = false; + boolean studentUnbindFound = false; + for (Method method : TrainAccountBindingAdminController.class.getDeclaredMethods()) + { + if (method.isAnnotationPresent(PreAuthorize.class)) + { + String perm = method.getAnnotation(PreAuthorize.class).value(); + if (perm.contains("train:teacher:bindUser")) teacherBindFound = true; + if (perm.contains("train:teacher:unbindUser")) teacherUnbindFound = true; + if (perm.contains("train:student:bindUser")) studentBindFound = true; + if (perm.contains("train:student:unbindUser")) studentUnbindFound = true; + } + } + assertTrue("TrainAccountBindingAdminController 必须配置 train:teacher:bindUser", teacherBindFound); + assertTrue("TrainAccountBindingAdminController 必须配置 train:teacher:unbindUser", teacherUnbindFound); + assertTrue("TrainAccountBindingAdminController 必须配置 train:student:bindUser", studentBindFound); + assertTrue("TrainAccountBindingAdminController 必须配置 train:student:unbindUser", studentUnbindFound); + } + + @Test + public void electronicBoardControllerAlignsToTrainNamespace() + { + for (Method method : TrainElectronicBoardController.class.getDeclaredMethods()) + { + if (method.isAnnotationPresent(PreAuthorize.class)) + { + String perm = method.getAnnotation(PreAuthorize.class).value(); + assertTrue("班牌Controller权限必须属于train:board:*命名空间: " + perm, perm.contains("train:board:")); + assertFalse("班牌Controller严禁遗留system:board:*命名空间: " + perm, perm.contains("system:board:")); + } + } + } + + @Test + public void migration005ConfiguresTeacherAndStudentRouteMenusAndRoleBindings() throws Exception + { + String sql = new String(Files.readAllBytes(findMigration("005_training_roles_permissions.sql")), StandardCharsets.UTF_8).toLowerCase(); + + // 验证 2239 教师工作台路由配置 + assertContains(sql, + "2239", + "教师工作台", + "'teacher'", + "'train/me/teacher/index'", + "'c'", + "'train:me:teacher'"); + + // 验证 2240 学员中心路由配置 + assertContains(sql, + "2240", + "学员中心", + "'student'", + "'train/me/student/index'", + "'c'", + "'train:me:student'"); + + // 验证教师角色 (role_id=4) 与学员角色 (role_id=5) 的专属菜单绑定 + assertContains(sql, + "select 4, 2239", + "select 5, 2240"); + } + + private Path findMigration(String filename) + { + for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent()) + { + Path candidate = path.resolve("sql/system/kingbase/" + filename); + if (Files.isRegularFile(candidate)) return candidate; + } + throw new IllegalStateException(filename + " migration not found from working directory"); + } + + private void assertContains(String sql, String... tokens) + { + for (String token : tokens) + { + assertTrue("missing migration token: " + token, sql.contains(token)); + } + } +} 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..4834ab501 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/StudentSelfServiceApiTest.java @@ -0,0 +1,346 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainClassReport; +import com.ruoyi.train.domain.TrainStudentCheckin; +import com.ruoyi.train.domain.TrainStudentPortrait; +import com.ruoyi.train.domain.TrainStudyDuration; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; +import com.ruoyi.train.mapper.TrainClassReportMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainStudentCheckinMapper; +import com.ruoyi.train.mapper.TrainStudentPortraitMapper; +import com.ruoyi.train.mapper.TrainStudyDurationMapper; +import com.ruoyi.train.service.ITrainStudentService; +import com.ruoyi.train.service.ITrainingIdentityService; + +/** + * 学员专属个人中心接口契约与自服务推导单元测试。 + * + * @author ruoyi + */ +public class StudentSelfServiceApiTest +{ + private TrainStudentMeController controller; + private ITrainingIdentityService identityService; + private ITrainStudentService studentService; + private TrainEnrollmentMapper enrollmentMapper; + private TrainLessonMapper lessonMapper; + private TrainClassReportMapper reportMapper; + private TrainStudentCheckinMapper checkinMapper; + private TrainStudyDurationMapper durationMapper; + private TrainStudentPortraitMapper portraitMapper; + + private String currentStudentId = "S_CURRENT"; + + @Before + public void setUp() + { + identityService = (ITrainingIdentityService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ITrainingIdentityService.class}, + (proxy, method, args) -> { + if ("assertStudentIdentity".equals(method.getName())) + { + if (currentStudentId == null) + { + throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", 403); + } + return currentStudentId; + } + return null; + } + ); + + studentService = (ITrainStudentService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ITrainStudentService.class}, + (proxy, method, args) -> { + if ("selectStudentDetail".equals(method.getName())) + { + StudentDetailVO detail = new StudentDetailVO(); + detail.setsId((String) args[0]); + detail.setsName("李四"); + detail.setIdCardLast4("5678"); + detail.setIdCardMasked("****************5678"); + detail.setOrgName("供电段"); + return detail; + } + return null; + } + ); + + enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + if ("selectEnrollmentsByStudentId".equals(method.getName())) + { + List 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()); + } + } + + @Test + public void testStudentProfileZeroLeakageOfAdminBindingFields() throws Exception + { + AjaxResult result = controller.getStudentProfile(); + assertEquals(200, result.get("code")); + Object data = result.get("data"); + assertTrue(data instanceof StudentDetailVO); + + Class clazz = data.getClass(); + while (clazz != null && clazz != Object.class) + { + for (java.lang.reflect.Field field : clazz.getDeclaredFields()) + { + assertFalse("学员自服务视图绝不可暴露 userId", "userId".equals(field.getName())); + assertFalse("学员自服务视图绝不可暴露 userName", "userName".equals(field.getName())); + assertFalse("学员自服务视图绝不可暴露 nickName", "nickName".equals(field.getName())); + assertFalse("学员自服务视图绝不可暴露 accountBindingStatus", "accountBindingStatus".equals(field.getName())); + } + clazz = clazz.getSuperclass(); + } + + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + String json = mapper.writeValueAsString(data); + assertFalse("JSON不得包含 userId", json.contains("\"userId\"")); + assertFalse("JSON不得包含 userName", json.contains("\"userName\"")); + assertFalse("JSON不得包含 nickName", json.contains("\"nickName\"")); + assertFalse("JSON不得包含 accountBindingStatus", json.contains("\"accountBindingStatus\"")); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java new file mode 100644 index 000000000..87fc37a1a --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TeacherSelfServiceApiTest.java @@ -0,0 +1,306 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainLesson; +import com.ruoyi.train.domain.TrainStudentCheckin; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.query.EnrollmentQuery; +import com.ruoyi.train.domain.vo.EnrollmentItemVO; +import com.ruoyi.train.domain.vo.TimetableLessonItemVO; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainLessonMapper; +import com.ruoyi.train.mapper.TrainStudentCheckinMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.service.ITrainingIdentityService; + +/** + * 教师专属自服务接口契约与 IDOR 越权防护测试。 + * + * @author ruoyi + */ +public class TeacherSelfServiceApiTest +{ + private TrainTeacherMeController controller; + private ITrainingIdentityService identityService; + private TrainTeacherMapper teacherMapper; + private TrainLessonMapper lessonMapper; + private TrainEnrollmentMapper enrollmentMapper; + private TrainStudentCheckinMapper checkinMapper; + + private String currentTeacherId = "T_CURRENT"; + + @Before + public void setUp() + { + identityService = (ITrainingIdentityService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ITrainingIdentityService.class}, + (proxy, method, args) -> { + if ("assertTeacherIdentity".equals(method.getName())) + { + if (currentTeacherId == null) + { + throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", 403); + } + return currentTeacherId; + } + return null; + } + ); + + teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainTeacherMapper.class}, + (proxy, method, args) -> { + if ("selectTrainTeacherByTId".equals(method.getName())) + { + TrainTeacher teacher = new TrainTeacher(); + teacher.setTId((String) args[0]); + teacher.settName("王老师"); + teacher.setOrgName("机务段"); + return teacher; + } + return null; + } + ); + + lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainLessonMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("countTeacherInPlan".equals(name)) + { + String tId = (String) args[0]; + String planId = (String) args[1]; + // 仅当教师是 T_CURRENT 且班级是 PLAN_MY 时有任教关系 + return ("T_CURRENT".equals(tId) && "PLAN_MY".equals(planId)) ? 1 : 0; + } + if ("selectPlansByTeacherId".equals(name)) + { + List 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()); + } + + @Test + public void testTeacherProfileZeroLeakageOfAdminBindingFields() throws Exception + { + AjaxResult result = controller.getTeacherProfile(); + assertEquals(200, result.get("code")); + TrainTeacher teacher = (TrainTeacher) result.get("data"); + // 验证返回前 userId 已置 null + assertEquals(null, teacher.getUserId()); + + // 验证反射中 TrainTeacher 绝不存在管理端字段 + for (java.lang.reflect.Field field : TrainTeacher.class.getDeclaredFields()) + { + assertFalse("TrainTeacher 绝不可包含 userName", "userName".equals(field.getName())); + assertFalse("TrainTeacher 绝不可包含 nickName", "nickName".equals(field.getName())); + assertFalse("TrainTeacher 绝不可包含 accountBindingStatus", "accountBindingStatus".equals(field.getName())); + } + + // 验证 JSON 序列化零泄露 + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + String json = mapper.writeValueAsString(teacher); + assertFalse("教师自服务响应 JSON 绝不可包含 userId", json.contains("\"userId\"")); + assertFalse("教师自服务响应 JSON 绝不可包含 userName", json.contains("\"userName\"")); + assertFalse("教师自服务响应 JSON 绝不可包含 nickName", json.contains("\"nickName\"")); + assertFalse("教师自服务响应 JSON 绝不可包含 accountBindingStatus", json.contains("\"accountBindingStatus\"")); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminTest.java new file mode 100644 index 000000000..acba8dad8 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/TrainAccountBindingAdminTest.java @@ -0,0 +1,459 @@ +package com.ruoyi.web.controller.train; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.core.domain.AjaxResult; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.system.service.ISysRoleService; +import com.ruoyi.system.service.ISysUserService; +import com.ruoyi.train.domain.TrainStudent; +import com.ruoyi.train.domain.TrainTeacher; +import com.ruoyi.train.domain.dto.UserBindingDTO; +import com.ruoyi.train.domain.vo.BindableUserVO; +import com.ruoyi.train.domain.vo.StudentAdminVO; +import com.ruoyi.train.domain.vo.StudentDetailVO; +import com.ruoyi.train.domain.vo.StudentItemVO; +import com.ruoyi.train.domain.vo.TeacherAdminVO; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.mapper.TrainTeacherMapper; +import com.ruoyi.train.service.ITrainingIdentityService; +import com.ruoyi.web.service.train.TrainAccountBindingAdminService; + +/** + * 管理员端账号绑定业务编排与 Controller 接口测试。 + * + *

测试用例覆盖: + * 1. 候选用户查询与状态校验(包含已停用、缺失角色、已被其他教师或学员绑定的拒绝理由) + * 2. 教师绑定:正常绑定、角色不匹配拒绝、已被绑定防静默覆盖拒绝、同账号幂等 + * 3. 教师解绑:正常解绑、未绑定幂等解绑 + * 4. 学员绑定:正常绑定、角色不匹配拒绝、已被绑定防静默覆盖拒绝、同账号幂等 + * 5. 学员解绑:正常解绑、未绑定幂等解绑 + * 6. 列表信息装配 (enrich) 校验 + *

+ * + * @author ruoyi + */ +public class TrainAccountBindingAdminTest +{ + private TrainAccountBindingAdminService service; + private TrainAccountBindingAdminController controller; + + private Map userStore = new HashMap<>(); + private Map> roleStore = new HashMap<>(); + private Map teacherStore = new HashMap<>(); + private Map studentStore = new HashMap<>(); + + private List identityServiceCallLog = new ArrayList<>(); + + @Before + public void setUp() + { + userStore.clear(); + roleStore.clear(); + teacherStore.clear(); + studentStore.clear(); + identityServiceCallLog.clear(); + + // 构造用户数据 + // 1. teacherUser (具备 train_teacher 角色, 正常) + SysUser u1 = new SysUser(); + u1.setUserId(101L); + u1.setUserName("teacher_zhang"); + u1.setNickName("张老师"); + u1.setStatus("0"); + u1.setDelFlag("0"); + userStore.put(101L, u1); + + SysRole rTeacher = new SysRole(); + rTeacher.setRoleId(4L); + rTeacher.setRoleKey("train_teacher"); + roleStore.put(101L, Collections.singletonList(rTeacher)); + + // 2. studentUser (具备 train_student 角色, 正常) + SysUser u2 = new SysUser(); + u2.setUserId(102L); + u2.setUserName("student_li"); + u2.setNickName("李学员"); + u2.setStatus("0"); + u2.setDelFlag("0"); + userStore.put(102L, u2); + + SysRole rStudent = new SysRole(); + rStudent.setRoleId(5L); + rStudent.setRoleKey("train_student"); + roleStore.put(102L, Collections.singletonList(rStudent)); + + // 3. disabledUser (具备角色但已停用) + SysUser u3 = new SysUser(); + u3.setUserId(103L); + u3.setUserName("disabled_user"); + u3.setNickName("已停用用户"); + u3.setStatus("1"); + u3.setDelFlag("0"); + userStore.put(103L, u3); + roleStore.put(103L, Collections.singletonList(rTeacher)); + + // 4. normalUser (无培训角色) + SysUser u4 = new SysUser(); + u4.setUserId(104L); + u4.setUserName("common_user"); + u4.setNickName("普通用户"); + u4.setStatus("0"); + u4.setDelFlag("0"); + userStore.put(104L, u4); + SysRole rCommon = new SysRole(); + rCommon.setRoleId(2L); + rCommon.setRoleKey("common"); + roleStore.put(104L, Collections.singletonList(rCommon)); + + // 教师业务档案 + TrainTeacher t1 = new TrainTeacher(); + t1.settId("T1001"); + t1.settName("张老师档案"); + t1.setStatus(1); + t1.setDelFlag("0"); + teacherStore.put("T1001", t1); + + // 学员业务档案 + TrainStudent s1 = new TrainStudent(); + s1.setsId("S2001"); + s1.setsName("李学员档案"); + s1.setDelFlag("0"); + studentStore.put("S2001", s1); + + // Dynamic Proxy for ISysUserService + ISysUserService userService = (ISysUserService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ISysUserService.class}, + (proxy, method, args) -> { + if ("selectUserById".equals(method.getName())) { + return userStore.get((Long) args[0]); + } else if ("selectUserList".equals(method.getName())) { + return new ArrayList<>(userStore.values()); + } else if ("selectUserByUserIds".equals(method.getName())) { + @SuppressWarnings("unchecked") + java.util.Collection uids = (java.util.Collection) args[0]; + List res = new ArrayList<>(); + for (Long id : uids) { + SysUser u = userStore.get(id); + if (u != null) res.add(u); + } + return res; + } + return null; + }); + + // Dynamic Proxy for ISysRoleService + ISysRoleService roleService = (ISysRoleService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ISysRoleService.class}, + (proxy, method, args) -> { + if ("selectRolesByUserId".equals(method.getName())) { + return roleStore.getOrDefault((Long) args[0], Collections.emptyList()); + } + return Collections.emptyList(); + }); + + // Dynamic Proxy for TrainTeacherMapper + TrainTeacherMapper teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainTeacherMapper.class}, + (proxy, method, args) -> { + if ("selectTrainTeacherByTId".equals(method.getName())) { + return teacherStore.get((String) args[0]); + } else if ("selectTrainTeacherByUserId".equals(method.getName())) { + Long uid = (Long) args[0]; + return teacherStore.values().stream() + .filter(t -> uid.equals(t.getUserId())) + .findFirst().orElse(null); + } + return null; + }); + + // Dynamic Proxy for TrainStudentMapper + TrainStudentMapper studentMapper = (TrainStudentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainStudentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainStudentBySId".equals(method.getName())) { + return studentStore.get((String) args[0]); + } else if ("selectTrainStudentByUserId".equals(method.getName())) { + Long uid = (Long) args[0]; + return studentStore.values().stream() + .filter(s -> uid.equals(s.getUserId())) + .findFirst().orElse(null); + } else if ("selectTrainStudentsBySIds".equals(method.getName())) { + @SuppressWarnings("unchecked") + java.util.Collection sIds = (java.util.Collection) args[0]; + List res = new ArrayList<>(); + for (String sid : sIds) { + TrainStudent s = studentStore.get(sid); + if (s != null) res.add(s); + } + return res; + } + return null; + }); + + // Dynamic Proxy for ITrainingIdentityService + ITrainingIdentityService identityService = (ITrainingIdentityService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{ITrainingIdentityService.class}, + (proxy, method, args) -> { + identityServiceCallLog.add(method.getName() + ":" + args[0] + ":" + (args.length > 1 ? args[1] : "")); + if ("bindTeacherUser".equals(method.getName())) { + TrainTeacher teacher = teacherStore.get((String) args[0]); + if (teacher != null) teacher.setUserId((Long) args[1]); + } else if ("unbindTeacherUser".equals(method.getName())) { + TrainTeacher teacher = teacherStore.get((String) args[0]); + if (teacher != null) teacher.setUserId(null); + } else if ("bindStudentUser".equals(method.getName())) { + TrainStudent student = studentStore.get((String) args[0]); + if (student != null) student.setUserId((Long) args[1]); + } else if ("unbindStudentUser".equals(method.getName())) { + TrainStudent student = studentStore.get((String) args[0]); + if (student != null) student.setUserId(null); + } + return null; + }); + + service = new TrainAccountBindingAdminService(identityService, userService, roleService, teacherMapper, studentMapper); + controller = new TrainAccountBindingAdminController(); + try { + java.lang.reflect.Field field = TrainAccountBindingAdminController.class.getDeclaredField("bindingAdminService"); + field.setAccessible(true); + field.set(controller, service); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void testSelectBindableUsersEvaluatesRolesAndStatus() + { + List teacherCandidates = service.selectBindableUsers("TEACHER", null); + assertNotNull(teacherCandidates); + assertFalse(teacherCandidates.isEmpty()); + + // teacher_zhang (101) 应该可以绑定 + BindableUserVO u101 = teacherCandidates.stream().filter(u -> u.getUserId().equals(101L)).findFirst().orElse(null); + assertNotNull(u101); + assertTrue("具备 train_teacher 角色的账号应当可绑定", u101.getCanBind()); + assertTrue(u101.getHasRole()); + + // student_li (102) 不可作为教师绑定 + BindableUserVO u102 = teacherCandidates.stream().filter(u -> u.getUserId().equals(102L)).findFirst().orElse(null); + assertNotNull(u102); + assertFalse("学员角色不可绑定教师", u102.getCanBind()); + assertTrue(u102.getReason().contains("未分配教师角色")); + + // disabled_user (103) 停用状态不可绑定 + BindableUserVO u103 = teacherCandidates.stream().filter(u -> u.getUserId().equals(103L)).findFirst().orElse(null); + assertNotNull(u103); + assertFalse("已停用账号不可绑定", u103.getCanBind()); + assertTrue(u103.getReason().contains("停用")); + + // common_user (104) 无培训角色不可绑定 + BindableUserVO u104 = teacherCandidates.stream().filter(u -> u.getUserId().equals(104L)).findFirst().orElse(null); + assertNotNull(u104); + assertFalse("普通用户无角色不可绑定", u104.getCanBind()); + } + + @Test + public void testBindTeacherSuccessAndAntiSilentOverwrite() + { + // 1. 正常绑定 + service.bindTeacherAccount("T1001", 101L); + assertEquals(Long.valueOf(101L), teacherStore.get("T1001").getUserId()); + assertTrue(identityServiceCallLog.contains("bindTeacherUser:T1001:101")); + + // 2. 幂等重复绑定相同账号:直接成功且不抛异常 + service.bindTeacherAccount("T1001", 101L); + + // 3. 静默覆盖保护:已有绑定时尝试绑定另一个账号应抛出 ServiceException + try { + service.bindTeacherAccount("T1001", 102L); + fail("已有绑定时应拒绝直接覆盖"); + } catch (ServiceException se) { + assertTrue(se.getMessage().contains("请先解除关联后再绑定新账号")); + } + + // 4. 解绑后可重新绑定 + service.unbindTeacherAccount("T1001"); + assertEquals(null, teacherStore.get("T1001").getUserId()); + + // 5. 再次解绑:幂等不报错 + service.unbindTeacherAccount("T1001"); + } + + @Test + public void testBindTeacherRejectsMismatchedRole() + { + try { + service.bindTeacherAccount("T1001", 102L); // 102 是学员 + fail("应当拒绝绑定未分配教师角色的系统账号"); + } catch (ServiceException se) { + assertTrue(se.getMessage().contains("未分配教师角色")); + } + } + + @Test + public void testBindStudentSuccessAndAntiSilentOverwrite() + { + // 1. 正常绑定 + service.bindStudentAccount("S2001", 102L); + assertEquals(Long.valueOf(102L), studentStore.get("S2001").getUserId()); + assertTrue(identityServiceCallLog.contains("bindStudentUser:S2001:102")); + + // 2. 幂等重复绑定相同账号 + service.bindStudentAccount("S2001", 102L); + + // 3. 静默覆盖保护 + try { + service.bindStudentAccount("S2001", 101L); + fail("已有绑定时应拒绝直接覆盖"); + } catch (ServiceException se) { + assertTrue(se.getMessage().contains("请先解除关联后再绑定新账号")); + } + + // 4. 解绑 + service.unbindStudentAccount("S2001"); + assertEquals(null, studentStore.get("S2001").getUserId()); + + // 5. 幂等解绑 + service.unbindStudentAccount("S2001"); + } + + @Test + public void testBindStudentRejectsMismatchedRole() + { + try { + service.bindStudentAccount("S2001", 101L); // 101 是教师 + fail("应当拒绝绑定未分配学员角色的系统账号"); + } catch (ServiceException se) { + assertTrue(se.getMessage().contains("未分配学员角色")); + } + } + + @Test + public void testControllerEndpointsDelegateToService() + { + // 测试 Controller 教师绑定调用 + UserBindingDTO dto = new UserBindingDTO(); + dto.setUserId(101L); + AjaxResult res = controller.bindTeacherUser("T1001", dto); + assertEquals(200, res.get("code")); + assertEquals(Long.valueOf(101L), teacherStore.get("T1001").getUserId()); + + // 测试 Controller 教师候选人查询 + AjaxResult candidateRes = controller.getTeacherBindableUsers(null); + assertEquals(200, candidateRes.get("code")); + + // 测试 Controller 教师解绑调用 + AjaxResult unbindRes = controller.unbindTeacherUser("T1001"); + assertEquals(200, unbindRes.get("code")); + assertEquals(null, teacherStore.get("T1001").getUserId()); + + // 测试 Controller 学员绑定调用 + UserBindingDTO sDto = new UserBindingDTO(); + sDto.setUserId(102L); + AjaxResult sRes = controller.bindStudentUser("S2001", sDto); + assertEquals(200, sRes.get("code")); + assertEquals(Long.valueOf(102L), studentStore.get("S2001").getUserId()); + + // 测试 Controller 学员解绑调用 + AjaxResult sUnbindRes = controller.unbindStudentUser("S2001"); + assertEquals(200, sUnbindRes.get("code")); + assertEquals(null, studentStore.get("S2001").getUserId()); + } + + @Test + public void testTeacherAndStudentAdminVOAssemblyAndZeroLeakage() + { + // 1. 教师列表批量装配与单体验证 + TrainTeacher t1 = new TrainTeacher(); + t1.settId("T1001"); + t1.setUserId(101L); + t1.settName("张老师"); + + TrainTeacher tUnbound = new TrainTeacher(); + tUnbound.settId("T1002"); + tUnbound.settName("未绑定教师"); + + List teacherList = new ArrayList<>(); + teacherList.add(t1); + teacherList.add(tUnbound); + + List teacherAdminList = service.toTeacherAdminList(teacherList); + assertEquals(2, teacherAdminList.size()); + + TeacherAdminVO vo1 = teacherAdminList.get(0); + assertEquals(Integer.valueOf(1), vo1.getAccountBindingStatus()); + assertEquals("teacher_zhang", vo1.getUserName()); + assertEquals("张老师", vo1.getNickName()); + + TeacherAdminVO voUnbound = teacherAdminList.get(1); + assertEquals(Integer.valueOf(0), voUnbound.getAccountBindingStatus()); + assertEquals(null, voUnbound.getUserName()); + + TeacherAdminVO singleTeacherVO = service.toTeacherAdminVO(t1); + assertEquals(Integer.valueOf(1), singleTeacherVO.getAccountBindingStatus()); + assertEquals("teacher_zhang", singleTeacherVO.getUserName()); + + // 2. 学员列表批量装配与单体验证 + studentStore.get("S2001").setUserId(102L); + + TrainStudent sUnboundStudent = new TrainStudent(); + sUnboundStudent.setsId("S2002"); + sUnboundStudent.setsName("未绑定学员"); + studentStore.put("S2002", sUnboundStudent); + + StudentItemVO sItem1 = new StudentItemVO(); + sItem1.setsId("S2001"); + sItem1.setsName("李学员"); + + StudentItemVO sItemUnbound = new StudentItemVO(); + sItemUnbound.setsId("S2002"); + sItemUnbound.setsName("未绑定学员"); + + List studentItemList = new ArrayList<>(); + studentItemList.add(sItem1); + studentItemList.add(sItemUnbound); + + List studentAdminList = service.toStudentAdminList(studentItemList); + assertEquals(2, studentAdminList.size()); + + StudentAdminVO sAdmin1 = studentAdminList.get(0); + assertEquals(Long.valueOf(102L), sAdmin1.getUserId()); + assertEquals(Integer.valueOf(1), sAdmin1.getAccountBindingStatus()); + assertEquals("student_li", sAdmin1.getUserName()); + assertEquals("李学员", sAdmin1.getNickName()); + + StudentAdminVO sAdminUnbound = studentAdminList.get(1); + assertEquals(null, sAdminUnbound.getUserId()); + assertEquals(Integer.valueOf(0), sAdminUnbound.getAccountBindingStatus()); + assertEquals(null, sAdminUnbound.getUserName()); + + StudentDetailVO sDetail = new StudentDetailVO(); + sDetail.setsId("S2001"); + sDetail.setsName("李学员"); + StudentAdminVO singleStudentVO = service.toStudentAdminVO(sDetail); + assertEquals(Long.valueOf(102L), singleStudentVO.getUserId()); + assertEquals(Integer.valueOf(1), singleStudentVO.getAccountBindingStatus()); + assertEquals("student_li", singleStudentVO.getUserName()); + } +} diff --git a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java index 543c59cb6..8a3f17e54 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java +++ b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/mapper/SysUserMapper.java @@ -52,6 +52,14 @@ public interface SysUserMapper */ public SysUser selectUserById(Long userId); + /** + * 根据用户ID列表批量查询用户 + * + * @param userIds 用户ID集合 + * @return 用户列表 + */ + public List selectUserByUserIds(@Param("userIds") java.util.Collection userIds); + /** * 新增用户信息 * diff --git a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java index 067245bd9..592651d3a 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java +++ b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/ISysUserService.java @@ -51,6 +51,14 @@ public interface ISysUserService */ public SysUser selectUserById(Long userId); + /** + * 根据用户ID列表批量查询用户 + * + * @param userIds 用户ID集合 + * @return 用户列表 + */ + public List selectUserByUserIds(java.util.Collection userIds); + /** * 根据用户ID查询用户所属角色组 * diff --git a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java index 7b83d0422..56e0b681d 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java +++ b/RuoYi-Vue/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/SysUserServiceImpl.java @@ -129,6 +129,22 @@ public class SysUserServiceImpl implements ISysUserService return userMapper.selectUserById(userId); } + /** + * 根据用户ID列表批量查询用户 + * + * @param userIds 用户ID集合 + * @return 用户列表 + */ + @Override + public List selectUserByUserIds(java.util.Collection userIds) + { + if (userIds == null || userIds.isEmpty()) + { + return java.util.Collections.emptyList(); + } + return userMapper.selectUserByUserIds(userIds); + } + /** * 查询用户所属角色组 * diff --git a/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml b/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml index f4ecdf714..9e11cab46 100644 --- a/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/RuoYi-Vue/ruoyi-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -130,6 +130,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where u.user_id = #{userId} + + + + where e.student_id = #{studentId} + order by r.report_time desc, r.report_id desc + limit 1 + 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..b82650817 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 @@ -61,6 +61,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where s_id = #{sId} and del_flag = '0' + + @@ -76,4 +84,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..a9afa5f09 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java @@ -941,6 +941,24 @@ public class EnrollmentAppServiceImplTest return studentStore.get(sId); } + @Override + public List selectTrainStudentsBySIds(java.util.Collection sIds) + { + List list = new ArrayList<>(); + if (sIds != null) + { + for (String sId : sIds) + { + TrainStudent s = studentStore.get(sId); + if (s != null) + { + list.add(s); + } + } + } + return list; + } + @Override public List selectTrainStudentList(StudentQuery query) { @@ -959,6 +977,30 @@ public class EnrollmentAppServiceImplTest studentStore.put(student.getsId(), student); return 1; } + + @Override + public TrainStudent selectTrainStudentByUserId(Long userId) + { + return null; + } + + @Override + public int countByUserId(Long userId) + { + return 0; + } + + @Override + public int bindUserId(String sId, Long userId) + { + return 0; + } + + @Override + public int unbindUserId(String sId) + { + return 0; + } } private static class MockTrainEnrollmentMapper implements TrainEnrollmentMapper @@ -1090,5 +1132,11 @@ public class EnrollmentAppServiceImplTest { return 1; } + + @Override + public List 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..7e93937ca 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/TrainStudentServiceImplTest.java @@ -258,6 +258,24 @@ public class TrainStudentServiceImplTest return store.get(sId); } + @Override + public List selectTrainStudentsBySIds(java.util.Collection sIds) + { + List list = new ArrayList<>(); + if (sIds != null) + { + for (String sId : sIds) + { + TrainStudent s = store.get(sId); + if (s != null) + { + list.add(s); + } + } + } + return list; + } + @Override public List selectTrainStudentList(StudentQuery query) { @@ -288,5 +306,29 @@ public class TrainStudentServiceImplTest store.put(student.getsId(), student); return 1; } + + @Override + public TrainStudent selectTrainStudentByUserId(Long userId) + { + return null; + } + + @Override + public int countByUserId(Long userId) + { + return 0; + } + + @Override + public int bindUserId(String sId, Long userId) + { + return 0; + } + + @Override + public int unbindUserId(String sId) + { + return 0; + } } } 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..d08afcebe --- /dev/null +++ b/RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql @@ -0,0 +1,334 @@ +-- ============================================================================= +-- RailwaySystem 数据库 RBAC 迁移脚本: 005_training_roles_permissions.sql +-- 适用数据库: KingbaseES V009R003C018 (schema: public) +-- 执行目标: 预置培训业务三角色、统一班牌权限命名空间、补齐按钮权限与角色关联 +-- 事务边界: 全程在显式事务 BEGIN ... COMMIT 中执行 +-- 防御保证: 内置 Collision Guard 静态检查,绝不覆盖已有外来资源,冲突即显式 ABORT +-- 设计依据: docs/development/rbac-r2/02_role_permission_matrix.md 及 06_migration_plan.md +-- ============================================================================= + +BEGIN; + +-- ============================================================================= +-- 步骤 0: ID 碰撞安全守卫 (Migration Collision Guard) +-- ============================================================================= +DO $$ +DECLARE + rec RECORD; +BEGIN + -- 0.1 检查 role_id 3, 4, 5 的碰撞与业务键一致性 + FOR rec IN SELECT role_id, role_key FROM public.sys_role WHERE role_id IN (3, 4, 5) + LOOP + IF rec.role_id = 3 AND rec.role_key <> 'train_admin' THEN + RAISE EXCEPTION 'Collision detected: sys_role.role_id=3 is already occupied by role_key=%', rec.role_key; + ELSIF rec.role_id = 4 AND rec.role_key <> 'train_teacher' THEN + RAISE EXCEPTION 'Collision detected: sys_role.role_id=4 is already occupied by role_key=%', rec.role_key; + ELSIF rec.role_id = 5 AND rec.role_key <> 'train_student' THEN + RAISE EXCEPTION 'Collision detected: sys_role.role_id=5 is already occupied by role_key=%', rec.role_key; + END IF; + END LOOP; + + -- 0.2 检查 role_key 存在时是否与预期 role_id 发生错位 + FOR rec IN SELECT role_id, role_key FROM public.sys_role WHERE role_key IN ('train_admin', 'train_teacher', 'train_student') + LOOP + IF rec.role_key = 'train_admin' AND rec.role_id <> 3 THEN + RAISE EXCEPTION 'Collision detected: role_key=train_admin exists with mismatched role_id=%', rec.role_id; + ELSIF rec.role_key = 'train_teacher' AND rec.role_id <> 4 THEN + RAISE EXCEPTION 'Collision detected: role_key=train_teacher exists with mismatched role_id=%', rec.role_id; + ELSIF rec.role_key = 'train_student' AND rec.role_id <> 5 THEN + RAISE EXCEPTION 'Collision detected: role_key=train_student exists with mismatched role_id=%', rec.role_id; + END IF; + END LOOP; + + -- 0.3 检查 menu_id 2201 ~ 2244 的碰撞:若ID已存在,必须属于预期本系统权限,严禁覆盖外来资源 + IF EXISTS ( + SELECT 1 FROM public.sys_menu + WHERE menu_id BETWEEN 2201 AND 2244 + AND (perms IS NULL OR perms NOT LIKE 'train:%') + ) THEN + RAISE EXCEPTION 'Collision detected: sys_menu.menu_id in 2201..2244 is occupied by non-train permissions'; + END IF; +END $$; + +-- ============================================================================= +-- 步骤 1: 预置 3 个核心业务角色 (train_admin, train_teacher, train_student) +-- ============================================================================= +INSERT INTO public.sys_role + (role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly, + status, del_flag, create_by, create_time, update_by, update_time, remark) +SELECT 3, '培训管理员', 'train_admin', 3, '1', 1, 1, + '0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '一期业务全局管理角色' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 3 OR role_key = 'train_admin'); + +INSERT INTO public.sys_role + (role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly, + status, del_flag, create_by, create_time, update_by, update_time, remark) +SELECT 4, '培训教师', 'train_teacher', 4, '2', 1, 1, + '0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '教师专属教学工作台' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 4 OR role_key = 'train_teacher'); + +INSERT INTO public.sys_role + (role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly, + status, del_flag, create_by, create_time, update_by, update_time, remark) +SELECT 5, '培训学员', 'train_student', 5, '2', 1, 1, + '0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '学员专属个人培训中心' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 5 OR role_key = 'train_student'); + +-- ============================================================================= +-- 步骤 2: 规范统一电子班牌权限命名空间 (system:board:* -> train:board:*) +-- ============================================================================= +UPDATE public.sys_menu +SET perms = REPLACE(perms, 'system:board:', 'train:board:'), + update_by = 'admin', + update_time = CURRENT_TIMESTAMP +WHERE perms LIKE 'system:board:%'; + +-- ============================================================================= +-- 步骤 3: 补齐基础培训管理模块按钮级权限 (sys_menu, menu_type='F') +-- ============================================================================= + +-- 3.1 教师管理 (parent: 2155) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2201, '教师查询', 2155, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:list', '#', 'admin', CURRENT_TIMESTAMP, '教师列表查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2201 OR perms = 'train:teacher:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2202, '教师详情', 2155, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:query', '#', 'admin', CURRENT_TIMESTAMP, '教师详情查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2202 OR perms = 'train:teacher:query'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2203, '教师新增', 2155, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:add', '#', 'admin', CURRENT_TIMESTAMP, '教师新增' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2203 OR perms = 'train:teacher:add'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2204, '教师修改', 2155, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:edit', '#', 'admin', CURRENT_TIMESTAMP, '教师修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2204 OR perms = 'train:teacher:edit'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2241, '教师账号关联', 2155, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:bindUser', '#', 'admin', CURRENT_TIMESTAMP, '教师系统账号关联' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2241 OR perms = 'train:teacher:bindUser'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2242, '教师账号解绑', 2155, 6, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:unbindUser', '#', 'admin', CURRENT_TIMESTAMP, '教师系统账号解绑' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2242 OR perms = 'train:teacher:unbindUser'); + +-- 3.2 学员管理 (parent: 2156) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2205, '学员查询', 2156, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:list', '#', 'admin', CURRENT_TIMESTAMP, '学员列表查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2205 OR perms = 'train:student:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2206, '学员详情', 2156, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:query', '#', 'admin', CURRENT_TIMESTAMP, '学员详情查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2206 OR perms = 'train:student:query'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2207, '学员新增', 2156, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:add', '#', 'admin', CURRENT_TIMESTAMP, '学员新增' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2207 OR perms = 'train:student:add'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2243, '学员账号关联', 2156, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:bindUser', '#', 'admin', CURRENT_TIMESTAMP, '学员系统账号关联' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2243 OR perms = 'train:student:bindUser'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2244, '学员账号解绑', 2156, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:unbindUser', '#', 'admin', CURRENT_TIMESTAMP, '学员系统账号解绑' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2244 OR perms = 'train:student:unbindUser'); + +-- 3.3 教室管理 (parent: 2157) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2208, '教室查询', 2157, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:classroom:list', '#', 'admin', CURRENT_TIMESTAMP, '教室列表查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2208 OR perms = 'train:classroom:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2209, '教室修改', 2157, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:classroom:edit', '#', 'admin', CURRENT_TIMESTAMP, '教室修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2209 OR perms = 'train:classroom:edit'); + +-- 3.4 实训场地 (parent: 2158) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2210, '场地查询', 2158, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:site:list', '#', 'admin', CURRENT_TIMESTAMP, '实训场地列表查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2210 OR perms = 'train:site:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2211, '场地修改', 2158, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:site:edit', '#', 'admin', CURRENT_TIMESTAMP, '实训场地修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2211 OR perms = 'train:site:edit'); + +-- 3.5 宿舍管理 (parent: 2161) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2212, '宿舍查询', 2161, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:dormitory:list', '#', 'admin', CURRENT_TIMESTAMP, '宿舍列表查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2212 OR perms = 'train:dormitory:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2213, '宿舍修改', 2161, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:dormitory:edit', '#', 'admin', CURRENT_TIMESTAMP, '宿舍修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2213 OR perms = 'train:dormitory:edit'); + +-- 3.6 课程管理 (parent: 2163) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2214, '课程查询', 2163, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:list', '#', 'admin', CURRENT_TIMESTAMP, '课程列表查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2214 OR perms = 'train:course:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2215, '课程详情', 2163, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:query', '#', 'admin', CURRENT_TIMESTAMP, '课程详情查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2215 OR perms = 'train:course:query'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2216, '课程新增', 2163, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:add', '#', 'admin', CURRENT_TIMESTAMP, '课程新增' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2216 OR perms = 'train:course:add'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2217, '课程修改', 2163, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:edit', '#', 'admin', CURRENT_TIMESTAMP, '课程修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2217 OR perms = 'train:course:edit'); + +-- 3.7 培训计划 (parent: 2164) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2218, '计划查询', 2164, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:list', '#', 'admin', CURRENT_TIMESTAMP, '培训计划列表' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2218 OR perms = 'train:plan:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2219, '计划详情', 2164, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:query', '#', 'admin', CURRENT_TIMESTAMP, '培训计划详情' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2219 OR perms = 'train:plan:query'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2220, '计划新增', 2164, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:add', '#', 'admin', CURRENT_TIMESTAMP, '培训计划新增' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2220 OR perms = 'train:plan:add'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2221, '计划修改', 2164, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:edit', '#', 'admin', CURRENT_TIMESTAMP, '培训计划修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2221 OR perms = 'train:plan:edit'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2222, '计划结训', 2164, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:complete', '#', 'admin', CURRENT_TIMESTAMP, '培训计划结束归档' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2222 OR perms = 'train:plan:complete'); + +-- 3.8 课次与排课 (parent: 2164) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2223, '排课查询', 2164, 6, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:query', '#', 'admin', CURRENT_TIMESTAMP, '排课课表查询' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2223 OR perms = 'train:lesson:query'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2224, '排课新增', 2164, 7, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:add', '#', 'admin', CURRENT_TIMESTAMP, '排课课次新增' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2224 OR perms = 'train:lesson:add'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2225, '排课修改', 2164, 8, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:edit', '#', 'admin', CURRENT_TIMESTAMP, '排课课次修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2225 OR perms = 'train:lesson:edit'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2226, '排课删除', 2164, 9, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:remove', '#', 'admin', CURRENT_TIMESTAMP, '排课课次删除' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2226 OR perms = 'train:lesson:remove'); + +-- 3.9 参训学员分班 (parent: 2164) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2227, '参训名单查询', 2164, 10, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:list', '#', 'admin', CURRENT_TIMESTAMP, '参训花名册列表' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2227 OR perms = 'train:enrollment:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2228, '参训记录详情', 2164, 11, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:query', '#', 'admin', CURRENT_TIMESTAMP, '参训记录详情' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2228 OR perms = 'train:enrollment:query'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2229, '参训人员添加', 2164, 12, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:add', '#', 'admin', CURRENT_TIMESTAMP, '参训学员录入' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2229 OR perms = 'train:enrollment:add'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2230, '参训人员修改', 2164, 13, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:edit', '#', 'admin', CURRENT_TIMESTAMP, '参训学员状态修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2230 OR perms = 'train:enrollment:edit'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2231, '参训人员移除', 2164, 14, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:remove', '#', 'admin', CURRENT_TIMESTAMP, '参训学员移除' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2231 OR perms = 'train:enrollment:remove'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2232, '参训人员导入', 2164, 15, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:import', '#', 'admin', CURRENT_TIMESTAMP, '参训学员批量导入' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2232 OR perms = 'train:enrollment:import'); + +-- 3.10 电子班牌标准权限 (parent: 2174) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2233, '班牌列表', 2174, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:list', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌列表' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2233 OR perms = 'train:board:list'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2234, '班牌详情', 2174, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:query', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌详情' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2234 OR perms = 'train:board:query'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2235, '班牌新增', 2174, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:add', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌新增' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2235 OR perms = 'train:board:add'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2236, '班牌修改', 2174, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:edit', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌修改' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2236 OR perms = 'train:board:edit'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2237, '班牌删除', 2174, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:remove', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌删除' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2237 OR perms = 'train:board:remove'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2238, '班牌导出', 2174, 6, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:export', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌导出' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2238 OR perms = 'train:board:export'); + +-- 3.11 自服务专属菜单与页面路由 (挂载于根节点 0,供教师与学员在侧边栏直达工作台) +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2239, '教师工作台', 0, 99, 'teacher', 'train/me/teacher/index', '', 1, 0, 'C', '0', '0', 'train:me:teacher', 'education', 'admin', CURRENT_TIMESTAMP, '教师专属工作台页面及自服务权限' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2239 OR perms = 'train:me:teacher'); + +INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT 2240, '学员中心', 0, 100, 'student', 'train/me/student/index', '', 1, 0, 'C', '0', '0', 'train:me:student', 'user', 'admin', CURRENT_TIMESTAMP, '学员专属个人培训中心页面及自服务权限' +WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2240 OR perms = 'train:me:student'); + +-- ============================================================================= +-- 步骤 4: 为业务角色分配菜单权限映射 (sys_role_menu) +-- ============================================================================= + +-- 4.1 train_admin (role_id = 3): 授予全部培训模块菜单与按钮 +INSERT INTO public.sys_role_menu (role_id, menu_id) +SELECT 3, m.menu_id +FROM public.sys_menu m +WHERE (m.menu_id BETWEEN 2154 AND 2183 OR m.menu_id BETWEEN 2201 AND 2238 OR m.menu_id BETWEEN 2241 AND 2244) + AND NOT EXISTS ( + SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 3 AND rm.menu_id = m.menu_id + ); + +-- 4.2 train_teacher (role_id = 4): 授予教师专属自服务权限 +INSERT INTO public.sys_role_menu (role_id, menu_id) +SELECT 4, 2239 +WHERE NOT EXISTS ( + SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 4 AND rm.menu_id = 2239 +); + +-- 4.3 train_student (role_id = 5): 授予学员专属自服务权限 +INSERT INTO public.sys_role_menu (role_id, menu_id) +SELECT 5, 2240 +WHERE NOT EXISTS ( + SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 5 AND rm.menu_id = 2240 +); + +-- ============================================================================= +-- 步骤 5: 迁移验证自检 +-- ============================================================================= +DO $$ +BEGIN + IF (SELECT count(*) FROM public.sys_role WHERE role_key IN ('train_admin', 'train_teacher', 'train_student')) <> 3 THEN + RAISE EXCEPTION 'Training roles count verification failed'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:me:teacher') THEN + RAISE EXCEPTION 'Teacher self-service permission missing'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:me:student') THEN + RAISE EXCEPTION 'Student self-service permission missing'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:teacher:bindUser') THEN + RAISE EXCEPTION 'Teacher binding permission missing'; + END IF; + + IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:student:bindUser') THEN + RAISE EXCEPTION 'Student binding permission missing'; + END IF; + + IF EXISTS (SELECT 1 FROM public.sys_menu WHERE perms LIKE 'system:board:%') THEN + RAISE EXCEPTION 'Legacy system:board:* permission namespace still exists'; + END IF; +END $$; + +COMMIT; diff --git a/build_ui/.env.development b/build_ui/.env.development index 668d9414c..b6b53351c 100644 --- a/build_ui/.env.development +++ b/build_ui/.env.development @@ -1,5 +1,5 @@ # 页面标题 -VUE_APP_TITLE = 智慧培训管理平台 +VUE_APP_TITLE = SYFJGYD学员管理系统 # 开发环境配置 ENV = 'development' diff --git a/build_ui/.env.production b/build_ui/.env.production index 460b4b630..89d4d4ecb 100644 --- a/build_ui/.env.production +++ b/build_ui/.env.production @@ -1,5 +1,5 @@ -# 页面标题 智慧培训管理平台 -VUE_APP_TITLE = 智慧培训管理平台 +# 页面标题 SYFJGYD学员管理系统 +VUE_APP_TITLE = SYFJGYD学员管理系统 # 生产环境配置 ENV = 'production' diff --git a/build_ui/.env.staging b/build_ui/.env.staging index 5a100a636..e914a1829 100644 --- a/build_ui/.env.staging +++ b/build_ui/.env.staging @@ -1,5 +1,5 @@ # 页面标题 -VUE_APP_TITLE = 智慧培训管理平台 +VUE_APP_TITLE = SYFJGYD学员管理系统 NODE_ENV = production diff --git a/build_ui/package.json b/build_ui/package.json index 08a366dc4..a69d86fc0 100644 --- a/build_ui/package.json +++ b/build_ui/package.json @@ -1,7 +1,7 @@ { "name": "ruoyi", "version": "3.8.5", - "description": "智慧培训管理平台", + "description": "SYFJGYD学员管理系统", "author": "若依", "license": "MIT", "scripts": { diff --git a/build_ui/src/api/train/meStudent.js b/build_ui/src/api/train/meStudent.js new file mode 100644 index 000000000..588c7ce61 --- /dev/null +++ b/build_ui/src/api/train/meStudent.js @@ -0,0 +1,60 @@ +import request from '@/utils/request' + +// 获取学员本人档案信息 (敏感信息已脱敏,严禁传 studentId) +export function getStudentProfile() { + return request({ + url: '/train/me/student', + method: 'get' + }) +} + +// 获取本人参训班级及报名状态列表 +export function getMyEnrollments(query) { + return request({ + url: '/train/me/student/enrollments', + method: 'get', + params: query + }) +} + +// 获取本人参训课表日程 +export function getMyTimetable(query) { + return request({ + url: '/train/me/student/timetable', + method: 'get', + params: query + }) +} + +// 获取本人最新开班报到凭据与状态 +export function getMyReport() { + return request({ + url: '/train/me/student/report', + method: 'get' + }) +} + +// 获取本人考勤打卡流水 +export function getMyAttendance(query) { + return request({ + url: '/train/me/student/attendance', + method: 'get', + params: query + }) +} + +// 获取本人累计培训学时记录 +export function getMyDuration() { + return request({ + url: '/train/me/student/duration', + method: 'get' + }) +} + +// 获取本人多维成长雷达画像 +export function getMyPortrait() { + return request({ + url: '/train/me/student/portrait', + method: 'get' + }) +} diff --git a/build_ui/src/api/train/meTeacher.js b/build_ui/src/api/train/meTeacher.js new file mode 100644 index 000000000..47b898528 --- /dev/null +++ b/build_ui/src/api/train/meTeacher.js @@ -0,0 +1,44 @@ +import request from '@/utils/request' + +// 获取教师本人档案信息 +export function getTeacherProfile() { + return request({ + url: '/train/me/teacher', + method: 'get' + }) +} + +// 获取教师本人任教的培训班列表 +export function getMyPlans(query) { + return request({ + url: '/train/me/teacher/plans', + method: 'get', + params: query + }) +} + +// 获取教师授课课表日程 +export function getMyTimetable(query) { + return request({ + url: '/train/me/teacher/timetable', + method: 'get', + params: query + }) +} + +// 获取任教班级学员花名册 (强制携带 planId) +export function getMyClassStudents(query) { + return request({ + url: '/train/me/teacher/students', + method: 'get', + params: query + }) +} + +// 查看指定课次打卡考勤 +export function getLessonAttendance(lessonId) { + return request({ + url: '/train/me/teacher/lessons/' + lessonId + '/attendance', + method: 'get' + }) +} diff --git a/build_ui/src/api/train/student.js b/build_ui/src/api/train/student.js index d5278cf75..28d9808e7 100644 --- a/build_ui/src/api/train/student.js +++ b/build_ui/src/api/train/student.js @@ -25,3 +25,29 @@ export function addStudent(data) { data: data }) } + +// 查询可关联学员的系统用户候选列表 +export function getStudentBindableUsers(keyword) { + return request({ + url: '/train/student/bindable-users', + method: 'get', + params: { keyword } + }) +} + +// 为学员关联系统用户账号 +export function bindStudentUser(sId, userId) { + return request({ + url: '/train/student/' + sId + '/user-binding', + method: 'put', + data: { userId } + }) +} + +// 为学员解除系统用户账号关联 +export function unbindStudentUser(sId) { + return request({ + url: '/train/student/' + sId + '/user-binding', + method: 'delete' + }) +} diff --git a/build_ui/src/api/train/teacher.js b/build_ui/src/api/train/teacher.js index 885282467..8ce6568f8 100644 --- a/build_ui/src/api/train/teacher.js +++ b/build_ui/src/api/train/teacher.js @@ -34,3 +34,29 @@ export function updateTeacher(data) { data: data }) } + +// 查询可关联教师的系统用户候选列表 +export function getTeacherBindableUsers(keyword) { + return request({ + url: '/train/teacher/bindable-users', + method: 'get', + params: { keyword } + }) +} + +// 为教师关联系统用户账号 +export function bindTeacherUser(tId, userId) { + return request({ + url: '/train/teacher/' + tId + '/user-binding', + method: 'put', + data: { userId } + }) +} + +// 为教师解除系统用户账号关联 +export function unbindTeacherUser(tId) { + return request({ + url: '/train/teacher/' + tId + '/user-binding', + method: 'delete' + }) +} diff --git a/build_ui/src/layout/components/Sidebar/Logo.vue b/build_ui/src/layout/components/Sidebar/Logo.vue index 71ec4a08b..cca2ec8bd 100644 --- a/build_ui/src/layout/components/Sidebar/Logo.vue +++ b/build_ui/src/layout/components/Sidebar/Logo.vue @@ -35,7 +35,7 @@ export default { }, data() { return { - title: '智慧培训管理平台', + title: 'SYFJGYD学员管理系统', logo: logoImg } } diff --git a/build_ui/src/settings.js b/build_ui/src/settings.js index c0a256168..f36482181 100644 --- a/build_ui/src/settings.js +++ b/build_ui/src/settings.js @@ -1,4 +1,6 @@ module.exports = { + title: 'SYFJGYD学员管理系统', + /** * 侧边栏主题 深色主题theme-dark,浅色主题theme-light */ diff --git a/build_ui/src/views/index.vue b/build_ui/src/views/index.vue index 4db63a33e..6ab754e53 100644 --- a/build_ui/src/views/index.vue +++ b/build_ui/src/views/index.vue @@ -1,36 +1,105 @@ - - - -
- 智慧培训管理平台 -
-
-

欢迎使用智慧培训管理平台。本平台提供培训计划编排、学员主档与参训管理、智能排课排程、电子班牌联动、教学设施与实训场地调度,以及全流程多维学习数据分析等核心功能。

-
-
- 系统架构 -
- -
- 系统版本:V1.0 - 版权所有:中国科学院沈阳计算技术研究所有限公司 -
-
-
-
- + + + diff --git a/build_ui/src/views/train/me/teacher/index.vue b/build_ui/src/views/train/me/teacher/index.vue new file mode 100644 index 000000000..51e942acb --- /dev/null +++ b/build_ui/src/views/train/me/teacher/index.vue @@ -0,0 +1,519 @@ + + + + + diff --git a/build_ui/src/views/train/student/index.vue b/build_ui/src/views/train/student/index.vue index 5d0bfa11d..49c76d591 100644 --- a/build_ui/src/views/train/student/index.vue +++ b/build_ui/src/views/train/student/index.vue @@ -82,7 +82,19 @@ - + + + + @@ -155,9 +184,13 @@ - + {{ detail.sName || '-' }} {{ detail.sId || '-' }} + + {{ detail.userName }} ({{ detail.nickName }}) + 未关联 + {{ detail.orgName || '-' }} {{ detail.sPhone || '-' }} {{ detail.sEmail || '-' }} @@ -166,11 +199,71 @@ {{ detail.remark || '-' }} + + + + +
+ 当前学员:{{ currentStudent.sName }} (学员编号: {{ currentStudent.sId }}) +
+ + + + + + 搜索 + + + + + + + + + + + + + + + + + + + +