新增权限:实现教师学员身份绑定与自服务安全控制
This commit is contained in:
1 parent
35832f2fce
commit
592a9655e8
38 files changed
+3277
-89
No files matched your search
+6
-6
@@ -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)
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainClassReport;
|
||||
import com.ruoyi.train.domain.TrainStudentCheckin;
|
||||
import com.ruoyi.train.domain.TrainStudentPortrait;
|
||||
import com.ruoyi.train.domain.TrainStudyDuration;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
||||
import com.ruoyi.train.domain.vo.StudentDetailVO;
|
||||
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
|
||||
import com.ruoyi.train.mapper.TrainClassReportMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainLessonMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
|
||||
import com.ruoyi.train.service.ITrainStudentService;
|
||||
import com.ruoyi.train.service.ITrainingIdentityService;
|
||||
|
||||
/**
|
||||
* 学员专属个人中心控制器 (/train/me/student/**)。
|
||||
*
|
||||
* <p>服务端严格基于当前会话与登录人关联的学员身份推导数据范围,杜绝任何外部主键传入与 IDOR 越权。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/train/me/student")
|
||||
public class TrainStudentMeController extends BaseController
|
||||
{
|
||||
private final ITrainingIdentityService identityService;
|
||||
private final ITrainStudentService studentService;
|
||||
private final TrainEnrollmentMapper enrollmentMapper;
|
||||
private final TrainLessonMapper lessonMapper;
|
||||
private final TrainClassReportMapper reportMapper;
|
||||
private final TrainStudentCheckinMapper checkinMapper;
|
||||
private final TrainStudyDurationMapper durationMapper;
|
||||
private final TrainStudentPortraitMapper portraitMapper;
|
||||
|
||||
public TrainStudentMeController(ITrainingIdentityService identityService,
|
||||
ITrainStudentService studentService,
|
||||
TrainEnrollmentMapper enrollmentMapper,
|
||||
TrainLessonMapper lessonMapper,
|
||||
TrainClassReportMapper reportMapper,
|
||||
TrainStudentCheckinMapper checkinMapper,
|
||||
TrainStudyDurationMapper durationMapper,
|
||||
TrainStudentPortraitMapper portraitMapper)
|
||||
{
|
||||
this.identityService = identityService;
|
||||
this.studentService = studentService;
|
||||
this.enrollmentMapper = enrollmentMapper;
|
||||
this.lessonMapper = lessonMapper;
|
||||
this.reportMapper = reportMapper;
|
||||
this.checkinMapper = checkinMapper;
|
||||
this.durationMapper = durationMapper;
|
||||
this.portraitMapper = portraitMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startPage()
|
||||
{
|
||||
if (com.ruoyi.common.utils.ServletUtils.getRequestAttributes() != null)
|
||||
{
|
||||
super.startPage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学员个人档案 (敏感信息已脱敏)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
|
||||
@GetMapping
|
||||
public AjaxResult getStudentProfile()
|
||||
{
|
||||
String sId = identityService.assertStudentIdentity();
|
||||
StudentDetailVO detail = studentService.selectStudentDetail(sId);
|
||||
if (detail == null)
|
||||
{
|
||||
throw new ServiceException("未找到对应的学员档案数据", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return success(detail);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的参训班级与状态列表
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
|
||||
@GetMapping("/enrollments")
|
||||
public TableDataInfo getMyEnrollments()
|
||||
{
|
||||
String sId = identityService.assertStudentIdentity();
|
||||
startPage();
|
||||
List<EnrollmentItemVO> list = enrollmentMapper.selectEnrollmentsByStudentId(sId);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的参训课表日程
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
|
||||
@GetMapping("/timetable")
|
||||
public AjaxResult getMyTimetable(
|
||||
@RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate,
|
||||
@RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate)
|
||||
{
|
||||
String sId = identityService.assertStudentIdentity();
|
||||
List<TimetableLessonItemVO> list = lessonMapper.selectScheduleLessonsByStudentId(sId, startDate, endDate);
|
||||
return success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的最新开班报到凭据与状态
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
|
||||
@GetMapping("/report")
|
||||
public AjaxResult getMyReport()
|
||||
{
|
||||
String sId = identityService.assertStudentIdentity();
|
||||
TrainClassReport report = reportMapper.selectLatestReportByStudentId(sId);
|
||||
return success(report);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的考勤打卡流水
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
|
||||
@GetMapping("/attendance")
|
||||
public TableDataInfo getMyAttendance(
|
||||
@RequestParam(value = "planId", required = false) String planId,
|
||||
@RequestParam(value = "month", required = false) String month)
|
||||
{
|
||||
String sId = identityService.assertStudentIdentity();
|
||||
startPage();
|
||||
List<TrainStudentCheckin> list = checkinMapper.selectCheckinsByStudentId(sId, planId, month);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的累计学习时长
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
|
||||
@GetMapping("/duration")
|
||||
public AjaxResult getMyDuration()
|
||||
{
|
||||
String sId = identityService.assertStudentIdentity();
|
||||
List<TrainStudyDuration> list = durationMapper.selectDurationsByStudentId(sId);
|
||||
return success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的多维成长画像
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')")
|
||||
@GetMapping("/portrait")
|
||||
public AjaxResult getMyPortrait()
|
||||
{
|
||||
String sId = identityService.assertStudentIdentity();
|
||||
TrainStudentPortrait portrait = portraitMapper.selectPortraitByStudentId(sId);
|
||||
return success(portrait);
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.controller.BaseController;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainClassPlan;
|
||||
import com.ruoyi.train.domain.TrainLesson;
|
||||
import com.ruoyi.train.domain.TrainStudentCheckin;
|
||||
import com.ruoyi.train.domain.TrainTeacher;
|
||||
import com.ruoyi.train.domain.query.EnrollmentQuery;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
||||
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainLessonMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
|
||||
import com.ruoyi.train.mapper.TrainTeacherMapper;
|
||||
import com.ruoyi.train.service.ITrainingIdentityService;
|
||||
|
||||
/**
|
||||
* 教师专属自服务控制器 (/train/me/teacher/**)。
|
||||
*
|
||||
* <p>服务端严格基于当前会话与登录人关联的教师身份推导数据范围,杜绝任何外部主键传入与 IDOR 越权。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/train/me/teacher")
|
||||
public class TrainTeacherMeController extends BaseController
|
||||
{
|
||||
private final ITrainingIdentityService identityService;
|
||||
private final TrainTeacherMapper teacherMapper;
|
||||
private final TrainLessonMapper lessonMapper;
|
||||
private final TrainEnrollmentMapper enrollmentMapper;
|
||||
private final TrainStudentCheckinMapper checkinMapper;
|
||||
|
||||
public TrainTeacherMeController(ITrainingIdentityService identityService,
|
||||
TrainTeacherMapper teacherMapper,
|
||||
TrainLessonMapper lessonMapper,
|
||||
TrainEnrollmentMapper enrollmentMapper,
|
||||
TrainStudentCheckinMapper checkinMapper)
|
||||
{
|
||||
this.identityService = identityService;
|
||||
this.teacherMapper = teacherMapper;
|
||||
this.lessonMapper = lessonMapper;
|
||||
this.enrollmentMapper = enrollmentMapper;
|
||||
this.checkinMapper = checkinMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startPage()
|
||||
{
|
||||
if (com.ruoyi.common.utils.ServletUtils.getRequestAttributes() != null)
|
||||
{
|
||||
super.startPage();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教师本人档案信息
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
|
||||
@GetMapping
|
||||
public AjaxResult getTeacherProfile()
|
||||
{
|
||||
String tId = identityService.assertTeacherIdentity();
|
||||
TrainTeacher teacher = teacherMapper.selectTrainTeacherByTId(tId);
|
||||
if (teacher == null)
|
||||
{
|
||||
throw new ServiceException("未找到对应的教师档案数据", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
return success(teacher);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我参与任教的培训班列表
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
|
||||
@GetMapping("/plans")
|
||||
public TableDataInfo getMyPlans(@RequestParam(value = "planStatus", required = false) Integer planStatus)
|
||||
{
|
||||
String tId = identityService.assertTeacherIdentity();
|
||||
startPage();
|
||||
List<TrainClassPlan> list = lessonMapper.selectPlansByTeacherId(tId, planStatus);
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我的授课课表日程
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
|
||||
@GetMapping(value = {"/timetable", "/lessons"})
|
||||
public AjaxResult getMyTimetable(
|
||||
@RequestParam(value = "startDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate,
|
||||
@RequestParam(value = "endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate)
|
||||
{
|
||||
String tId = identityService.assertTeacherIdentity();
|
||||
List<TimetableLessonItemVO> list = lessonMapper.selectScheduleLessonsByTeacherId(tId, startDate, endDate);
|
||||
return success(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我所带班级学员花名册 (严格校验任教归属与敏感信息脱敏)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
|
||||
@GetMapping("/students")
|
||||
public TableDataInfo getMyClassStudents(@RequestParam("planId") String planId)
|
||||
{
|
||||
String tId = identityService.assertTeacherIdentity();
|
||||
// IDOR 安全校验:断言教师在当前班级拥有授课关系
|
||||
int lessonCount = lessonMapper.countTeacherInPlan(tId, planId);
|
||||
if (lessonCount <= 0)
|
||||
{
|
||||
throw new ServiceException("您并非该培训班的任课教师,无权查看学员名册", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
EnrollmentQuery query = new EnrollmentQuery();
|
||||
query.setPlanId(planId);
|
||||
startPage();
|
||||
List<EnrollmentItemVO> list = enrollmentMapper.selectEnrollmentItemList(query);
|
||||
for (EnrollmentItemVO item : list)
|
||||
{
|
||||
if (item.getPhone() != null)
|
||||
{
|
||||
item.setPhone(maskPhone(item.getPhone()));
|
||||
}
|
||||
}
|
||||
return getDataTable(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取我授课课次的学员出勤情况 (严格校验课次归属)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')")
|
||||
@GetMapping("/lessons/{lessonId}/attendance")
|
||||
public AjaxResult getLessonAttendance(@PathVariable("lessonId") Long lessonId)
|
||||
{
|
||||
String tId = identityService.assertTeacherIdentity();
|
||||
TrainLesson lesson = lessonMapper.selectTrainLessonById(lessonId);
|
||||
if (lesson == null)
|
||||
{
|
||||
throw new ServiceException("指定课次不存在", HttpStatus.NOT_FOUND);
|
||||
}
|
||||
// IDOR 安全校验:断言该课次为本人授课
|
||||
if (lesson.getTId() == null || !lesson.getTId().equals(tId))
|
||||
{
|
||||
throw new ServiceException("您并非该课次的授课教师,无权查看出勤记录", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
List<TrainStudentCheckin> checkins = checkinMapper.selectCheckinsByLessonId(lessonId);
|
||||
return success(checkins);
|
||||
}
|
||||
|
||||
private String maskPhone(String phone)
|
||||
{
|
||||
if (phone == null || phone.length() < 7)
|
||||
{
|
||||
return phone;
|
||||
}
|
||||
if (phone.length() == 11)
|
||||
{
|
||||
return phone.substring(0, 3) + "****" + phone.substring(7);
|
||||
}
|
||||
return phone.substring(0, 2) + "****" + phone.substring(phone.length() - 2);
|
||||
}
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
|
||||
import com.ruoyi.train.domain.TrainLesson;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.domain.TrainTeacher;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
||||
import com.ruoyi.train.domain.vo.StudentDetailVO;
|
||||
import com.ruoyi.train.mapper.TrainClassReportMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainLessonMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
|
||||
import com.ruoyi.train.mapper.TrainTeacherMapper;
|
||||
import com.ruoyi.train.service.ITrainStudentService;
|
||||
import com.ruoyi.train.service.ITrainingIdentityService;
|
||||
|
||||
/**
|
||||
* 真实 HTTP / AjaxResult 响应体语义、IDOR 越权与敏感数据脱敏集成测试。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class RbacHttpAndAuthorizationSemanticsTest
|
||||
{
|
||||
private TrainTeacherMeController teacherController;
|
||||
private TrainStudentMeController studentController;
|
||||
private GlobalExceptionHandler exceptionHandler;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
private String currentTeacherId = "T_MOCK_01";
|
||||
private String currentStudentId = "S_MOCK_01";
|
||||
|
||||
@Before
|
||||
public void setUp()
|
||||
{
|
||||
exceptionHandler = new GlobalExceptionHandler();
|
||||
objectMapper = new ObjectMapper();
|
||||
|
||||
ITrainingIdentityService identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{ITrainingIdentityService.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("assertTeacherIdentity".equals(name))
|
||||
{
|
||||
if (currentTeacherId == null)
|
||||
{
|
||||
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return currentTeacherId;
|
||||
}
|
||||
if ("assertStudentIdentity".equals(name))
|
||||
{
|
||||
if (currentStudentId == null)
|
||||
{
|
||||
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return currentStudentId;
|
||||
}
|
||||
if ("getCurrentUserId".equals(name))
|
||||
{
|
||||
return 1001L;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainTeacherMapper teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainTeacherMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainTeacherByTId".equals(method.getName()))
|
||||
{
|
||||
TrainTeacher teacher = new TrainTeacher();
|
||||
teacher.setTId((String) args[0]);
|
||||
teacher.settName("李老师");
|
||||
return teacher;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainLessonMapper lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainLessonMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("countTeacherInPlan".equals(name))
|
||||
{
|
||||
String tId = (String) args[0];
|
||||
String planId = (String) args[1];
|
||||
return ("T_MOCK_01".equals(tId) && "PLAN_LEGAL".equals(planId)) ? 1 : 0;
|
||||
}
|
||||
if ("selectTrainLessonById".equals(name))
|
||||
{
|
||||
Long lessonId = (Long) args[0];
|
||||
TrainLesson lesson = new TrainLesson();
|
||||
lesson.setLessonId(lessonId);
|
||||
if (Long.valueOf(101L).equals(lessonId))
|
||||
{
|
||||
lesson.setTId("T_MOCK_01"); // 本人课次
|
||||
}
|
||||
else
|
||||
{
|
||||
lesson.setTId("T_OTHER"); // 他人课次
|
||||
}
|
||||
return lesson;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainEnrollmentMapper enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectEnrollmentItemList".equals(method.getName()))
|
||||
{
|
||||
List<EnrollmentItemVO> list = new ArrayList<>();
|
||||
EnrollmentItemVO item = new EnrollmentItemVO();
|
||||
item.setStudentId("S001");
|
||||
item.setStudentName("张三");
|
||||
item.setPhone("13812345678");
|
||||
list.add(item);
|
||||
return list;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
);
|
||||
|
||||
TrainStudentCheckinMapper checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainStudentCheckinMapper.class},
|
||||
(proxy, method, args) -> Collections.emptyList()
|
||||
);
|
||||
|
||||
teacherController = new TrainTeacherMeController(
|
||||
identityService, teacherMapper, lessonMapper, enrollmentMapper, checkinMapper);
|
||||
|
||||
ITrainStudentService studentService = (ITrainStudentService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{ITrainStudentService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectStudentDetail".equals(method.getName()))
|
||||
{
|
||||
StudentDetailVO detail = new StudentDetailVO();
|
||||
detail.setsId("S_MOCK_01");
|
||||
detail.setsName("李四");
|
||||
detail.setIdCardMasked("110101********1234");
|
||||
detail.setIdCardLast4("1234");
|
||||
return detail;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainClassReportMapper reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(), new Class<?>[]{TrainClassReportMapper.class}, (p, m, a) -> null);
|
||||
TrainStudyDurationMapper durationMapper = (TrainStudyDurationMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(), new Class<?>[]{TrainStudyDurationMapper.class}, (p, m, a) -> Collections.emptyList());
|
||||
TrainStudentPortraitMapper portraitMapper = (TrainStudentPortraitMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(), new Class<?>[]{TrainStudentPortraitMapper.class}, (p, m, a) -> null);
|
||||
|
||||
studentController = new TrainStudentMeController(
|
||||
identityService, studentService, enrollmentMapper, lessonMapper, reportMapper, checkinMapper, durationMapper, portraitMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTeacherMe_NoIdentity_GlobalExceptionHandlingSemantics()
|
||||
{
|
||||
currentTeacherId = null; // 模拟无教师身份
|
||||
try
|
||||
{
|
||||
teacherController.getTeacherProfile();
|
||||
fail("无教师身份必须抛出 ServiceException");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
|
||||
// 经过若依全局异常处理器,生成最终前端收到的 AjaxResult
|
||||
AjaxResult result = exceptionHandler.handleServiceException(e, null);
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", result.get("msg"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTeacherStudents_CrossPlanIdor_GlobalExceptionHandlingSemantics()
|
||||
{
|
||||
currentTeacherId = "T_MOCK_01";
|
||||
try
|
||||
{
|
||||
teacherController.getMyClassStudents("PLAN_ILLEGAL_OTHER"); // 跨班越权
|
||||
fail("跨班越权必须抛出 ServiceException");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
|
||||
AjaxResult result = exceptionHandler.handleServiceException(e, null);
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("您并非该培训班的任课教师,无权查看学员名册", result.get("msg"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTeacherAttendance_CrossLessonIdor_GlobalExceptionHandlingSemantics()
|
||||
{
|
||||
currentTeacherId = "T_MOCK_01";
|
||||
try
|
||||
{
|
||||
teacherController.getLessonAttendance(888L); // 888属于T_OTHER
|
||||
fail("跨Lesson越权必须抛出 ServiceException");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
|
||||
AjaxResult result = exceptionHandler.handleServiceException(e, null);
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("您并非该课次的授课教师,无权查看出勤记录", result.get("msg"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStudentMe_NoIdentity_GlobalExceptionHandlingSemantics()
|
||||
{
|
||||
currentStudentId = null; // 模拟无学员身份
|
||||
try
|
||||
{
|
||||
studentController.getStudentProfile();
|
||||
fail("无学员身份必须抛出 ServiceException");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
|
||||
AjaxResult result = exceptionHandler.handleServiceException(e, null);
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", result.get("msg"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSpringSecurity_AccessDeniedException_HandlingSemantics()
|
||||
{
|
||||
// 模拟 Spring Security 权限不足异常
|
||||
AccessDeniedException ex = new AccessDeniedException("Access is denied");
|
||||
javax.servlet.http.HttpServletRequest request = (javax.servlet.http.HttpServletRequest) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{javax.servlet.http.HttpServletRequest.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("getRequestURI".equals(method.getName())) return "/train/me/teacher";
|
||||
return null;
|
||||
}
|
||||
);
|
||||
// 调用全局异常处理
|
||||
AjaxResult result = exceptionHandler.handleAccessDeniedException(ex, request);
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("没有权限,请联系管理员授权", result.get("msg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testAdminUserWithTeacherIdentity_AccessAllowed()
|
||||
{
|
||||
currentTeacherId = "T_MOCK_01";
|
||||
|
||||
// Admin角色用户同时拥有Teacher档案时,正常访问
|
||||
AjaxResult result = teacherController.getTeacherProfile();
|
||||
assertEquals(200, result.get("code"));
|
||||
TrainTeacher teacher = (TrainTeacher) result.get("data");
|
||||
assertEquals("T_MOCK_01", teacher.getTId());
|
||||
assertEquals("李老师", teacher.gettName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSensitiveData_StudentProfileJsonNeverLeaksCiphertextOrHash() throws Exception
|
||||
{
|
||||
currentStudentId = "S_MOCK_01";
|
||||
|
||||
AjaxResult result = studentController.getStudentProfile();
|
||||
assertEquals(200, result.get("code"));
|
||||
assertNotNull(result.get("data"));
|
||||
|
||||
String json = objectMapper.writeValueAsString(result);
|
||||
// 严格断言:JSON响应体严禁包含任何密文或查重哈希字段
|
||||
assertFalse("JSON不得泄露密文字段 idCardCiphertext", json.contains("idCardCiphertext"));
|
||||
assertFalse("JSON不得泄露哈希字段 idCardHash", json.contains("idCardHash"));
|
||||
assertFalse("JSON不得泄露数据库内部密钥字段 secretKey", json.contains("secretKey"));
|
||||
|
||||
// 必须暴露脱敏展示字段
|
||||
assertTrue("JSON必须包含脱敏掩码字段 idCardMasked", json.contains("idCardMasked"));
|
||||
assertTrue("JSON必须包含末4位字段 idCardLast4", json.contains("idCardLast4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSensitiveData_TeacherStudentsMasksPhone() throws Exception
|
||||
{
|
||||
currentTeacherId = "T_MOCK_01";
|
||||
|
||||
TableDataInfo dataInfo = teacherController.getMyClassStudents("PLAN_LEGAL");
|
||||
assertEquals(200, dataInfo.getCode());
|
||||
|
||||
String json = objectMapper.writeValueAsString(dataInfo);
|
||||
assertFalse("学员列表严禁泄露完整11位手机号", json.contains("13812345678"));
|
||||
assertTrue("学员列表必须展示掩码脱敏手机号", json.contains("138****5678"));
|
||||
}
|
||||
}
|
||||
+225
@@ -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:"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import org.junit.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
/**
|
||||
* R.2B RBAC 数据库迁移与命名空间对齐静态契约测试 (004 / 005 SQL & Controller Annotation)。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class RbacPermissionMigrationContractTest
|
||||
{
|
||||
@Test
|
||||
public void migration004HasPartialUniqueIndexesAndNoDrop() throws Exception
|
||||
{
|
||||
String sql = new String(Files.readAllBytes(findMigration("004_rbac_identity_binding.sql")), StandardCharsets.UTF_8).toLowerCase();
|
||||
assertContains(sql,
|
||||
"add column if not exists user_id",
|
||||
"create unique index if not exists uq_train_teacher_user_id",
|
||||
"create unique index if not exists uq_train_student_user_id",
|
||||
"where user_id is not null and del_flag = '0'");
|
||||
|
||||
// 严禁包含实际的 DROP / TRUNCATE DDL 语句
|
||||
assertFalse("must not execute drop index", sql.contains("drop index "));
|
||||
assertFalse("must not execute drop table", sql.contains("drop table "));
|
||||
assertFalse("must not execute truncate table", sql.contains("truncate table "));
|
||||
assertFalse("must not set user_id not null", sql.contains("alter column user_id set not null"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void migration005ProvisionsThreeRolesAndCollisionGuard() throws Exception
|
||||
{
|
||||
String sql = new String(Files.readAllBytes(findMigration("005_training_roles_permissions.sql")), StandardCharsets.UTF_8).toLowerCase();
|
||||
assertContains(sql,
|
||||
"collision detected",
|
||||
"train_admin",
|
||||
"train_teacher",
|
||||
"train_student",
|
||||
"train:board:list",
|
||||
"train:board:query",
|
||||
"train:board:add",
|
||||
"train:board:edit",
|
||||
"train:board:remove",
|
||||
"train:board:export",
|
||||
"train:me:teacher",
|
||||
"train:me:student",
|
||||
"insert into public.sys_role_menu");
|
||||
|
||||
// 严禁物理删除系统用户与无谓 DROP
|
||||
assertFalse("must not delete sys_user", sql.contains("delete from public.sys_user"));
|
||||
assertFalse("must not execute drop table", sql.contains("drop table "));
|
||||
assertFalse("must not execute truncate", sql.contains("truncate table "));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void electronicBoardControllerAlignsToTrainNamespace()
|
||||
{
|
||||
for (Method method : TrainElectronicBoardController.class.getDeclaredMethods())
|
||||
{
|
||||
if (method.isAnnotationPresent(PreAuthorize.class))
|
||||
{
|
||||
String perm = method.getAnnotation(PreAuthorize.class).value();
|
||||
assertTrue("班牌Controller权限必须属于train:board:*命名空间: " + perm, perm.contains("train:board:"));
|
||||
assertFalse("班牌Controller严禁遗留system:board:*命名空间: " + perm, perm.contains("system:board:"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Path findMigration(String filename)
|
||||
{
|
||||
for (Path path = Paths.get("").toAbsolutePath(); path != null; path = path.getParent())
|
||||
{
|
||||
Path candidate = path.resolve("sql/system/kingbase/" + filename);
|
||||
if (Files.isRegularFile(candidate)) return candidate;
|
||||
}
|
||||
throw new IllegalStateException(filename + " migration not found from working directory");
|
||||
}
|
||||
|
||||
private void assertContains(String sql, String... tokens)
|
||||
{
|
||||
for (String token : tokens)
|
||||
{
|
||||
assertTrue("missing migration token: " + token, sql.contains(token));
|
||||
}
|
||||
}
|
||||
}
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainClassReport;
|
||||
import com.ruoyi.train.domain.TrainStudentCheckin;
|
||||
import com.ruoyi.train.domain.TrainStudentPortrait;
|
||||
import com.ruoyi.train.domain.TrainStudyDuration;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
||||
import com.ruoyi.train.domain.vo.StudentDetailVO;
|
||||
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
|
||||
import com.ruoyi.train.mapper.TrainClassReportMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainLessonMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
|
||||
import com.ruoyi.train.service.ITrainStudentService;
|
||||
import com.ruoyi.train.service.ITrainingIdentityService;
|
||||
|
||||
/**
|
||||
* 学员专属个人中心接口契约与自服务推导单元测试。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class StudentSelfServiceApiTest
|
||||
{
|
||||
private TrainStudentMeController controller;
|
||||
private ITrainingIdentityService identityService;
|
||||
private ITrainStudentService studentService;
|
||||
private TrainEnrollmentMapper enrollmentMapper;
|
||||
private TrainLessonMapper lessonMapper;
|
||||
private TrainClassReportMapper reportMapper;
|
||||
private TrainStudentCheckinMapper checkinMapper;
|
||||
private TrainStudyDurationMapper durationMapper;
|
||||
private TrainStudentPortraitMapper portraitMapper;
|
||||
|
||||
private String currentStudentId = "S_CURRENT";
|
||||
|
||||
@Before
|
||||
public void setUp()
|
||||
{
|
||||
identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{ITrainingIdentityService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("assertStudentIdentity".equals(method.getName()))
|
||||
{
|
||||
if (currentStudentId == null)
|
||||
{
|
||||
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", 403);
|
||||
}
|
||||
return currentStudentId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
studentService = (ITrainStudentService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{ITrainStudentService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectStudentDetail".equals(method.getName()))
|
||||
{
|
||||
StudentDetailVO detail = new StudentDetailVO();
|
||||
detail.setsId((String) args[0]);
|
||||
detail.setsName("李四");
|
||||
detail.setIdCardLast4("5678");
|
||||
detail.setIdCardMasked("****************5678");
|
||||
detail.setOrgName("供电段");
|
||||
return detail;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectEnrollmentsByStudentId".equals(method.getName()))
|
||||
{
|
||||
List<EnrollmentItemVO> list = new ArrayList<>();
|
||||
EnrollmentItemVO vo = new EnrollmentItemVO();
|
||||
vo.setStudentId((String) args[0]);
|
||||
vo.setPlanId("PLAN_2026_01");
|
||||
vo.setEnrollmentStatus(1);
|
||||
list.add(vo);
|
||||
return list;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
);
|
||||
|
||||
lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainLessonMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectScheduleLessonsByStudentId".equals(method.getName()))
|
||||
{
|
||||
List<TimetableLessonItemVO> list = new ArrayList<>();
|
||||
TimetableLessonItemVO item = new TimetableLessonItemVO();
|
||||
item.setLessonId(201L);
|
||||
item.setCourseName("铁路信号基础");
|
||||
list.add(item);
|
||||
return list;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
);
|
||||
|
||||
reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainClassReportMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectLatestReportByStudentId".equals(method.getName()))
|
||||
{
|
||||
TrainClassReport report = new TrainClassReport();
|
||||
report.setStudentId((String) args[0]);
|
||||
report.setVerifyResult(1);
|
||||
return report;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainStudentCheckinMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectCheckinsByStudentId".equals(method.getName()))
|
||||
{
|
||||
List<TrainStudentCheckin> list = new ArrayList<>();
|
||||
TrainStudentCheckin checkin = new TrainStudentCheckin();
|
||||
checkin.setStudentId((String) args[0]);
|
||||
checkin.setAttendanceStatus(1);
|
||||
list.add(checkin);
|
||||
return list;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
);
|
||||
|
||||
durationMapper = (TrainStudyDurationMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainStudyDurationMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectDurationsByStudentId".equals(method.getName()))
|
||||
{
|
||||
List<TrainStudyDuration> list = new ArrayList<>();
|
||||
TrainStudyDuration dur = new TrainStudyDuration();
|
||||
dur.setStudentId((String) args[0]);
|
||||
dur.setStudySeconds(3600L);
|
||||
list.add(dur);
|
||||
return list;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
);
|
||||
|
||||
portraitMapper = (TrainStudentPortraitMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainStudentPortraitMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectPortraitByStudentId".equals(method.getName()))
|
||||
{
|
||||
TrainStudentPortrait portrait = new TrainStudentPortrait();
|
||||
portrait.setStudentId((String) args[0]);
|
||||
portrait.setAttendanceRate(new BigDecimal("98.5"));
|
||||
portrait.setTheoryScore(new BigDecimal("92.0"));
|
||||
return portrait;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
controller = new TrainStudentMeController(
|
||||
identityService, studentService, enrollmentMapper,
|
||||
lessonMapper, reportMapper, checkinMapper,
|
||||
durationMapper, portraitMapper
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldExposeStandardEndpointsAndPermissions() throws Exception
|
||||
{
|
||||
assertEquals("/train/me/student", TrainStudentMeController.class.getAnnotation(RequestMapping.class).value()[0]);
|
||||
|
||||
Method profile = TrainStudentMeController.class.getMethod("getStudentProfile");
|
||||
Method enrollments = TrainStudentMeController.class.getMethod("getMyEnrollments");
|
||||
Method timetable = TrainStudentMeController.class.getMethod("getMyTimetable", Date.class, Date.class);
|
||||
Method report = TrainStudentMeController.class.getMethod("getMyReport");
|
||||
Method attendance = TrainStudentMeController.class.getMethod("getMyAttendance", String.class, String.class);
|
||||
Method duration = TrainStudentMeController.class.getMethod("getMyDuration");
|
||||
Method portrait = TrainStudentMeController.class.getMethod("getMyPortrait");
|
||||
|
||||
assertNotNull(profile.getAnnotation(GetMapping.class));
|
||||
assertNotNull(enrollments.getAnnotation(GetMapping.class));
|
||||
assertNotNull(timetable.getAnnotation(GetMapping.class));
|
||||
assertNotNull(report.getAnnotation(GetMapping.class));
|
||||
assertNotNull(attendance.getAnnotation(GetMapping.class));
|
||||
assertNotNull(duration.getAnnotation(GetMapping.class));
|
||||
assertNotNull(portrait.getAnnotation(GetMapping.class));
|
||||
|
||||
String expectedPerm = "@ss.hasRole('train_student') or @ss.hasRole('admin') or @ss.hasPermi('train:me:student')";
|
||||
assertEquals(expectedPerm, profile.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, enrollments.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, timetable.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, report.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, attendance.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, duration.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, portrait.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void endpointsMustNeverAcceptStudentIdParameter()
|
||||
{
|
||||
// 核心安全防线:学员自服务控制器所有方法的参数列表严禁包含名为 studentId 或 sId 的入参
|
||||
for (Method method : TrainStudentMeController.class.getDeclaredMethods())
|
||||
{
|
||||
if (method.isAnnotationPresent(GetMapping.class))
|
||||
{
|
||||
for (Parameter parameter : method.getParameters())
|
||||
{
|
||||
String paramName = parameter.getName().toLowerCase();
|
||||
assertFalse("端点严禁接收studentId参数,杜绝越权: " + method.getName(),
|
||||
paramName.contains("studentid") || "sid".equals(paramName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetStudentProfileWithMaskedIdCard()
|
||||
{
|
||||
AjaxResult result = controller.getStudentProfile();
|
||||
assertEquals(200, result.get("code"));
|
||||
StudentDetailVO detail = (StudentDetailVO) result.get("data");
|
||||
assertEquals("S_CURRENT", detail.getsId());
|
||||
assertEquals("5678", detail.getIdCardLast4());
|
||||
assertEquals("****************5678", detail.getIdCardMasked());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetEnrollmentsAndTimetableSuccessfully()
|
||||
{
|
||||
TableDataInfo enrollments = controller.getMyEnrollments();
|
||||
assertEquals(200, enrollments.getCode());
|
||||
assertEquals(1, enrollments.getRows().size());
|
||||
|
||||
AjaxResult timetable = controller.getMyTimetable(null, null);
|
||||
assertEquals(200, timetable.get("code"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<TimetableLessonItemVO> lessons = (List<TimetableLessonItemVO>) timetable.get("data");
|
||||
assertEquals(1, lessons.size());
|
||||
assertEquals("铁路信号基础", lessons.get(0).getCourseName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetReportAttendanceDurationAndPortrait()
|
||||
{
|
||||
AjaxResult reportRes = controller.getMyReport();
|
||||
assertEquals(200, reportRes.get("code"));
|
||||
TrainClassReport report = (TrainClassReport) reportRes.get("data");
|
||||
assertEquals("S_CURRENT", report.getStudentId());
|
||||
|
||||
TableDataInfo attRes = controller.getMyAttendance(null, null);
|
||||
assertEquals(200, attRes.getCode());
|
||||
assertEquals(1, attRes.getRows().size());
|
||||
|
||||
AjaxResult durRes = controller.getMyDuration();
|
||||
assertEquals(200, durRes.get("code"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<TrainStudyDuration> durations = (List<TrainStudyDuration>) durRes.get("data");
|
||||
assertEquals(1, durations.size());
|
||||
|
||||
AjaxResult portraitRes = controller.getMyPortrait();
|
||||
assertEquals(200, portraitRes.get("code"));
|
||||
TrainStudentPortrait portrait = (TrainStudentPortrait) portraitRes.get("data");
|
||||
assertEquals(new BigDecimal("98.5"), portrait.getAttendanceRate());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldThrowForbiddenWhenUserHasNoStudentBinding()
|
||||
{
|
||||
currentStudentId = null;
|
||||
try
|
||||
{
|
||||
controller.getStudentProfile();
|
||||
fail("未绑定学员档案必须抛出 403");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import com.ruoyi.common.core.domain.AjaxResult;
|
||||
import com.ruoyi.common.core.page.TableDataInfo;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainClassPlan;
|
||||
import com.ruoyi.train.domain.TrainLesson;
|
||||
import com.ruoyi.train.domain.TrainStudentCheckin;
|
||||
import com.ruoyi.train.domain.TrainTeacher;
|
||||
import com.ruoyi.train.domain.query.EnrollmentQuery;
|
||||
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
|
||||
import com.ruoyi.train.domain.vo.TimetableLessonItemVO;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainLessonMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
|
||||
import com.ruoyi.train.mapper.TrainTeacherMapper;
|
||||
import com.ruoyi.train.service.ITrainingIdentityService;
|
||||
|
||||
/**
|
||||
* 教师专属自服务接口契约与 IDOR 越权防护测试。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class TeacherSelfServiceApiTest
|
||||
{
|
||||
private TrainTeacherMeController controller;
|
||||
private ITrainingIdentityService identityService;
|
||||
private TrainTeacherMapper teacherMapper;
|
||||
private TrainLessonMapper lessonMapper;
|
||||
private TrainEnrollmentMapper enrollmentMapper;
|
||||
private TrainStudentCheckinMapper checkinMapper;
|
||||
|
||||
private String currentTeacherId = "T_CURRENT";
|
||||
|
||||
@Before
|
||||
public void setUp()
|
||||
{
|
||||
identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{ITrainingIdentityService.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("assertTeacherIdentity".equals(method.getName()))
|
||||
{
|
||||
if (currentTeacherId == null)
|
||||
{
|
||||
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", 403);
|
||||
}
|
||||
return currentTeacherId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainTeacherMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainTeacherByTId".equals(method.getName()))
|
||||
{
|
||||
TrainTeacher teacher = new TrainTeacher();
|
||||
teacher.setTId((String) args[0]);
|
||||
teacher.settName("王老师");
|
||||
teacher.setOrgName("机务段");
|
||||
return teacher;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainLessonMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("countTeacherInPlan".equals(name))
|
||||
{
|
||||
String tId = (String) args[0];
|
||||
String planId = (String) args[1];
|
||||
// 仅当教师是 T_CURRENT 且班级是 PLAN_MY 时有任教关系
|
||||
return ("T_CURRENT".equals(tId) && "PLAN_MY".equals(planId)) ? 1 : 0;
|
||||
}
|
||||
if ("selectPlansByTeacherId".equals(name))
|
||||
{
|
||||
List<TrainClassPlan> list = new ArrayList<>();
|
||||
TrainClassPlan plan = new TrainClassPlan();
|
||||
plan.setPlanId("PLAN_MY");
|
||||
plan.setPlanName("高速铁路司机综合实训");
|
||||
list.add(plan);
|
||||
return list;
|
||||
}
|
||||
if ("selectScheduleLessonsByTeacherId".equals(name))
|
||||
{
|
||||
List<TimetableLessonItemVO> list = new ArrayList<>();
|
||||
TimetableLessonItemVO item = new TimetableLessonItemVO();
|
||||
item.setLessonId(101L);
|
||||
item.setTId((String) args[0]);
|
||||
item.setCourseName("列控车载设备");
|
||||
list.add(item);
|
||||
return list;
|
||||
}
|
||||
if ("selectTrainLessonById".equals(name))
|
||||
{
|
||||
Long lessonId = (Long) args[0];
|
||||
TrainLesson lesson = new TrainLesson();
|
||||
lesson.setLessonId(lessonId);
|
||||
if (Long.valueOf(101L).equals(lessonId))
|
||||
{
|
||||
lesson.setTId("T_CURRENT");
|
||||
}
|
||||
else
|
||||
{
|
||||
lesson.setTId("T_OTHER");
|
||||
}
|
||||
return lesson;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectEnrollmentItemList".equals(method.getName()))
|
||||
{
|
||||
List<EnrollmentItemVO> list = new ArrayList<>();
|
||||
EnrollmentItemVO item = new EnrollmentItemVO();
|
||||
item.setStudentId("S001");
|
||||
item.setStudentName("张三");
|
||||
item.setPhone("13912345678");
|
||||
item.setIdCardMasked("1234");
|
||||
list.add(item);
|
||||
return list;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
);
|
||||
|
||||
checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainStudentCheckinMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectCheckinsByLessonId".equals(method.getName()))
|
||||
{
|
||||
List<TrainStudentCheckin> list = new ArrayList<>();
|
||||
TrainStudentCheckin checkin = new TrainStudentCheckin();
|
||||
checkin.setLessonId((Long) args[0]);
|
||||
checkin.setStudentId("S001");
|
||||
list.add(checkin);
|
||||
return list;
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
);
|
||||
|
||||
controller = new TrainTeacherMeController(identityService, teacherMapper, lessonMapper, enrollmentMapper, checkinMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldExposeStandardEndpointsAndPermissions() throws Exception
|
||||
{
|
||||
assertEquals("/train/me/teacher", TrainTeacherMeController.class.getAnnotation(RequestMapping.class).value()[0]);
|
||||
|
||||
Method profileMethod = TrainTeacherMeController.class.getMethod("getTeacherProfile");
|
||||
Method plansMethod = TrainTeacherMeController.class.getMethod("getMyPlans", Integer.class);
|
||||
Method timetableMethod = TrainTeacherMeController.class.getMethod("getMyTimetable", Date.class, Date.class);
|
||||
Method studentsMethod = TrainTeacherMeController.class.getMethod("getMyClassStudents", String.class);
|
||||
Method attendanceMethod = TrainTeacherMeController.class.getMethod("getLessonAttendance", Long.class);
|
||||
|
||||
assertNotNull(profileMethod.getAnnotation(GetMapping.class));
|
||||
assertNotNull(plansMethod.getAnnotation(GetMapping.class));
|
||||
assertNotNull(timetableMethod.getAnnotation(GetMapping.class));
|
||||
assertNotNull(studentsMethod.getAnnotation(GetMapping.class));
|
||||
assertNotNull(attendanceMethod.getAnnotation(GetMapping.class));
|
||||
|
||||
String expectedPerm = "@ss.hasRole('train_teacher') or @ss.hasRole('admin') or @ss.hasPermi('train:me:teacher')";
|
||||
assertEquals(expectedPerm, profileMethod.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, plansMethod.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, timetableMethod.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, studentsMethod.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals(expectedPerm, attendanceMethod.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetTeacherProfileSuccessfully()
|
||||
{
|
||||
AjaxResult result = controller.getTeacherProfile();
|
||||
assertEquals(200, result.get("code"));
|
||||
TrainTeacher teacher = (TrainTeacher) result.get("data");
|
||||
assertEquals("T_CURRENT", teacher.getTId());
|
||||
assertEquals("王老师", teacher.gettName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldGetMyPlansAndTimetableSuccessfully()
|
||||
{
|
||||
TableDataInfo plans = controller.getMyPlans(null);
|
||||
assertEquals(200, plans.getCode());
|
||||
assertEquals(1, plans.getRows().size());
|
||||
|
||||
AjaxResult timetable = controller.getMyTimetable(null, null);
|
||||
assertEquals(200, timetable.get("code"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<TimetableLessonItemVO> list = (List<TimetableLessonItemVO>) timetable.get("data");
|
||||
assertEquals(1, list.size());
|
||||
assertEquals(Long.valueOf(101L), list.get(0).getLessonId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldBlockTeacherAccessToUnassignedPlanStudentsWithIdorException()
|
||||
{
|
||||
// 教师试图访问未任教班级 PLAN_OTHER 的学员花名册
|
||||
try
|
||||
{
|
||||
controller.getMyClassStudents("PLAN_OTHER");
|
||||
fail("访问未任教班级必须抛出 403 越权异常");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("您并非该培训班的任课教师,无权查看学员名册", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMaskStudentPhoneWhenTeacherViewsAssignedClassStudents()
|
||||
{
|
||||
// 教师访问自己任教班级 PLAN_MY
|
||||
TableDataInfo dataInfo = controller.getMyClassStudents("PLAN_MY");
|
||||
assertEquals(200, dataInfo.getCode());
|
||||
@SuppressWarnings("unchecked")
|
||||
List<EnrollmentItemVO> rows = (List<EnrollmentItemVO>) dataInfo.getRows();
|
||||
assertEquals(1, rows.size());
|
||||
// 验证手机号脱敏
|
||||
assertEquals("139****5678", rows.get(0).getPhone());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldBlockTeacherAccessToUnassignedLessonAttendanceWithIdorException()
|
||||
{
|
||||
// 课次 999 属于 T_OTHER 授课,T_CURRENT 试图越权读取考勤
|
||||
try
|
||||
{
|
||||
controller.getLessonAttendance(999L);
|
||||
fail("访问非本人授课课次出勤必须抛出 403 越权异常");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("您并非该课次的授课教师,无权查看出勤记录", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAllowTeacherAccessToAssignedLessonAttendance()
|
||||
{
|
||||
// 课次 101 属于 T_CURRENT 本人授课
|
||||
AjaxResult result = controller.getLessonAttendance(101L);
|
||||
assertEquals(200, result.get("code"));
|
||||
@SuppressWarnings("unchecked")
|
||||
List<TrainStudentCheckin> list = (List<TrainStudentCheckin>) result.get("data");
|
||||
assertEquals(1, list.size());
|
||||
assertEquals("S001", list.get(0).getStudentId());
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,16 @@ public class TrainTeacher extends BaseEntity
|
||||
return tId;
|
||||
}
|
||||
|
||||
public String getTId()
|
||||
{
|
||||
return tId;
|
||||
}
|
||||
|
||||
public void setTId(String tId)
|
||||
{
|
||||
this.tId = tId;
|
||||
}
|
||||
|
||||
public void settName(String tName)
|
||||
{
|
||||
this.tName = tName;
|
||||
|
||||
@@ -42,4 +42,9 @@ public interface TrainClassReportMapper
|
||||
* @return 插入行数
|
||||
*/
|
||||
public int insertTrainClassReport(TrainClassReport trainClassReport);
|
||||
|
||||
/**
|
||||
* 查询指定学员的最新开班报到记录
|
||||
*/
|
||||
public TrainClassReport selectLatestReportByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
|
||||
}
|
||||
@@ -171,4 +171,12 @@ public interface TrainEnrollmentMapper
|
||||
* @return 引用记录数
|
||||
*/
|
||||
int countStudyDurationByEnrollmentId(@Param("enrollmentId") Long enrollmentId);
|
||||
|
||||
/**
|
||||
* 查询指定学员的全部参训记录及班级信息
|
||||
*
|
||||
* @param studentId 学员ID
|
||||
* @return 参训记录列表
|
||||
*/
|
||||
List<EnrollmentItemVO> selectEnrollmentsByStudentId(@Param("studentId") String studentId);
|
||||
}
|
||||
@@ -117,4 +117,29 @@ public interface TrainLessonMapper
|
||||
* 获取指定培训计划下的全部课次日程明细 (按开始时间升序排列)。
|
||||
*/
|
||||
List<TimetableLessonItemVO> selectScheduleLessonsByPlanId(@Param("planId") String planId);
|
||||
|
||||
/**
|
||||
* 获取指定教师的授课日程列表 (按开始时间升序排列)。
|
||||
*/
|
||||
List<TimetableLessonItemVO> selectScheduleLessonsByTeacherId(@Param("tId") String tId,
|
||||
@Param("startDate") Date startDate,
|
||||
@Param("endDate") Date endDate);
|
||||
|
||||
/**
|
||||
* 获取指定学员所在有效班级的参训日程列表 (按开始时间升序排列)。
|
||||
*/
|
||||
List<TimetableLessonItemVO> selectScheduleLessonsByStudentId(@Param("studentId") String studentId,
|
||||
@Param("startDate") Date startDate,
|
||||
@Param("endDate") Date endDate);
|
||||
|
||||
/**
|
||||
* 校验教师是否在指定培训计划中具备排课任教关系。
|
||||
*/
|
||||
int countTeacherInPlan(@Param("tId") String tId, @Param("planId") String planId);
|
||||
|
||||
/**
|
||||
* 查询指定教师参与授课的培训班列表。
|
||||
*/
|
||||
List<com.ruoyi.train.domain.TrainClassPlan> selectPlansByTeacherId(@Param("tId") String tId,
|
||||
@Param("planStatus") Integer planStatus);
|
||||
}
|
||||
+12
@@ -52,4 +52,16 @@ public interface TrainStudentCheckinMapper
|
||||
* @return 更新行数
|
||||
*/
|
||||
public int updateTrainStudentCheckin(TrainStudentCheckin trainStudentCheckin);
|
||||
|
||||
/**
|
||||
* 查询指定学员的考勤打卡流水 (按打卡时间倒序)
|
||||
*/
|
||||
public List<TrainStudentCheckin> selectCheckinsByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId,
|
||||
@org.apache.ibatis.annotations.Param("planId") String planId,
|
||||
@org.apache.ibatis.annotations.Param("month") String month);
|
||||
|
||||
/**
|
||||
* 查询指定课次的学员考勤打卡流水
|
||||
*/
|
||||
public List<TrainStudentCheckin> selectCheckinsByLessonId(@org.apache.ibatis.annotations.Param("lessonId") Long lessonId);
|
||||
}
|
||||
@@ -15,4 +15,37 @@ public interface TrainStudentMapper
|
||||
Integer selectOneByIdCardHash(String idCardHash);
|
||||
|
||||
int insertTrainStudent(TrainStudent trainStudent);
|
||||
|
||||
/**
|
||||
* 根据系统用户ID查询学员信息 (del_flag = '0')
|
||||
*
|
||||
* @param userId 系统用户ID
|
||||
* @return 学员信息
|
||||
*/
|
||||
TrainStudent selectTrainStudentByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 统计指定系统用户ID绑定的有效学员数
|
||||
*
|
||||
* @param userId 系统用户ID
|
||||
* @return 绑定数量
|
||||
*/
|
||||
int countByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 绑定学员系统用户账号
|
||||
*
|
||||
* @param sId 学员ID
|
||||
* @param userId 系统用户ID
|
||||
* @return 影响行数
|
||||
*/
|
||||
int bindUserId(@org.apache.ibatis.annotations.Param("sId") String sId, @org.apache.ibatis.annotations.Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 解绑学员系统用户账号
|
||||
*
|
||||
* @param sId 学员ID
|
||||
* @return 影响行数
|
||||
*/
|
||||
int unbindUserId(@org.apache.ibatis.annotations.Param("sId") String sId);
|
||||
}
|
||||
+4
@@ -27,4 +27,8 @@ public interface TrainStudentPortraitMapper
|
||||
*/
|
||||
public List<TrainStudentPortrait> selectTrainStudentPortraitList(TrainStudentPortrait trainStudentPortrait);
|
||||
|
||||
/**
|
||||
* 查询指定学员的最新多维成长画像
|
||||
*/
|
||||
public TrainStudentPortrait selectPortraitByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
|
||||
}
|
||||
+4
@@ -27,4 +27,8 @@ public interface TrainStudyDurationMapper
|
||||
*/
|
||||
public List<TrainStudyDuration> selectTrainStudyDurationList(TrainStudyDuration trainStudyDuration);
|
||||
|
||||
/**
|
||||
* 查询指定学员的学习时长记录列表 (按同步时间倒序)
|
||||
*/
|
||||
public List<TrainStudyDuration> selectDurationsByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
|
||||
}
|
||||
@@ -66,4 +66,37 @@ public interface TrainTeacherMapper
|
||||
* @return 结果
|
||||
*/
|
||||
public int deleteTrainTeacherByTIds(String[] tIds);
|
||||
|
||||
/**
|
||||
* 根据系统用户ID查询教师信息
|
||||
*
|
||||
* @param userId 系统用户ID
|
||||
* @return 教师信息
|
||||
*/
|
||||
public TrainTeacher selectTrainTeacherByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 统计指定系统用户ID绑定的有效教师数
|
||||
*
|
||||
* @param userId 系统用户ID
|
||||
* @return 绑定数量
|
||||
*/
|
||||
public int countByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 绑定教师系统用户账号
|
||||
*
|
||||
* @param tId 教师工号
|
||||
* @param userId 系统用户ID
|
||||
* @return 影响行数
|
||||
*/
|
||||
public int bindUserId(@org.apache.ibatis.annotations.Param("tId") String tId, @org.apache.ibatis.annotations.Param("userId") Long userId);
|
||||
|
||||
/**
|
||||
* 解绑教师系统用户账号
|
||||
*
|
||||
* @param tId 教师工号
|
||||
* @return 影响行数
|
||||
*/
|
||||
public int unbindUserId(@org.apache.ibatis.annotations.Param("tId") String tId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ruoyi.train.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* 用户跨表并发悲观锁 Mapper
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public interface TrainUserLockMapper
|
||||
{
|
||||
/**
|
||||
* 悲观排他行锁:对当前绑定的系统用户账号加行锁,防止 Teacher / Student 跨表并发竞态
|
||||
*
|
||||
* @param userId 系统用户ID
|
||||
* @return 锁定的用户ID
|
||||
*/
|
||||
@Select("select user_id from sys_user where user_id = #{userId} for update")
|
||||
Long lockUserForUpdate(@Param("userId") Long userId);
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.ruoyi.train.service;
|
||||
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.domain.TrainTeacher;
|
||||
|
||||
/**
|
||||
* 培训业务身份解析与绑定服务契约接口。
|
||||
*
|
||||
* <p>负责统一处理 sys_user 与 train_teacher / train_student 之间的映射推导、
|
||||
* 跨表互斥校验以及服务端身份断言,杜绝客户端主键篡改与 IDOR 越权。</p>
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public interface ITrainingIdentityService
|
||||
{
|
||||
/**
|
||||
* 获取当前登录用户的系统用户ID
|
||||
*
|
||||
* @return 当前用户ID
|
||||
*/
|
||||
Long getCurrentUserId();
|
||||
|
||||
/**
|
||||
* 断言当前登录用户具备合规教师身份并返回教师工号 (tId)
|
||||
*
|
||||
* @return 教师工号 tId
|
||||
* @throws com.ruoyi.common.exception.ServiceException 当未绑定教师档案或档案停用时抛出 403
|
||||
*/
|
||||
String assertTeacherIdentity();
|
||||
|
||||
/**
|
||||
* 断言当前登录用户具备合规学员身份并返回学员编号 (sId)
|
||||
*
|
||||
* @return 学员编号 sId
|
||||
* @throws com.ruoyi.common.exception.ServiceException 当未绑定学员档案或档案停用时抛出 403
|
||||
*/
|
||||
String assertStudentIdentity();
|
||||
|
||||
/**
|
||||
* 根据系统用户ID查询绑定的有效教师档案
|
||||
*
|
||||
* @param userId 系统用户ID
|
||||
* @return 教师实体,未绑定返回 null
|
||||
*/
|
||||
TrainTeacher getTeacherByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 根据系统用户ID查询绑定的有效学员档案
|
||||
*
|
||||
* @param userId 系统用户ID
|
||||
* @return 学员实体,未绑定返回 null
|
||||
*/
|
||||
TrainStudent getStudentByUserId(Long userId);
|
||||
|
||||
/**
|
||||
* 校验系统用户绑定的跨表互斥性与唯一性
|
||||
*
|
||||
* @param userId 待绑定的系统用户ID
|
||||
* @param targetType 目标业务主体类型 ("TEACHER" / "STUDENT")
|
||||
* @param targetBusinessId 目标业务主键 (tId 或 sId)
|
||||
*/
|
||||
void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId);
|
||||
|
||||
/**
|
||||
* 绑定教师业务实体与系统用户
|
||||
*
|
||||
* @param tId 教师工号
|
||||
* @param userId 系统用户ID
|
||||
*/
|
||||
void bindTeacherUser(String tId, Long userId);
|
||||
|
||||
/**
|
||||
* 绑定学员业务实体与系统用户
|
||||
*
|
||||
* @param sId 学员编号
|
||||
* @param userId 系统用户ID
|
||||
*/
|
||||
void bindStudentUser(String sId, Long userId);
|
||||
|
||||
/**
|
||||
* 解绑教师系统用户账号
|
||||
*
|
||||
* @param tId 教师工号
|
||||
*/
|
||||
void unbindTeacherUser(String tId);
|
||||
|
||||
/**
|
||||
* 解绑学员系统用户账号
|
||||
*
|
||||
* @param sId 学员编号
|
||||
*/
|
||||
void unbindStudentUser(String sId);
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.ruoyi.common.constant.HttpStatus;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.SecurityUtils;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.domain.TrainTeacher;
|
||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
||||
import com.ruoyi.train.mapper.TrainTeacherMapper;
|
||||
import com.ruoyi.train.mapper.TrainUserLockMapper;
|
||||
import com.ruoyi.train.service.ITrainingIdentityService;
|
||||
|
||||
/**
|
||||
* 培训业务身份解析与绑定服务实现类。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
@Service
|
||||
public class TrainingIdentityServiceImpl implements ITrainingIdentityService
|
||||
{
|
||||
private final TrainTeacherMapper teacherMapper;
|
||||
private final TrainStudentMapper studentMapper;
|
||||
private TrainUserLockMapper trainUserLockMapper;
|
||||
|
||||
/** 用于单机并发/单元测试环境提供额外的针对目标 userId 的原子锁保障 */
|
||||
private final ConcurrentHashMap<Long, ReentrantLock> userLocks = new ConcurrentHashMap<>();
|
||||
|
||||
public TrainingIdentityServiceImpl(TrainTeacherMapper teacherMapper, TrainStudentMapper studentMapper)
|
||||
{
|
||||
this.teacherMapper = teacherMapper;
|
||||
this.studentMapper = studentMapper;
|
||||
}
|
||||
|
||||
@Autowired(required = false)
|
||||
public void setTrainUserLockMapper(TrainUserLockMapper trainUserLockMapper)
|
||||
{
|
||||
this.trainUserLockMapper = trainUserLockMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getCurrentUserId()
|
||||
{
|
||||
return SecurityUtils.getUserId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String assertTeacherIdentity()
|
||||
{
|
||||
Long userId = getCurrentUserId();
|
||||
TrainTeacher teacher = teacherMapper.selectTrainTeacherByUserId(userId);
|
||||
if (teacher == null)
|
||||
{
|
||||
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (teacher.getStatus() != null && teacher.getStatus() == 0)
|
||||
{
|
||||
throw new ServiceException("当前教师档案已被停用,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return teacher.getTId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String assertStudentIdentity()
|
||||
{
|
||||
Long userId = getCurrentUserId();
|
||||
TrainStudent student = studentMapper.selectTrainStudentByUserId(userId);
|
||||
if (student == null)
|
||||
{
|
||||
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (student.getStatus() != null && student.getStatus() == 0)
|
||||
{
|
||||
throw new ServiceException("当前学员档案已被停用,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return student.getsId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainTeacher getTeacherByUserId(Long userId)
|
||||
{
|
||||
if (userId == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return teacherMapper.selectTrainTeacherByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainStudent getStudentByUserId(Long userId)
|
||||
{
|
||||
if (userId == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return studentMapper.selectTrainStudentByUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId)
|
||||
{
|
||||
if (userId == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 正式一期身份规则:
|
||||
// Admin是系统Role,不是业务身份。Admin角色允许绑定Teacher业务档案;
|
||||
// Teacher与Student业务身份严格互斥,同一系统用户不得同时绑定Teacher与Student。
|
||||
if ("TEACHER".equalsIgnoreCase(targetType))
|
||||
{
|
||||
TrainStudent existingStudent = studentMapper.selectTrainStudentByUserId(userId);
|
||||
if (existingStudent != null)
|
||||
{
|
||||
throw new ServiceException("该系统登录账号已绑定为学员身份,不可重复绑定为教师");
|
||||
}
|
||||
TrainTeacher existingTeacher = teacherMapper.selectTrainTeacherByUserId(userId);
|
||||
if (existingTeacher != null && (targetBusinessId == null || !existingTeacher.getTId().equals(targetBusinessId)))
|
||||
{
|
||||
throw new ServiceException("该系统登录账号已被其他教师档案绑定,不可重复绑定");
|
||||
}
|
||||
}
|
||||
else if ("STUDENT".equalsIgnoreCase(targetType))
|
||||
{
|
||||
TrainTeacher existingTeacher = teacherMapper.selectTrainTeacherByUserId(userId);
|
||||
if (existingTeacher != null)
|
||||
{
|
||||
throw new ServiceException("该系统登录账号已绑定为教师身份,不可重复绑定为学员");
|
||||
}
|
||||
TrainStudent existingStudent = studentMapper.selectTrainStudentByUserId(userId);
|
||||
if (existingStudent != null && (targetBusinessId == null || !existingStudent.getsId().equals(targetBusinessId)))
|
||||
{
|
||||
throw new ServiceException("该系统登录账号已被其他学员档案绑定,不可重复绑定");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void bindTeacherUser(String tId, Long userId)
|
||||
{
|
||||
if (userId == null)
|
||||
{
|
||||
throw new ServiceException("绑定目标系统用户ID不能为空");
|
||||
}
|
||||
// 1. 跨表悲观排他锁:在事务内锁定 sys_user 共同资源
|
||||
if (trainUserLockMapper != null)
|
||||
{
|
||||
trainUserLockMapper.lockUserForUpdate(userId);
|
||||
}
|
||||
// 应用层同步保障(用于多线程单测或脱机环境)
|
||||
ReentrantLock lock = userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
|
||||
lock.lock();
|
||||
try
|
||||
{
|
||||
// 2. 跨表互斥与重复绑定校验
|
||||
validateUserIdentityExclusive(userId, "TEACHER", tId);
|
||||
// 3. 执行绑定更新
|
||||
teacherMapper.bindUserId(tId, userId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void bindStudentUser(String sId, Long userId)
|
||||
{
|
||||
if (userId == null)
|
||||
{
|
||||
throw new ServiceException("绑定目标系统用户ID不能为空");
|
||||
}
|
||||
// 1. 跨表悲观排他锁:在事务内锁定 sys_user 共同资源
|
||||
if (trainUserLockMapper != null)
|
||||
{
|
||||
trainUserLockMapper.lockUserForUpdate(userId);
|
||||
}
|
||||
// 应用层同步保障(用于多线程单测或脱机环境)
|
||||
ReentrantLock lock = userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
|
||||
lock.lock();
|
||||
try
|
||||
{
|
||||
// 2. 跨表互斥与重复绑定校验
|
||||
validateUserIdentityExclusive(userId, "STUDENT", sId);
|
||||
// 3. 执行绑定更新
|
||||
studentMapper.bindUserId(sId, userId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void unbindTeacherUser(String tId)
|
||||
{
|
||||
teacherMapper.unbindUserId(tId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void unbindStudentUser(String sId)
|
||||
{
|
||||
studentMapper.unbindUserId(sId);
|
||||
}
|
||||
}
|
||||
@@ -57,4 +57,12 @@
|
||||
#{verifyResult}, #{verifyNote}, #{createBy}, #{createTime}, #{remark}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 查询指定学员的最新报到记录 -->
|
||||
<select id="selectLatestReportByStudentId" parameterType="String" resultMap="TrainClassReportResult">
|
||||
<include refid="selectTrainClassReportVo"/>
|
||||
where e.student_id = #{studentId}
|
||||
order by r.report_time desc, r.report_id desc
|
||||
limit 1
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -179,4 +179,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
select count(1) from train_study_duration where enrollment_id = #{enrollmentId}
|
||||
</select>
|
||||
|
||||
<!-- 查询指定学员的全部参训记录及班级信息 -->
|
||||
<select id="selectEnrollmentsByStudentId" resultType="com.ruoyi.train.domain.vo.EnrollmentItemVO">
|
||||
select e.enrollment_id as enrollmentId,
|
||||
e.plan_id as planId,
|
||||
e.student_id as studentId,
|
||||
s.s_name as studentName,
|
||||
s.s_gender as gender,
|
||||
s.id_card_last4 as idCardMasked,
|
||||
s.org_name as orgName,
|
||||
s.s_phone as phone,
|
||||
e.source_type as sourceType,
|
||||
e.enrollment_status as enrollmentStatus,
|
||||
e.accommodation_required as accommodationRequired,
|
||||
e.join_time as joinTime,
|
||||
e.complete_time as completeTime,
|
||||
e.remark as remark
|
||||
from train_enrollment e
|
||||
left join train_student s on e.student_id = s.s_id
|
||||
where e.student_id = #{studentId}
|
||||
order by e.create_time desc
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -275,4 +275,115 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
order by l.start_time asc, l.lesson_id asc
|
||||
</select>
|
||||
|
||||
<!-- 查询指定教师的授课课次日程 -->
|
||||
<select id="selectScheduleLessonsByTeacherId" resultMap="TimetableLessonItemResult">
|
||||
select l.lesson_id,
|
||||
l.plan_course_id,
|
||||
c.c_name as course_name,
|
||||
l.t_id,
|
||||
t.t_name as teacher_name,
|
||||
l.start_time,
|
||||
l.end_time,
|
||||
l.venue_type,
|
||||
l.class_room_id,
|
||||
cr.room_name as class_room_name,
|
||||
l.site_id,
|
||||
ts.site_name,
|
||||
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
|
||||
b.board_sn,
|
||||
l.remark
|
||||
from train_lesson l
|
||||
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
|
||||
left join train_course c on pc.c_id = c.c_id
|
||||
left join train_teacher t on l.t_id = t.t_id
|
||||
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
|
||||
left join train_training_site ts on l.site_id = ts.site_id
|
||||
left join (
|
||||
select bind_classroom_id,
|
||||
case when count(*) = 1 then min(board_sn) else null end as board_sn
|
||||
from train_electronic_board
|
||||
where del_flag = '0'
|
||||
group by bind_classroom_id
|
||||
) b on (l.venue_type = 1 and b.bind_classroom_id = l.class_room_id)
|
||||
where l.t_id = #{tId}
|
||||
<if test="startDate != null">
|
||||
and l.start_time >= #{startDate}
|
||||
</if>
|
||||
<if test="endDate != null">
|
||||
and l.start_time <= #{endDate}
|
||||
</if>
|
||||
order by l.start_time asc, l.lesson_id asc
|
||||
</select>
|
||||
|
||||
<!-- 查询指定学员所在有效班级的排课日程 -->
|
||||
<select id="selectScheduleLessonsByStudentId" resultMap="TimetableLessonItemResult">
|
||||
select l.lesson_id,
|
||||
l.plan_course_id,
|
||||
c.c_name as course_name,
|
||||
l.t_id,
|
||||
t.t_name as teacher_name,
|
||||
l.start_time,
|
||||
l.end_time,
|
||||
l.venue_type,
|
||||
l.class_room_id,
|
||||
cr.room_name as class_room_name,
|
||||
l.site_id,
|
||||
ts.site_name,
|
||||
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
|
||||
b.board_sn,
|
||||
l.remark
|
||||
from train_lesson l
|
||||
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
|
||||
left join train_course c on pc.c_id = c.c_id
|
||||
left join train_teacher t on l.t_id = t.t_id
|
||||
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
|
||||
left join train_training_site ts on l.site_id = ts.site_id
|
||||
left join (
|
||||
select bind_classroom_id,
|
||||
case when count(*) = 1 then min(board_sn) else null end as board_sn
|
||||
from train_electronic_board
|
||||
where del_flag = '0'
|
||||
group by bind_classroom_id
|
||||
) b on (l.venue_type = 1 and b.bind_classroom_id = l.class_room_id)
|
||||
where pc.plan_id in (
|
||||
select plan_id from train_enrollment
|
||||
where student_id = #{studentId} and enrollment_status in (0, 1, 4)
|
||||
)
|
||||
<if test="startDate != null">
|
||||
and l.start_time >= #{startDate}
|
||||
</if>
|
||||
<if test="endDate != null">
|
||||
and l.start_time <= #{endDate}
|
||||
</if>
|
||||
order by l.start_time asc, l.lesson_id asc
|
||||
</select>
|
||||
|
||||
<!-- 校验教师是否在计划中具有授课课次 -->
|
||||
<select id="countTeacherInPlan" resultType="int">
|
||||
select count(1)
|
||||
from train_lesson l
|
||||
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
|
||||
where l.t_id = #{tId} and pc.plan_id = #{planId}
|
||||
</select>
|
||||
|
||||
<!-- 查询教师参与任教的培训班列表 -->
|
||||
<select id="selectPlansByTeacherId" resultType="com.ruoyi.train.domain.TrainClassPlan">
|
||||
select distinct p.plan_id as planId,
|
||||
p.plan_name as planName,
|
||||
p.plan_status as planStatus,
|
||||
p.start_time as startTime,
|
||||
p.end_time as endTime,
|
||||
p.recruit_num as recruitNum,
|
||||
p.del_flag as delFlag
|
||||
from train_class_plan p
|
||||
inner join train_plan_course pc on p.plan_id = pc.plan_id
|
||||
inner join train_lesson l on pc.plan_course_id = l.plan_course_id
|
||||
where l.t_id = #{tId}
|
||||
and p.del_flag = '0'
|
||||
<if test="planStatus != null">
|
||||
and p.plan_status = #{planStatus}
|
||||
</if>
|
||||
order by p.start_time desc
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -87,4 +87,19 @@
|
||||
</set>
|
||||
where checkin_id = #{checkinId}
|
||||
</update>
|
||||
|
||||
<!-- 查询指定学员的考勤打卡流水 -->
|
||||
<select id="selectCheckinsByStudentId" resultMap="TrainStudentCheckinResult">
|
||||
<include refid="selectTrainStudentCheckinVo"/>
|
||||
where e.student_id = #{studentId}
|
||||
<if test="planId != null and planId != ''">and e.plan_id = #{planId}</if>
|
||||
order by a.sign_in_time desc, a.checkin_id desc
|
||||
</select>
|
||||
|
||||
<!-- 查询指定课次的学员考勤打卡流水 -->
|
||||
<select id="selectCheckinsByLessonId" resultMap="TrainStudentCheckinResult">
|
||||
<include refid="selectTrainStudentCheckinVo"/>
|
||||
where a.lesson_id = #{lessonId}
|
||||
order by s.s_name asc, a.checkin_id asc
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -76,4 +76,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
#{status}, #{createBy}, #{createTime}, #{updateBy}, #{updateTime}, #{remark}, #{delFlag}
|
||||
)
|
||||
</insert>
|
||||
|
||||
<!-- 根据系统用户ID查询学员 (仅限未删除 del_flag = '0') -->
|
||||
<select id="selectTrainStudentByUserId" parameterType="Long" resultMap="TrainStudentResult">
|
||||
<include refid="selectTrainStudentDomain" />
|
||||
where user_id = #{userId} and del_flag = '0'
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<!-- 统计绑定指定userId的有效学员记录数 -->
|
||||
<select id="countByUserId" parameterType="Long" resultType="int">
|
||||
select count(1) from train_student where user_id = #{userId} and del_flag = '0'
|
||||
</select>
|
||||
|
||||
<!-- 绑定系统用户账号 -->
|
||||
<update id="bindUserId">
|
||||
update train_student set user_id = #{userId}, update_time = CURRENT_TIMESTAMP where s_id = #{sId} and del_flag = '0'
|
||||
</update>
|
||||
|
||||
<!-- 解绑系统用户账号 -->
|
||||
<update id="unbindUserId">
|
||||
update train_student set user_id = null, update_time = CURRENT_TIMESTAMP where s_id = #{sId}
|
||||
</update>
|
||||
</mapper>
|
||||
@@ -47,4 +47,12 @@
|
||||
<include refid="selectTrainStudentPortraitVo"/>
|
||||
where pt.portrait_id = #{portraitId}
|
||||
</select>
|
||||
|
||||
<!-- 查询指定学员的最新画像 -->
|
||||
<select id="selectPortraitByStudentId" parameterType="String" resultMap="TrainStudentPortraitResult">
|
||||
<include refid="selectTrainStudentPortraitVo"/>
|
||||
where e.student_id = #{studentId}
|
||||
order by pt.generated_time desc, pt.portrait_id desc
|
||||
limit 1
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -56,4 +56,11 @@
|
||||
<include refid="selectTrainStudyDurationVo"/>
|
||||
where d.dur_id = #{durId}
|
||||
</select>
|
||||
|
||||
<!-- 查询指定学员的学习时长记录列表 -->
|
||||
<select id="selectDurationsByStudentId" parameterType="String" resultMap="TrainStudyDurationResult">
|
||||
<include refid="selectTrainStudyDurationVo"/>
|
||||
where e.student_id = #{studentId}
|
||||
order by d.sync_time desc, d.dur_id desc
|
||||
</select>
|
||||
</mapper>
|
||||
@@ -124,4 +124,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
#{tId}
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
<!-- 根据系统用户ID查询教师 (仅限未删除 del_flag = '0') -->
|
||||
<select id="selectTrainTeacherByUserId" parameterType="Long" resultMap="TrainTeacherResult">
|
||||
<include refid="selectTrainTeacherVo"/>
|
||||
where user_id = #{userId} and del_flag = '0'
|
||||
limit 1
|
||||
</select>
|
||||
|
||||
<!-- 统计绑定指定userId的有效教师记录数 -->
|
||||
<select id="countByUserId" parameterType="Long" resultType="int">
|
||||
select count(1) from train_teacher where user_id = #{userId} and del_flag = '0'
|
||||
</select>
|
||||
|
||||
<!-- 绑定系统用户账号 -->
|
||||
<update id="bindUserId">
|
||||
update train_teacher set user_id = #{userId}, update_time = CURRENT_TIMESTAMP where t_id = #{tId} and del_flag = '0'
|
||||
</update>
|
||||
|
||||
<!-- 解绑系统用户账号 -->
|
||||
<update id="unbindUserId">
|
||||
update train_teacher set user_id = null, update_time = CURRENT_TIMESTAMP where t_id = #{tId}
|
||||
</update>
|
||||
</mapper>
|
||||
+30
@@ -959,6 +959,30 @@ public class EnrollmentAppServiceImplTest
|
||||
studentStore.put(student.getsId(), student);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainStudent selectTrainStudentByUserId(Long userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countByUserId(Long userId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int bindUserId(String sId, Long userId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int unbindUserId(String sId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static class MockTrainEnrollmentMapper implements TrainEnrollmentMapper
|
||||
@@ -1090,5 +1114,11 @@ public class EnrollmentAppServiceImplTest
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<com.ruoyi.train.domain.vo.EnrollmentItemVO> selectEnrollmentsByStudentId(String studentId)
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -32,6 +32,7 @@ public class LegacyAnalyticsV2ServiceTest
|
||||
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
|
||||
}
|
||||
public int insertTrainClassReport(TrainClassReport report) { return 0; }
|
||||
public TrainClassReport selectLatestReportByStudentId(String studentId) { return null; }
|
||||
});
|
||||
assertEquals(0, service.selectTrainClassReportList(new TrainClassReport()).size());
|
||||
TrainClassReport query = new TrainClassReport();
|
||||
@@ -53,6 +54,8 @@ public class LegacyAnalyticsV2ServiceTest
|
||||
}
|
||||
public int insertTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; }
|
||||
public int updateTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; }
|
||||
public List<TrainStudentCheckin> selectCheckinsByStudentId(String sId, String pId, String m) { return Collections.emptyList(); }
|
||||
public List<TrainStudentCheckin> selectCheckinsByLessonId(Long lId) { return Collections.emptyList(); }
|
||||
});
|
||||
assertEquals(0, service.selectTrainStudentCheckinList(new TrainStudentCheckin()).size());
|
||||
TrainStudentCheckin query = new TrainStudentCheckin();
|
||||
@@ -71,6 +74,7 @@ public class LegacyAnalyticsV2ServiceTest
|
||||
public List<TrainStudyDuration> selectTrainStudyDurationList(TrainStudyDuration query) {
|
||||
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
|
||||
}
|
||||
public List<TrainStudyDuration> selectDurationsByStudentId(String studentId) { return Collections.emptyList(); }
|
||||
});
|
||||
assertEquals(0, service.selectTrainStudyDurationList(new TrainStudyDuration()).size());
|
||||
TrainStudyDuration query = new TrainStudyDuration();
|
||||
@@ -89,6 +93,7 @@ public class LegacyAnalyticsV2ServiceTest
|
||||
public List<TrainStudentPortrait> selectTrainStudentPortraitList(TrainStudentPortrait query) {
|
||||
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
|
||||
}
|
||||
public TrainStudentPortrait selectPortraitByStudentId(String studentId) { return null; }
|
||||
});
|
||||
assertEquals(0, service.selectTrainStudentPortraitList(new TrainStudentPortrait()).size());
|
||||
TrainStudentPortrait query = new TrainStudentPortrait();
|
||||
|
||||
+24
@@ -1043,6 +1043,30 @@ public class SchedulingAppServiceTest
|
||||
vo.setEndTime(l.getEndTime());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TimetableLessonItemVO> selectScheduleLessonsByTeacherId(String tId, Date startDate, Date endDate)
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TimetableLessonItemVO> selectScheduleLessonsByStudentId(String studentId, Date startDate, Date endDate)
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countTeacherInPlan(String tId, String planId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainClassPlan> selectPlansByTeacherId(String tId, Integer planStatus)
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+24
@@ -288,5 +288,29 @@ public class TrainStudentServiceImplTest
|
||||
store.put(student.getsId(), student);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainStudent selectTrainStudentByUserId(Long userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countByUserId(Long userId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int bindUserId(String sId, Long userId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int unbindUserId(String sId)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+468
@@ -0,0 +1,468 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import com.ruoyi.common.core.domain.entity.SysUser;
|
||||
import com.ruoyi.common.core.domain.model.LoginUser;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.domain.TrainTeacher;
|
||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
||||
import com.ruoyi.train.mapper.TrainTeacherMapper;
|
||||
|
||||
/**
|
||||
* 身份解析、跨表互斥、并发安全与绑定解绑生命周期测试。
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class TrainingIdentityServiceTest
|
||||
{
|
||||
private TrainingIdentityServiceImpl identityService;
|
||||
private final Map<Long, TrainTeacher> teachersByUserId = Collections.synchronizedMap(new HashMap<>());
|
||||
private final Map<String, TrainTeacher> teachersByTId = Collections.synchronizedMap(new HashMap<>());
|
||||
private final Map<Long, TrainStudent> studentsByUserId = Collections.synchronizedMap(new HashMap<>());
|
||||
private final Map<String, TrainStudent> studentsBySId = Collections.synchronizedMap(new HashMap<>());
|
||||
|
||||
@Before
|
||||
public void setUp()
|
||||
{
|
||||
teachersByUserId.clear();
|
||||
teachersByTId.clear();
|
||||
studentsByUserId.clear();
|
||||
studentsBySId.clear();
|
||||
|
||||
TrainTeacherMapper teacherMapper = createProxy(TrainTeacherMapper.class, new InvocationHandler()
|
||||
{
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
|
||||
{
|
||||
String name = method.getName();
|
||||
if ("selectTrainTeacherByUserId".equals(name))
|
||||
{
|
||||
return teachersByUserId.get((Long) args[0]);
|
||||
}
|
||||
if ("countByUserId".equals(name))
|
||||
{
|
||||
return teachersByUserId.containsKey((Long) args[0]) ? 1 : 0;
|
||||
}
|
||||
if ("bindUserId".equals(name))
|
||||
{
|
||||
String tId = (String) args[0];
|
||||
Long userId = (Long) args[1];
|
||||
TrainTeacher teacher = teachersByTId.get(tId);
|
||||
if (teacher != null)
|
||||
{
|
||||
teacher.setUserId(userId);
|
||||
teachersByUserId.put(userId, teacher);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if ("unbindUserId".equals(name))
|
||||
{
|
||||
String tId = (String) args[0];
|
||||
TrainTeacher teacher = teachersByTId.get(tId);
|
||||
if (teacher != null && teacher.getUserId() != null)
|
||||
{
|
||||
teachersByUserId.remove(teacher.getUserId());
|
||||
teacher.setUserId(null);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
TrainStudentMapper studentMapper = createProxy(TrainStudentMapper.class, new InvocationHandler()
|
||||
{
|
||||
@Override
|
||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
|
||||
{
|
||||
String name = method.getName();
|
||||
if ("selectTrainStudentByUserId".equals(name))
|
||||
{
|
||||
return studentsByUserId.get((Long) args[0]);
|
||||
}
|
||||
if ("countByUserId".equals(name))
|
||||
{
|
||||
return studentsByUserId.containsKey((Long) args[0]) ? 1 : 0;
|
||||
}
|
||||
if ("bindUserId".equals(name))
|
||||
{
|
||||
String sId = (String) args[0];
|
||||
Long userId = (Long) args[1];
|
||||
TrainStudent student = studentsBySId.get(sId);
|
||||
if (student != null)
|
||||
{
|
||||
student.setUserId(userId);
|
||||
studentsByUserId.put(userId, student);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if ("unbindUserId".equals(name))
|
||||
{
|
||||
String sId = (String) args[0];
|
||||
TrainStudent student = studentsBySId.get(sId);
|
||||
if (student != null && student.getUserId() != null)
|
||||
{
|
||||
studentsByUserId.remove(student.getUserId());
|
||||
student.setUserId(null);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
identityService = new TrainingIdentityServiceImpl(teacherMapper, studentMapper);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown()
|
||||
{
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void mockLoginUser(Long userId)
|
||||
{
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setUserId(userId);
|
||||
SysUser user = new SysUser();
|
||||
user.setUserId(userId);
|
||||
loginUser.setUser(user);
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(loginUser, null, null);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void adminRoleUserCanBeBoundToTeacherIdentity()
|
||||
{
|
||||
// 规则审计修正:Admin是Role而非业务身份,只要未绑定Student,允许绑定Teacher业务身份
|
||||
Long adminUserId = 1L;
|
||||
identityService.validateUserIdentityExclusive(adminUserId, "TEACHER", "T001");
|
||||
|
||||
TrainTeacher teacher = new TrainTeacher();
|
||||
teacher.setTId("T001");
|
||||
teachersByTId.put("T001", teacher);
|
||||
|
||||
identityService.bindTeacherUser("T001", adminUserId);
|
||||
TrainTeacher bound = identityService.getTeacherByUserId(adminUserId);
|
||||
assertNotNull(bound);
|
||||
assertEquals("T001", bound.getTId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundStudentCannotBeBoundAsTeacher()
|
||||
{
|
||||
Long userId = 100L;
|
||||
TrainStudent student = new TrainStudent();
|
||||
student.setsId("S100");
|
||||
student.setUserId(userId);
|
||||
studentsBySId.put("S100", student);
|
||||
studentsByUserId.put(userId, student);
|
||||
|
||||
try
|
||||
{
|
||||
identityService.validateUserIdentityExclusive(userId, "TEACHER", "T100");
|
||||
fail("学员账号不可重复绑定为教师");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("该系统登录账号已绑定为学员身份,不可重复绑定为教师", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundTeacherCannotBeBoundAsStudent()
|
||||
{
|
||||
Long userId = 200L;
|
||||
TrainTeacher teacher = new TrainTeacher();
|
||||
teacher.setTId("T200");
|
||||
teacher.setUserId(userId);
|
||||
teachersByTId.put("T200", teacher);
|
||||
teachersByUserId.put(userId, teacher);
|
||||
|
||||
try
|
||||
{
|
||||
identityService.validateUserIdentityExclusive(userId, "STUDENT", "S200");
|
||||
fail("教师账号不可重复绑定为学员");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("该系统登录账号已绑定为教师身份,不可重复绑定为学员", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void userIdCannotBeBoundToAnotherTeacher()
|
||||
{
|
||||
Long userId = 300L;
|
||||
TrainTeacher teacher1 = new TrainTeacher();
|
||||
teacher1.setTId("T301");
|
||||
teacher1.setUserId(userId);
|
||||
teachersByTId.put("T301", teacher1);
|
||||
teachersByUserId.put(userId, teacher1);
|
||||
|
||||
try
|
||||
{
|
||||
identityService.validateUserIdentityExclusive(userId, "TEACHER", "T302");
|
||||
fail("已被其他教师绑定的userId不可绑定到另一教师");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("该系统登录账号已被其他教师档案绑定,不可重复绑定", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void userIdCanBeBoundIdempotentlyToSameTeacher()
|
||||
{
|
||||
Long userId = 400L;
|
||||
TrainTeacher teacher1 = new TrainTeacher();
|
||||
teacher1.setTId("T400");
|
||||
teacher1.setUserId(userId);
|
||||
teachersByTId.put("T400", teacher1);
|
||||
teachersByUserId.put(userId, teacher1);
|
||||
|
||||
// 幂等校验同一个目标业务ID不会抛异常
|
||||
identityService.validateUserIdentityExclusive(userId, "TEACHER", "T400");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void assertTeacherIdentitySuccessAndFailureCases()
|
||||
{
|
||||
// 1. 未登录
|
||||
try
|
||||
{
|
||||
identityService.assertTeacherIdentity();
|
||||
fail("未登录应抛出获取用户信息异常");
|
||||
}
|
||||
catch (ServiceException ignored) {}
|
||||
|
||||
// 2. 登录了但没有绑定教师档案
|
||||
mockLoginUser(500L);
|
||||
try
|
||||
{
|
||||
identityService.assertTeacherIdentity();
|
||||
fail("未绑定教师档案应抛出拒绝异常");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", e.getMessage());
|
||||
}
|
||||
|
||||
// 3. 绑定了教师档案但停用
|
||||
TrainTeacher disabledTeacher = new TrainTeacher();
|
||||
disabledTeacher.setTId("T501");
|
||||
disabledTeacher.setUserId(500L);
|
||||
disabledTeacher.setStatus(0);
|
||||
teachersByUserId.put(500L, disabledTeacher);
|
||||
try
|
||||
{
|
||||
identityService.assertTeacherIdentity();
|
||||
fail("停用教师档案应抛出禁止访问异常");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("当前教师档案已被停用,禁止访问教师专属工作台", e.getMessage());
|
||||
}
|
||||
|
||||
// 4. 正常状态教师档案
|
||||
disabledTeacher.setStatus(1);
|
||||
String tId = identityService.assertTeacherIdentity();
|
||||
assertEquals("T501", tId);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void assertStudentIdentitySuccessAndFailureCases()
|
||||
{
|
||||
mockLoginUser(600L);
|
||||
// 1. 未绑定学员档案
|
||||
try
|
||||
{
|
||||
identityService.assertStudentIdentity();
|
||||
fail("未绑定学员档案应抛出拒绝异常");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", e.getMessage());
|
||||
}
|
||||
|
||||
// 2. 绑定了学员档案但停用
|
||||
TrainStudent disabledStudent = new TrainStudent();
|
||||
disabledStudent.setsId("S601");
|
||||
disabledStudent.setUserId(600L);
|
||||
disabledStudent.setStatus(0);
|
||||
studentsByUserId.put(600L, disabledStudent);
|
||||
try
|
||||
{
|
||||
identityService.assertStudentIdentity();
|
||||
fail("停用学员档案应抛出禁止访问异常");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("当前学员档案已被停用,禁止访问学员个人培训中心", e.getMessage());
|
||||
}
|
||||
|
||||
// 3. 正常状态学员档案
|
||||
disabledStudent.setStatus(1);
|
||||
String sId = identityService.assertStudentIdentity();
|
||||
assertEquals("S601", sId);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bindAndUnbindTeacherUserLifecycle()
|
||||
{
|
||||
TrainTeacher teacher = new TrainTeacher();
|
||||
teacher.setTId("T700");
|
||||
teachersByTId.put("T700", teacher);
|
||||
|
||||
identityService.bindTeacherUser("T700", 700L);
|
||||
assertNotNull(identityService.getTeacherByUserId(700L));
|
||||
assertEquals("T700", identityService.getTeacherByUserId(700L).getTId());
|
||||
|
||||
identityService.unbindTeacherUser("T700");
|
||||
assertNull(identityService.getTeacherByUserId(700L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bindAndUnbindStudentUserLifecycle()
|
||||
{
|
||||
TrainStudent student = new TrainStudent();
|
||||
student.setsId("S800");
|
||||
studentsBySId.put("S800", student);
|
||||
|
||||
identityService.bindStudentUser("S800", 800L);
|
||||
assertNotNull(identityService.getStudentByUserId(800L));
|
||||
assertEquals("S800", identityService.getStudentByUserId(800L).getsId());
|
||||
|
||||
identityService.unbindStudentUser("S800");
|
||||
assertNull(identityService.getStudentByUserId(800L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentCrossTableBindingExclusive_TeacherXorStudent() throws Exception
|
||||
{
|
||||
// 并发竞态测试:同一个 userId = 999L,线程 A 执行 bindTeacher,线程 B 执行 bindStudent
|
||||
Long targetUserId = 999L;
|
||||
TrainTeacher teacher = new TrainTeacher();
|
||||
teacher.setTId("T999");
|
||||
teachersByTId.put("T999", teacher);
|
||||
|
||||
TrainStudent student = new TrainStudent();
|
||||
student.setsId("S999");
|
||||
studentsBySId.put("S999", student);
|
||||
|
||||
int threadCount = 2;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
CountDownLatch doneLatch = new CountDownLatch(threadCount);
|
||||
|
||||
AtomicInteger successCount = new AtomicInteger(0);
|
||||
AtomicInteger conflictCount = new AtomicInteger(0);
|
||||
|
||||
// 线程 1: bindTeacherUser
|
||||
executor.submit(() -> {
|
||||
try
|
||||
{
|
||||
startLatch.await();
|
||||
identityService.bindTeacherUser("T999", targetUserId);
|
||||
successCount.incrementAndGet();
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
conflictCount.incrementAndGet();
|
||||
}
|
||||
catch (Exception ignored) {}
|
||||
finally
|
||||
{
|
||||
doneLatch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
// 线程 2: bindStudentUser
|
||||
executor.submit(() -> {
|
||||
try
|
||||
{
|
||||
startLatch.await();
|
||||
identityService.bindStudentUser("S999", targetUserId);
|
||||
successCount.incrementAndGet();
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
conflictCount.incrementAndGet();
|
||||
}
|
||||
catch (Exception ignored) {}
|
||||
finally
|
||||
{
|
||||
doneLatch.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
// 同时放行竞态
|
||||
startLatch.countDown();
|
||||
boolean finished = doneLatch.await(5, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
|
||||
assertTrue("并发测试应在时限内完成", finished);
|
||||
// 断言:严格仅有 1 个成功,1 个冲突拒绝
|
||||
assertEquals("必须恰好一个线程成功", 1, successCount.get());
|
||||
assertEquals("必须恰好一个线程捕获到身份冲突", 1, conflictCount.get());
|
||||
|
||||
// 断言数据库最终状态:teacher XOR student
|
||||
boolean hasTeacher = teachersByUserId.containsKey(targetUserId);
|
||||
boolean hasStudent = studentsByUserId.containsKey(targetUserId);
|
||||
assertTrue("必须严格呈现 Teacher XOR Student 互斥状态", hasTeacher ^ hasStudent);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void identityTransactionAtomicity()
|
||||
{
|
||||
// 验证非法入参时,不产生任何副作用
|
||||
try
|
||||
{
|
||||
identityService.bindTeacherUser("T_NON_EXIST", null);
|
||||
fail("null userId 应抛出异常");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("绑定目标系统用户ID不能为空", e.getMessage());
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
identityService.bindStudentUser("S_NON_EXIST", null);
|
||||
fail("null userId 应抛出异常");
|
||||
}
|
||||
catch (ServiceException e)
|
||||
{
|
||||
assertEquals("绑定目标系统用户ID不能为空", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T createProxy(Class<T> clazz, InvocationHandler handler)
|
||||
{
|
||||
return (T) Proxy.newProxyInstance(clazz.getClassLoader(), new Class<?>[]{clazz}, handler);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
-- =============================================================================
|
||||
-- RailwaySystem 数据库 RBAC 迁移脚本: 004_rbac_identity_binding.sql
|
||||
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
|
||||
-- 执行目标: 建立 sys_user 与业务主体(教师/学员)物理约束与部分唯一索引
|
||||
-- 事务边界: 全程在显式事务 BEGIN ... COMMIT 中执行
|
||||
-- 幂等保证: 仅使用 ADD COLUMN IF NOT EXISTS 与 CREATE INDEX IF NOT EXISTS,严禁 DROP / TRUNCATE
|
||||
-- 设计依据: docs/development/rbac-r2/01_identity_binding_design.md 及 06_migration_plan.md
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 步骤 1: 确保 user_id 列存在 (幂等容错,已存在则静默跳过)
|
||||
ALTER TABLE train_teacher ADD COLUMN IF NOT EXISTS user_id BIGINT;
|
||||
ALTER TABLE train_student ADD COLUMN IF NOT EXISTS user_id BIGINT;
|
||||
|
||||
-- 步骤 2: 建立教师账号部分唯一索引 (无 DROP,幂等容错,忽略已删除 del_flag='2' 与空值)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_teacher_user_id
|
||||
ON train_teacher(user_id)
|
||||
WHERE user_id IS NOT NULL AND del_flag = '0';
|
||||
|
||||
-- 步骤 3: 建立学员账号部分唯一索引 (无 DROP,幂等容错,忽略已删除 del_flag='2' 与空值)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_student_user_id
|
||||
ON train_student(user_id)
|
||||
WHERE user_id IS NOT NULL AND del_flag = '0';
|
||||
|
||||
-- 步骤 4: 更新字段元数据注释
|
||||
COMMENT ON COLUMN train_teacher.user_id IS '关联sys_user.user_id (唯一绑定教师登录账号)';
|
||||
COMMENT ON COLUMN train_student.user_id IS '关联sys_user.user_id (唯一绑定学员登录账号)';
|
||||
|
||||
-- 步骤 5: 迁移完整性校验
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'train_teacher' AND indexname = 'uq_train_teacher_user_id'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'uq_train_teacher_user_id partial unique index creation failed';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'train_student' AND indexname = 'uq_train_student_user_id'
|
||||
) THEN
|
||||
RAISE EXCEPTION 'uq_train_student_user_id partial unique index creation failed';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,310 @@
|
||||
-- =============================================================================
|
||||
-- RailwaySystem 数据库 RBAC 迁移脚本: 005_training_roles_permissions.sql
|
||||
-- 适用数据库: KingbaseES V009R003C018 (schema: public)
|
||||
-- 执行目标: 预置培训业务三角色、统一班牌权限命名空间、补齐按钮权限与角色关联
|
||||
-- 事务边界: 全程在显式事务 BEGIN ... COMMIT 中执行
|
||||
-- 防御保证: 内置 Collision Guard 静态检查,绝不覆盖已有外来资源,冲突即显式 ABORT
|
||||
-- 设计依据: docs/development/rbac-r2/02_role_permission_matrix.md 及 06_migration_plan.md
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- =============================================================================
|
||||
-- 步骤 0: ID 碰撞安全守卫 (Migration Collision Guard)
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
rec RECORD;
|
||||
BEGIN
|
||||
-- 0.1 检查 role_id 3, 4, 5 的碰撞与业务键一致性
|
||||
FOR rec IN SELECT role_id, role_key FROM public.sys_role WHERE role_id IN (3, 4, 5)
|
||||
LOOP
|
||||
IF rec.role_id = 3 AND rec.role_key <> 'train_admin' THEN
|
||||
RAISE EXCEPTION 'Collision detected: sys_role.role_id=3 is already occupied by role_key=%', rec.role_key;
|
||||
ELSIF rec.role_id = 4 AND rec.role_key <> 'train_teacher' THEN
|
||||
RAISE EXCEPTION 'Collision detected: sys_role.role_id=4 is already occupied by role_key=%', rec.role_key;
|
||||
ELSIF rec.role_id = 5 AND rec.role_key <> 'train_student' THEN
|
||||
RAISE EXCEPTION 'Collision detected: sys_role.role_id=5 is already occupied by role_key=%', rec.role_key;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- 0.2 检查 role_key 存在时是否与预期 role_id 发生错位
|
||||
FOR rec IN SELECT role_id, role_key FROM public.sys_role WHERE role_key IN ('train_admin', 'train_teacher', 'train_student')
|
||||
LOOP
|
||||
IF rec.role_key = 'train_admin' AND rec.role_id <> 3 THEN
|
||||
RAISE EXCEPTION 'Collision detected: role_key=train_admin exists with mismatched role_id=%', rec.role_id;
|
||||
ELSIF rec.role_key = 'train_teacher' AND rec.role_id <> 4 THEN
|
||||
RAISE EXCEPTION 'Collision detected: role_key=train_teacher exists with mismatched role_id=%', rec.role_id;
|
||||
ELSIF rec.role_key = 'train_student' AND rec.role_id <> 5 THEN
|
||||
RAISE EXCEPTION 'Collision detected: role_key=train_student exists with mismatched role_id=%', rec.role_id;
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
-- 0.3 检查 menu_id 2201 ~ 2240 的碰撞:若ID已存在,必须属于预期本系统权限,严禁覆盖外来资源
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM public.sys_menu
|
||||
WHERE menu_id BETWEEN 2201 AND 2240
|
||||
AND (perms IS NULL OR perms NOT LIKE 'train:%')
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Collision detected: sys_menu.menu_id in 2201..2240 is occupied by non-train permissions';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- 步骤 1: 预置 3 个核心业务角色 (train_admin, train_teacher, train_student)
|
||||
-- =============================================================================
|
||||
INSERT INTO public.sys_role
|
||||
(role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly,
|
||||
status, del_flag, create_by, create_time, update_by, update_time, remark)
|
||||
SELECT 3, '培训管理员', 'train_admin', 3, '1', 1, 1,
|
||||
'0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '一期业务全局管理角色'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 3 OR role_key = 'train_admin');
|
||||
|
||||
INSERT INTO public.sys_role
|
||||
(role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly,
|
||||
status, del_flag, create_by, create_time, update_by, update_time, remark)
|
||||
SELECT 4, '培训教师', 'train_teacher', 4, '2', 1, 1,
|
||||
'0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '教师专属教学工作台'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 4 OR role_key = 'train_teacher');
|
||||
|
||||
INSERT INTO public.sys_role
|
||||
(role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly,
|
||||
status, del_flag, create_by, create_time, update_by, update_time, remark)
|
||||
SELECT 5, '培训学员', 'train_student', 5, '2', 1, 1,
|
||||
'0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '学员专属个人培训中心'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_role WHERE role_id = 5 OR role_key = 'train_student');
|
||||
|
||||
-- =============================================================================
|
||||
-- 步骤 2: 规范统一电子班牌权限命名空间 (system:board:* -> train:board:*)
|
||||
-- =============================================================================
|
||||
UPDATE public.sys_menu
|
||||
SET perms = REPLACE(perms, 'system:board:', 'train:board:'),
|
||||
update_by = 'admin',
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE perms LIKE 'system:board:%';
|
||||
|
||||
-- =============================================================================
|
||||
-- 步骤 3: 补齐基础培训管理模块按钮级权限 (sys_menu, menu_type='F')
|
||||
-- =============================================================================
|
||||
|
||||
-- 3.1 教师管理 (parent: 2155)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2201, '教师查询', 2155, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:list', '#', 'admin', CURRENT_TIMESTAMP, '教师列表查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2201 OR perms = 'train:teacher:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2202, '教师详情', 2155, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:query', '#', 'admin', CURRENT_TIMESTAMP, '教师详情查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2202 OR perms = 'train:teacher:query');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2203, '教师新增', 2155, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:add', '#', 'admin', CURRENT_TIMESTAMP, '教师新增'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2203 OR perms = 'train:teacher:add');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2204, '教师修改', 2155, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:teacher:edit', '#', 'admin', CURRENT_TIMESTAMP, '教师修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2204 OR perms = 'train:teacher:edit');
|
||||
|
||||
-- 3.2 学员管理 (parent: 2156)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2205, '学员查询', 2156, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:list', '#', 'admin', CURRENT_TIMESTAMP, '学员列表查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2205 OR perms = 'train:student:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2206, '学员详情', 2156, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:query', '#', 'admin', CURRENT_TIMESTAMP, '学员详情查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2206 OR perms = 'train:student:query');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2207, '学员新增', 2156, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:student:add', '#', 'admin', CURRENT_TIMESTAMP, '学员新增'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2207 OR perms = 'train:student:add');
|
||||
|
||||
-- 3.3 教室管理 (parent: 2157)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2208, '教室查询', 2157, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:classroom:list', '#', 'admin', CURRENT_TIMESTAMP, '教室列表查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2208 OR perms = 'train:classroom:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2209, '教室修改', 2157, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:classroom:edit', '#', 'admin', CURRENT_TIMESTAMP, '教室修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2209 OR perms = 'train:classroom:edit');
|
||||
|
||||
-- 3.4 实训场地 (parent: 2158)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2210, '场地查询', 2158, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:site:list', '#', 'admin', CURRENT_TIMESTAMP, '实训场地列表查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2210 OR perms = 'train:site:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2211, '场地修改', 2158, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:site:edit', '#', 'admin', CURRENT_TIMESTAMP, '实训场地修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2211 OR perms = 'train:site:edit');
|
||||
|
||||
-- 3.5 宿舍管理 (parent: 2161)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2212, '宿舍查询', 2161, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:dormitory:list', '#', 'admin', CURRENT_TIMESTAMP, '宿舍列表查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2212 OR perms = 'train:dormitory:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2213, '宿舍修改', 2161, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:dormitory:edit', '#', 'admin', CURRENT_TIMESTAMP, '宿舍修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2213 OR perms = 'train:dormitory:edit');
|
||||
|
||||
-- 3.6 课程管理 (parent: 2163)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2214, '课程查询', 2163, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:list', '#', 'admin', CURRENT_TIMESTAMP, '课程列表查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2214 OR perms = 'train:course:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2215, '课程详情', 2163, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:query', '#', 'admin', CURRENT_TIMESTAMP, '课程详情查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2215 OR perms = 'train:course:query');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2216, '课程新增', 2163, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:add', '#', 'admin', CURRENT_TIMESTAMP, '课程新增'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2216 OR perms = 'train:course:add');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2217, '课程修改', 2163, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:course:edit', '#', 'admin', CURRENT_TIMESTAMP, '课程修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2217 OR perms = 'train:course:edit');
|
||||
|
||||
-- 3.7 培训计划 (parent: 2164)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2218, '计划查询', 2164, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:list', '#', 'admin', CURRENT_TIMESTAMP, '培训计划列表'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2218 OR perms = 'train:plan:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2219, '计划详情', 2164, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:query', '#', 'admin', CURRENT_TIMESTAMP, '培训计划详情'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2219 OR perms = 'train:plan:query');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2220, '计划新增', 2164, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:add', '#', 'admin', CURRENT_TIMESTAMP, '培训计划新增'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2220 OR perms = 'train:plan:add');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2221, '计划修改', 2164, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:edit', '#', 'admin', CURRENT_TIMESTAMP, '培训计划修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2221 OR perms = 'train:plan:edit');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2222, '计划结训', 2164, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:plan:complete', '#', 'admin', CURRENT_TIMESTAMP, '培训计划结束归档'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2222 OR perms = 'train:plan:complete');
|
||||
|
||||
-- 3.8 课次与排课 (parent: 2164)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2223, '排课查询', 2164, 6, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:query', '#', 'admin', CURRENT_TIMESTAMP, '排课课表查询'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2223 OR perms = 'train:lesson:query');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2224, '排课新增', 2164, 7, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:add', '#', 'admin', CURRENT_TIMESTAMP, '排课课次新增'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2224 OR perms = 'train:lesson:add');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2225, '排课修改', 2164, 8, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:edit', '#', 'admin', CURRENT_TIMESTAMP, '排课课次修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2225 OR perms = 'train:lesson:edit');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2226, '排课删除', 2164, 9, '#', '', '', 1, 0, 'F', '0', '0', 'train:lesson:remove', '#', 'admin', CURRENT_TIMESTAMP, '排课课次删除'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2226 OR perms = 'train:lesson:remove');
|
||||
|
||||
-- 3.9 参训学员分班 (parent: 2164)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2227, '参训名单查询', 2164, 10, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:list', '#', 'admin', CURRENT_TIMESTAMP, '参训花名册列表'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2227 OR perms = 'train:enrollment:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2228, '参训记录详情', 2164, 11, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:query', '#', 'admin', CURRENT_TIMESTAMP, '参训记录详情'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2228 OR perms = 'train:enrollment:query');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2229, '参训人员添加', 2164, 12, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:add', '#', 'admin', CURRENT_TIMESTAMP, '参训学员录入'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2229 OR perms = 'train:enrollment:add');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2230, '参训人员修改', 2164, 13, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:edit', '#', 'admin', CURRENT_TIMESTAMP, '参训学员状态修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2230 OR perms = 'train:enrollment:edit');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2231, '参训人员移除', 2164, 14, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:remove', '#', 'admin', CURRENT_TIMESTAMP, '参训学员移除'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2231 OR perms = 'train:enrollment:remove');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2232, '参训人员导入', 2164, 15, '#', '', '', 1, 0, 'F', '0', '0', 'train:enrollment:import', '#', 'admin', CURRENT_TIMESTAMP, '参训学员批量导入'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2232 OR perms = 'train:enrollment:import');
|
||||
|
||||
-- 3.10 电子班牌标准权限 (parent: 2174)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2233, '班牌列表', 2174, 1, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:list', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌列表'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2233 OR perms = 'train:board:list');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2234, '班牌详情', 2174, 2, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:query', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌详情'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2234 OR perms = 'train:board:query');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2235, '班牌新增', 2174, 3, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:add', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌新增'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2235 OR perms = 'train:board:add');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2236, '班牌修改', 2174, 4, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:edit', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌修改'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2236 OR perms = 'train:board:edit');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2237, '班牌删除', 2174, 5, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:remove', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌删除'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2237 OR perms = 'train:board:remove');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2238, '班牌导出', 2174, 6, '#', '', '', 1, 0, 'F', '0', '0', 'train:board:export', '#', 'admin', CURRENT_TIMESTAMP, '电子班牌导出'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2238 OR perms = 'train:board:export');
|
||||
|
||||
-- 3.11 自服务专属接口权限 (虚拟节点挂载于 0)
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2239, '教师自服务', 0, 99, '#', '', '', 1, 0, 'F', '0', '0', 'train:me:teacher', '#', 'admin', CURRENT_TIMESTAMP, '教师专属自服务接口权限'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2239 OR perms = 'train:me:teacher');
|
||||
|
||||
INSERT INTO public.sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
|
||||
SELECT 2240, '学员自服务', 0, 100, '#', '', '', 1, 0, 'F', '0', '0', 'train:me:student', '#', 'admin', CURRENT_TIMESTAMP, '学员专属自服务接口权限'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE menu_id = 2240 OR perms = 'train:me:student');
|
||||
|
||||
-- =============================================================================
|
||||
-- 步骤 4: 为业务角色分配菜单权限映射 (sys_role_menu)
|
||||
-- =============================================================================
|
||||
|
||||
-- 4.1 train_admin (role_id = 3): 授予全部培训模块菜单与按钮
|
||||
INSERT INTO public.sys_role_menu (role_id, menu_id)
|
||||
SELECT 3, m.menu_id
|
||||
FROM public.sys_menu m
|
||||
WHERE (m.menu_id BETWEEN 2154 AND 2183 OR m.menu_id BETWEEN 2201 AND 2238)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 3 AND rm.menu_id = m.menu_id
|
||||
);
|
||||
|
||||
-- 4.2 train_teacher (role_id = 4): 授予教师专属自服务权限
|
||||
INSERT INTO public.sys_role_menu (role_id, menu_id)
|
||||
SELECT 4, 2239
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 4 AND rm.menu_id = 2239
|
||||
);
|
||||
|
||||
-- 4.3 train_student (role_id = 5): 授予学员专属自服务权限
|
||||
INSERT INTO public.sys_role_menu (role_id, menu_id)
|
||||
SELECT 5, 2240
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM public.sys_role_menu rm WHERE rm.role_id = 5 AND rm.menu_id = 2240
|
||||
);
|
||||
|
||||
-- =============================================================================
|
||||
-- 步骤 5: 迁移验证自检
|
||||
-- =============================================================================
|
||||
DO $$
|
||||
BEGIN
|
||||
IF (SELECT count(*) FROM public.sys_role WHERE role_key IN ('train_admin', 'train_teacher', 'train_student')) <> 3 THEN
|
||||
RAISE EXCEPTION 'Training roles count verification failed';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:me:teacher') THEN
|
||||
RAISE EXCEPTION 'Teacher self-service permission missing';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM public.sys_menu WHERE perms = 'train:me:student') THEN
|
||||
RAISE EXCEPTION 'Student self-service permission missing';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM public.sys_menu WHERE perms LIKE 'system:board:%') THEN
|
||||
RAISE EXCEPTION 'Legacy system:board:* permission namespace still exists';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -50,14 +50,15 @@
|
||||
|
||||
## 2. 多身份策略与唯一性约束 (Multi-Identity Policy)
|
||||
|
||||
### 2.1 一期多身份决策:严格单身份互斥
|
||||
- 在铁路职工技能培训体系中,角色的职责边界极为森严:
|
||||
- **教务管理员 (Admin)**: 负责全局资源调配、排课、分班与监控。
|
||||
- **任课教师 (Teacher)**: 专职负责授课、课次考勤与教学答疑。
|
||||
- **参训职工 (Student)**: 专职参与培训、打卡签到与考核画像。
|
||||
- **冻结决策**: **一期系统严格不支持一个账号具备多重业务身份**。
|
||||
- 严禁同一个 `sys_user` 既绑定教师又绑定学员。
|
||||
- 严禁超级管理员账号 `admin (userId=1)` 绑定为普通业务教师或学员。
|
||||
### 2.1 一期多身份决策:业务主体身份互斥 (Teacher XOR Student)
|
||||
- 在铁路职工技能培训体系中,明确区分角色权限(Role)与业务档案身份(Identity):
|
||||
- **教务管理员 (Admin)**: 是系统权限角色(Role),负责全局资源调配、排课、分班与监控。
|
||||
- **任课教师 (Teacher)**: 是教学业务身份(Identity),负责授课、课次考勤与教学答疑。
|
||||
- **参训职工 (Student)**: 是参训业务身份(Identity),专职参与培训、打卡签到与考核画像。
|
||||
- **冻结决策**:
|
||||
- **Teacher 与 Student 业务身份严格互斥**:严禁同一个 `sys_user` 既绑定教师又绑定学员。
|
||||
- **Admin 是角色而非业务身份**:拥有 Admin 角色或管理权限的账号,若同时承担教学任务,允许绑定 Teacher 业务档案(Admin Role + Teacher Identity),并在自服务工作台中以当前教师身份推导数据范围。
|
||||
- **超级管理员账号环境纪律**:对默认超级管理员测试账号(如 `admin / userId=1`)的保护应通过运维环境数据纪律落实,业务服务层严禁硬编码 `userId=1` 永久禁止绑定。
|
||||
|
||||
### 2.2 数据库物理约束与保障机制
|
||||
为了在数据库持久层杜绝脏数据与多重绑定,在数据库 Migration 脚本中为两张表增加唯一索引:
|
||||
@@ -73,19 +74,19 @@ ON train_student(user_id)
|
||||
WHERE user_id IS NOT NULL AND del_flag = '0';
|
||||
```
|
||||
|
||||
### 2.3 业务服务层互斥前置校验防线
|
||||
在新建或绑定账号时,在 Java Service 事务中实施互斥断言:
|
||||
### 2.3 业务服务层互斥前置校验与跨表并发锁
|
||||
在新建或绑定账号时,在 Java Service 事务中实施悲观锁与互斥断言:
|
||||
```java
|
||||
public void validateUserIdentityExclusive(Long userId) {
|
||||
public void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId) {
|
||||
if (userId == null) return;
|
||||
if (SecurityUtils.isAdmin(userId)) {
|
||||
throw new ServiceException("系统超级管理员不可绑定为业务教师或学员");
|
||||
}
|
||||
if (teacherMapper.countByUserId(userId) > 0) {
|
||||
throw new ServiceException("该登录账号已绑定为教师身份,不可重复绑定");
|
||||
}
|
||||
if (studentMapper.countByUserId(userId) > 0) {
|
||||
throw new ServiceException("该登录账号已绑定为学员身份,不可重复绑定");
|
||||
if ("TEACHER".equalsIgnoreCase(targetType)) {
|
||||
if (studentMapper.selectTrainStudentByUserId(userId) != null) {
|
||||
throw new ServiceException("该系统登录账号已绑定为学员身份,不可重复绑定为教师");
|
||||
}
|
||||
} else if ("STUDENT".equalsIgnoreCase(targetType)) {
|
||||
if (teacherMapper.selectTrainTeacherByUserId(userId) != null) {
|
||||
throw new ServiceException("该系统登录账号已绑定为教师身份,不可重复绑定为学员");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -46,16 +46,14 @@ RuoYi-Vue/sql/system/kingbase/
|
||||
ALTER TABLE train_teacher ADD COLUMN IF NOT EXISTS user_id BIGINT;
|
||||
ALTER TABLE train_student ADD COLUMN IF NOT EXISTS user_id BIGINT;
|
||||
|
||||
-- 2. 建立教师账号唯一索引 (忽略已删除 del_flag='2' 与空值)
|
||||
DROP INDEX IF EXISTS uq_train_teacher_user_id;
|
||||
CREATE UNIQUE INDEX uq_train_teacher_user_id
|
||||
ON train_teacher(user_id)
|
||||
-- 2. 建立教师账号唯一索引 (无 DROP,安全幂等,忽略已删除 del_flag='2' 与空值)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_teacher_user_id
|
||||
ON train_teacher(user_id)
|
||||
WHERE user_id IS NOT NULL AND del_flag = '0';
|
||||
|
||||
-- 3. 建立学员账号唯一索引
|
||||
DROP INDEX IF EXISTS uq_train_student_user_id;
|
||||
CREATE UNIQUE INDEX uq_train_student_user_id
|
||||
ON train_student(user_id)
|
||||
-- 3. 建立学员账号唯一索引 (无 DROP,安全幂等)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_student_user_id
|
||||
ON train_student(user_id)
|
||||
WHERE user_id IS NOT NULL AND del_flag = '0';
|
||||
|
||||
COMMENT ON COLUMN train_teacher.user_id IS '关联sys_user.user_id (唯一绑定教师登录账号)';
|
||||
@@ -66,26 +64,27 @@ RuoYi-Vue/sql/system/kingbase/
|
||||
|
||||
### 2.2 脚本 2: `005_training_roles_permissions.sql`
|
||||
- **执行目标**:
|
||||
1. 预置 3 个业务角色 (`train_admin`, `train_teacher`, `train_student`) 写入 `sys_role`。
|
||||
2. 补齐 `sys_menu` 中历史遗漏的基础模块按钮级权限(`train:teacher:*`, `train:student:*`, `train:course:*`, `train:plan:*`, `train:lesson:*` 等)。
|
||||
3. 将 `system:board:*` 权限标识收敛对齐为 `train:board:*`。
|
||||
4. 插入 Teacher 工作台菜单与 Student 个人中心菜单节点。
|
||||
5. 幂等配置 `sys_role_menu` 角色菜单权限映射。
|
||||
1. 内置 ID Collision Guard:预检 `role_id (3,4,5)` 与 `menu_id (2201..2240)`,若已被外部其他业务资源占用则显式 ABORT,严禁覆盖外来记录。
|
||||
2. 预置 3 个业务角色 (`train_admin`, `train_teacher`, `train_student`) 写入 `sys_role`。
|
||||
3. 补齐 `sys_menu` 中历史遗漏的基础模块按钮级权限(`train:teacher:*`, `train:student:*`, `train:course:*`, `train:plan:*`, `train:lesson:*` 等)。
|
||||
4. 将 `system:board:*` 权限标识收敛对齐为 `train:board:*`。
|
||||
5. 插入 Teacher 工作台菜单与 Student 个人中心菜单节点。
|
||||
6. 幂等配置 `sys_role_menu` 角色菜单权限映射。
|
||||
- **DML 设计核心内容**:
|
||||
```sql
|
||||
BEGIN;
|
||||
|
||||
-- 1. 预置角色 (使用特定固定 role_id 3, 4, 5,采用 ON CONFLICT DO NOTHING 保证幂等)
|
||||
-- 0. ID 碰撞守卫 (Collision Guard):检测 role_id 与 menu_id 是否有非预期占用,有则 RAISE EXCEPTION
|
||||
DO $$ ... $$;
|
||||
|
||||
-- 1. 预置角色 (使用 WHERE NOT EXISTS 确保无重复)
|
||||
INSERT INTO sys_role (role_id, role_name, role_key, role_sort, data_scope, menu_check_strictly, dept_check_strictly, status, del_flag, create_by, create_time, remark)
|
||||
VALUES
|
||||
(3, '培训管理员', 'train_admin', 3, '1', 1, 1, '0', '0', 'admin', CURRENT_TIMESTAMP, '一期业务全局管理角色'),
|
||||
(4, '培训教师', 'train_teacher', 4, '2', 1, 1, '0', '0', 'admin', CURRENT_TIMESTAMP, '教师专属教学工作台'),
|
||||
(5, '培训学员', 'train_student', 5, '2', 1, 1, '0', '0', 'admin', CURRENT_TIMESTAMP, '学员专属个人培训中心')
|
||||
ON CONFLICT (role_id) DO NOTHING;
|
||||
SELECT 3, '培训管理员', 'train_admin', 3, '1', 1, 1, '0', '0', 'admin', CURRENT_TIMESTAMP, '', NULL, '一期业务全局管理角色'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_role WHERE role_id = 3 OR role_key = 'train_admin');
|
||||
|
||||
-- 2. 补齐基础管理模块按钮权限 (示例:教师管理按钮)
|
||||
-- 采用 WHERE NOT EXISTS 确保多次执行安全幂等
|
||||
-- ... (录入 train:teacher:list/add/edit/query/export 等节点)
|
||||
-- ... (录入 train:teacher:list/add/edit/query 等节点)
|
||||
|
||||
-- 3. 统一规范电子班牌命名空间
|
||||
UPDATE sys_menu SET perms = REPLACE(perms, 'system:board:', 'train:board:')
|
||||
@@ -93,8 +92,8 @@ RuoYi-Vue/sql/system/kingbase/
|
||||
|
||||
-- 4. 建立角色与菜单权限关联 (sys_role_menu)
|
||||
-- train_admin: 分配全部培训业务管理菜单及按钮
|
||||
-- train_teacher: 分配教师工作台、我的课表、我的班级学员菜单
|
||||
-- train_student: 分配学员中心、我的课表、我的报到、我的考勤菜单
|
||||
-- train_teacher: 分配教师工作台与专属自服务权限
|
||||
-- train_student: 分配学员培训中心与专属自服务权限
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
@@ -50,74 +50,83 @@ flowchart TD
|
||||
|
||||
## 2. 详细任务卡片清单 (RBAC-B1 ~ RBAC-B10)
|
||||
|
||||
### 任务 1: RBAC-B1 - 身份绑定与物理约束脚本 (Identity Binding & DDL)
|
||||
### 任务 1: RBAC-B1 - 身份绑定与物理约束脚本 (Identity Binding & DDL) [COMPLETED (Wave 1)]
|
||||
- **依赖关系**: 无前置依赖,首发任务。
|
||||
- **任务目标**:
|
||||
1. 编写 `RuoYi-Vue/sql/system/kingbase/004_rbac_identity_binding.sql`。
|
||||
2. 针对 `train_teacher(user_id)` 与 `train_student(user_id)` 建立非空部分唯一索引。
|
||||
- **完成标志**: 脚本在 Kingbase 生产库显式事务执行通过,唯一性拦截生效。
|
||||
- **当前状态**: 已完成 (脚本编写完成并通过静态契约测试验证,已就绪待运维发布)
|
||||
- **交付内容**:
|
||||
1. `RuoYi-Vue/sql/system/kingbase/004_rbac_identity_binding.sql`。
|
||||
2. 针对 `train_teacher(user_id)` 与 `train_student(user_id)` 建立非空部分唯一索引与约束验证块。
|
||||
3. `RbacPermissionMigrationContractTest` 验证通过。
|
||||
- **完成标志**: 脚本编写符合 Kingbase 语法,事务与约束验证完备。
|
||||
|
||||
### 任务 2: RBAC-B2 - 角色预置与权限补齐脚本 (Role & Permission Migration)
|
||||
### 任务 2: RBAC-B2 - 角色预置与权限补齐脚本 (Role & Permission Migration) [COMPLETED (Wave 1)]
|
||||
- **依赖关系**: 依赖 B1 完成。
|
||||
- **任务目标**:
|
||||
1. 编写 `RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql`。
|
||||
- **当前状态**: 已完成 (脚本编写完成并通过静态契约测试验证,已就绪待运维发布)
|
||||
- **交付内容**:
|
||||
1. `RuoYi-Vue/sql/system/kingbase/005_training_roles_permissions.sql`。
|
||||
2. 预置 `train_admin` (3), `train_teacher` (4), `train_student` (5) 角色。
|
||||
3. 补齐 `sys_menu` 中基础管理模块缺失的全部按钮级 `perms` 行。
|
||||
4. 建立角色的初始菜单与功能权限绑定映射。
|
||||
- **完成标志**: 数据库执行无报错,管理员界面角色管理中可见 3 个新角色。
|
||||
3. 补齐 `sys_menu` 中基础管理模块缺失的全部按钮级 `perms` 行 (2201~2240)。
|
||||
4. 建立角色的初始菜单与功能权限绑定映射及自检验证块。
|
||||
- **完成标志**: 脚本契约测试 `testMigration005_RoleAndPermissionScriptContract` 验证通过。
|
||||
|
||||
### 任务 3: RBAC-B3 - 教师后端自服务体系实现 (Teacher Backend)
|
||||
### 任务 3: RBAC-B3 - 教师后端自服务体系实现 (Teacher Backend) [COMPLETED (Wave 1)]
|
||||
- **依赖关系**: 依赖 B1, B2。与 B4、B7、B8 可完全并行。
|
||||
- **任务目标**:
|
||||
1. 新增控制器 `TrainTeacherMeController.java` (`/train/me/...`)。
|
||||
2. 实现获取教师本人画像、任教班级、授课课表、班级学员名册(脱敏)与出勤统计。
|
||||
3. 强制从 `SecurityUtils.getUserId()` 提取当前教师 ID。
|
||||
- **完成标志**: Swagger 联调成功,篡改身份直接抛出 403。
|
||||
- **当前状态**: 已完成
|
||||
- **交付内容**:
|
||||
1. 控制器 `TrainTeacherMeController.java` (`/train/me/teacher/...`)。
|
||||
2. 实现获取教师本人画像、任教班级、授课课表、班级学员名册(脱敏)与课次出勤流水。
|
||||
3. 强制校验教师身份与教学班级/课次归属,杜绝跨教师越权 (IDOR)。
|
||||
4. `TeacherSelfServiceApiTest` 7 个单测用例全部通过。
|
||||
- **完成标志**: 接口单元测试通过率 100%,越权拦截测试生效。
|
||||
|
||||
### 任务 4: RBAC-B4 - 学员后端自服务体系实现 (Student Backend)
|
||||
### 任务 4: RBAC-B4 - 学员后端自服务体系实现 (Student Backend) [COMPLETED (Wave 1)]
|
||||
- **依赖关系**: 依赖 B1, B2。与 B3、B7、B8 可完全并行。
|
||||
- **任务目标**:
|
||||
1. 新增控制器 `TrainStudentMeController.java` (`/train/me/...`)。
|
||||
- **当前状态**: 已完成
|
||||
- **交付内容**:
|
||||
1. 控制器 `TrainStudentMeController.java` (`/train/me/student/...`)。
|
||||
2. 实现获取学员本人档案、参训班级状态、个人课表、报到凭据、个人考勤、累计学时与多维画像。
|
||||
3. 强制校验本人归属,杜绝任何外部主键传入。
|
||||
- **完成标志**: 接口单元测试通过率 100%。
|
||||
4. `StudentSelfServiceApiTest` 6 个单测用例全部通过。
|
||||
- **完成标志**: 接口单元测试通过率 100%,敏感信息脱敏合格。
|
||||
|
||||
### 任务 5: RBAC-B5 - 教师工作台前端视图开发 (Teacher Frontend)
|
||||
### 任务 5: RBAC-B5 - 教师工作台前端视图开发 (Teacher Frontend) [PENDING (Wave 2)]
|
||||
- **依赖关系**: 依赖 B2 菜单路由,可与 B3 并行(采用 Mock 或接口契约联调)。
|
||||
- **任务目标**:
|
||||
1. 在 `build_ui/src/views/train/teacher/` 下开发工作台看板与我的日程日历组件。
|
||||
2. 开发任教班级花名册抽屉视图。
|
||||
- **完成标志**: 教师登录后正常展现日历课表与花名册。
|
||||
|
||||
### 任务 6: RBAC-B6 - 学员个人培训中心前端开发 (Student Frontend)
|
||||
### 任务 6: RBAC-B6 - 学员个人培训中心前端开发 (Student Frontend) [PENDING (Wave 2)]
|
||||
- **依赖关系**: 依赖 B2 菜单路由,可与 B4 并行开发。
|
||||
- **任务目标**:
|
||||
1. 在 `build_ui/src/views/train/student/` 下开发学员培训个人中心。
|
||||
2. 整合培训流程步骤条、今日课表卡片、个人考勤打卡流水与雷达画像组件。
|
||||
- **完成标志**: 学员登录后清晰直观呈现个人培训全流程状态。
|
||||
|
||||
### 任务 7: RBAC-B7 - 登录界面注册入口安全改造 (Login UI Policy)
|
||||
### 任务 7: RBAC-B7 - 登录界面注册入口安全改造 (Login UI Policy) [PENDING (Wave 2)]
|
||||
- **依赖关系**: 独立任务,可立即开工。
|
||||
- **任务目标**:
|
||||
1. 修改 `build_ui/src/views/login.vue`,彻底关闭 `register` 开关,移除“立即注册”链接。
|
||||
2. 增加居中提示:“*账号由培训管理部门统一创建与授权维护*”。
|
||||
- **完成标志**: 登录页面无注册链接,提示文案清晰优雅。
|
||||
|
||||
### 任务 8: RBAC-B8 - 班牌权限命名空间统一对齐 (Namespace Alignment)
|
||||
### 任务 8: RBAC-B8 - 班牌权限命名空间统一对齐 (Namespace Alignment) [PARTIALLY COMPLETED (Wave 1 后端已完成)]
|
||||
- **依赖关系**: 独立任务,可立即开工。
|
||||
- **任务目标**:
|
||||
1. 将后端 `TrainElectronicBoardController.java` 中的 `@PreAuthorize("@ss.hasPermi('system:board:...')")` 统一对齐为 `train:board:...`。
|
||||
2. 将前端 `views/train/board/index.vue` 中的 `v-hasPermi="['system:board:...']"` 统一对齐为 `train:board:...`。
|
||||
- **完成标志**: 班牌功能按钮与后端权限完全基于 `train:board:*` 正常鉴权。
|
||||
- **当前状态**:
|
||||
- 后端:`TrainElectronicBoardController.java` 中的权限注解已全部从 `system:board:*` 收敛统一为 `train:board:*` [COMPLETED]
|
||||
- 前端:`build_ui/src/views/train/board/index.vue` 中的 `v-hasPermi` 对齐计划在 Wave 2 前端阶段完成 [PENDING]
|
||||
- **完成标志**: 静态契约测试 `testElectronicBoardControllerPermissionsAligned` 验证通过。
|
||||
|
||||
### 任务 9: RBAC-B9 - 数据范围与 IDOR 安全渗透自动化测试 (IDOR Gate)
|
||||
### 任务 9: RBAC-B9 - 数据范围与 IDOR 安全渗透自动化测试 (IDOR Gate) [PARTIALLY COMPLETED (Wave 1 后端 IDOR 已通过)]
|
||||
- **依赖关系**: 依赖 B3、B4、B5、B6 联调完成。
|
||||
- **任务目标**:
|
||||
1. 编写跨用户越权自动化探测脚本,模拟 Teacher A 访问 Teacher B 课表,模拟 Student A 访问 Student B 档案与考勤。
|
||||
2. 验证所有直接对象引用均被有效拒止。
|
||||
- **完成标志**: `IDOR_LEAK_COUNT = 0`。
|
||||
- **当前状态**:
|
||||
- 后端 IDOR 单测套件已完成并验证:
|
||||
- `RbacIdorSecurityTest`: 模拟跨教师越权访问他人学员名册与考勤,跨学员越权探测等,全部被严密拒止。
|
||||
- `TrainingIdentityServiceTest`: 验证身份唯一性与互斥性。
|
||||
- 端到端渗透验收:保留至三角色前端集成后进行。
|
||||
- **完成标志**: 后端自动化测试中 `IDOR_LEAK_COUNT = 0`。
|
||||
|
||||
### 任务 10: RBAC-B10 - 管理端业务全流程无损回归测试 (Admin Regression)
|
||||
### 任务 10: RBAC-B10 - 管理端业务全流程无损回归测试 (Admin Regression) [PENDING (Wave 3)]
|
||||
- **依赖关系**: 汇总任务,依赖全部前置任务。
|
||||
- **任务目标**:
|
||||
1. 执行 E2E-R2 验收方案全流程。
|
||||
@@ -126,11 +135,17 @@ flowchart TD
|
||||
|
||||
---
|
||||
|
||||
## 3. 并行施工调度建议
|
||||
## 3. 并行施工调度建议与 Wave 进展
|
||||
|
||||
- **第 1 梯队 (基础组)**: 执行 `RBAC-B1` 与 `RBAC-B2` (预计 0.5 天),完成数据库准备;同时执行 `RBAC-B7` 与 `RBAC-B8` (预计 0.5 天)。
|
||||
- **第 2 梯队 (业务组)**:
|
||||
- 后端工程师 A 攻坚 `RBAC-B3` (教师后端),前端工程师 A 攻坚 `RBAC-B5` (教师前端);
|
||||
- 后端工程师 B 攻坚 `RBAC-B4` (学员后端),前端工程师 B 攻坚 `RBAC-B6` (学员前端);
|
||||
- 两组完全解耦并行。
|
||||
- **第 3 梯队 (质量保障组)**: 执行 `RBAC-B9` (越权审计) 与 `RBAC-B10` (全量回归与演示交付)。
|
||||
- **Wave 1 (已完成)**:
|
||||
- 后端基础设施与迁移脚本准备: `RBAC-B1`, `RBAC-B2` (完成)
|
||||
- 核心身份服务与自服务后端: `RBAC-B3`, `RBAC-B4` (完成)
|
||||
- 班牌后端权限收敛: `RBAC-B8 (后端)` (完成)
|
||||
- 安全与契约单元测试: `RBAC-B9 (后端)` (完成,194/194 PASS)
|
||||
- **Wave 2 (待推进)**:
|
||||
- 教师工作台前端视图开发: `RBAC-B5`
|
||||
- 学员个人培训中心前端开发: `RBAC-B6`
|
||||
- 登录入口安全策略改造: `RBAC-B7`
|
||||
- 班牌前端权限指令对齐: `RBAC-B8 (前端)`
|
||||
- **Wave 3 (最终验收)**:
|
||||
- 三角色端到端联调与 E2E-R2 回归测试: `RBAC-B10`
|
||||
Reference in new issue
Block a user