新增权限:实现教师学员身份绑定与自服务安全控制

This commit is contained in:
wangzhibiao committed 2026-09-15 14:26:40 +08:00
1 parent 35832f2fce
commit 592a9655e8
38 files changed
+3277 -89

No files matched your search

@@ -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)
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,324 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.access.AccessDeniedException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.framework.web.exception.GlobalExceptionHandler;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.domain.vo.EnrollmentItemVO;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* 真实 HTTP / AjaxResult 响应体语义、IDOR 越权与敏感数据脱敏集成测试。
*
* @author ruoyi
*/
public class RbacHttpAndAuthorizationSemanticsTest
{
private TrainTeacherMeController teacherController;
private TrainStudentMeController studentController;
private GlobalExceptionHandler exceptionHandler;
private ObjectMapper objectMapper;
private String currentTeacherId = "T_MOCK_01";
private String currentStudentId = "S_MOCK_01";
@Before
public void setUp()
{
exceptionHandler = new GlobalExceptionHandler();
objectMapper = new ObjectMapper();
ITrainingIdentityService identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("assertTeacherIdentity".equals(name))
{
if (currentTeacherId == null)
{
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
}
return currentTeacherId;
}
if ("assertStudentIdentity".equals(name))
{
if (currentStudentId == null)
{
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
}
return currentStudentId;
}
if ("getCurrentUserId".equals(name))
{
return 1001L;
}
return null;
}
);
TrainTeacherMapper teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainTeacherMapper.class},
(proxy, method, args) -> {
if ("selectTrainTeacherByTId".equals(method.getName()))
{
TrainTeacher teacher = new TrainTeacher();
teacher.setTId((String) args[0]);
teacher.settName("李老师");
return teacher;
}
return null;
}
);
TrainLessonMapper lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainLessonMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("countTeacherInPlan".equals(name))
{
String tId = (String) args[0];
String planId = (String) args[1];
return ("T_MOCK_01".equals(tId) && "PLAN_LEGAL".equals(planId)) ? 1 : 0;
}
if ("selectTrainLessonById".equals(name))
{
Long lessonId = (Long) args[0];
TrainLesson lesson = new TrainLesson();
lesson.setLessonId(lessonId);
if (Long.valueOf(101L).equals(lessonId))
{
lesson.setTId("T_MOCK_01"); // 本人课次
}
else
{
lesson.setTId("T_OTHER"); // 他人课次
}
return lesson;
}
return null;
}
);
TrainEnrollmentMapper enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainEnrollmentMapper.class},
(proxy, method, args) -> {
if ("selectEnrollmentItemList".equals(method.getName()))
{
List<EnrollmentItemVO> list = new ArrayList<>();
EnrollmentItemVO item = new EnrollmentItemVO();
item.setStudentId("S001");
item.setStudentName("张三");
item.setPhone("13812345678");
list.add(item);
return list;
}
return Collections.emptyList();
}
);
TrainStudentCheckinMapper checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentCheckinMapper.class},
(proxy, method, args) -> Collections.emptyList()
);
teacherController = new TrainTeacherMeController(
identityService, teacherMapper, lessonMapper, enrollmentMapper, checkinMapper);
ITrainStudentService studentService = (ITrainStudentService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainStudentService.class},
(proxy, method, args) -> {
if ("selectStudentDetail".equals(method.getName()))
{
StudentDetailVO detail = new StudentDetailVO();
detail.setsId("S_MOCK_01");
detail.setsName("李四");
detail.setIdCardMasked("110101********1234");
detail.setIdCardLast4("1234");
return detail;
}
return null;
}
);
TrainClassReportMapper reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
getClass().getClassLoader(), new Class<?>[]{TrainClassReportMapper.class}, (p, m, a) -> null);
TrainStudyDurationMapper durationMapper = (TrainStudyDurationMapper) Proxy.newProxyInstance(
getClass().getClassLoader(), new Class<?>[]{TrainStudyDurationMapper.class}, (p, m, a) -> Collections.emptyList());
TrainStudentPortraitMapper portraitMapper = (TrainStudentPortraitMapper) Proxy.newProxyInstance(
getClass().getClassLoader(), new Class<?>[]{TrainStudentPortraitMapper.class}, (p, m, a) -> null);
studentController = new TrainStudentMeController(
identityService, studentService, enrollmentMapper, lessonMapper, reportMapper, checkinMapper, durationMapper, portraitMapper);
}
@Test
public void testTeacherMe_NoIdentity_GlobalExceptionHandlingSemantics()
{
currentTeacherId = null; // 模拟无教师身份
try
{
teacherController.getTeacherProfile();
fail("无教师身份必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
// 经过若依全局异常处理器,生成最终前端收到的 AjaxResult
AjaxResult result = exceptionHandler.handleServiceException(e, null);
assertEquals(403, result.get("code"));
assertEquals("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", result.get("msg"));
}
}
@Test
public void testTeacherStudents_CrossPlanIdor_GlobalExceptionHandlingSemantics()
{
currentTeacherId = "T_MOCK_01";
try
{
teacherController.getMyClassStudents("PLAN_ILLEGAL_OTHER"); // 跨班越权
fail("跨班越权必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
AjaxResult result = exceptionHandler.handleServiceException(e, null);
assertEquals(403, result.get("code"));
assertEquals("您并非该培训班的任课教师,无权查看学员名册", result.get("msg"));
}
}
@Test
public void testTeacherAttendance_CrossLessonIdor_GlobalExceptionHandlingSemantics()
{
currentTeacherId = "T_MOCK_01";
try
{
teacherController.getLessonAttendance(888L); // 888属于T_OTHER
fail("跨Lesson越权必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
AjaxResult result = exceptionHandler.handleServiceException(e, null);
assertEquals(403, result.get("code"));
assertEquals("您并非该课次的授课教师,无权查看出勤记录", result.get("msg"));
}
}
@Test
public void testStudentMe_NoIdentity_GlobalExceptionHandlingSemantics()
{
currentStudentId = null; // 模拟无学员身份
try
{
studentController.getStudentProfile();
fail("无学员身份必须抛出 ServiceException");
}
catch (ServiceException e)
{
assertEquals(Integer.valueOf(HttpStatus.FORBIDDEN), Integer.valueOf(e.getCode()));
AjaxResult result = exceptionHandler.handleServiceException(e, null);
assertEquals(403, result.get("code"));
assertEquals("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", result.get("msg"));
}
}
@Test
public void testSpringSecurity_AccessDeniedException_HandlingSemantics()
{
// 模拟 Spring Security 权限不足异常
AccessDeniedException ex = new AccessDeniedException("Access is denied");
javax.servlet.http.HttpServletRequest request = (javax.servlet.http.HttpServletRequest) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{javax.servlet.http.HttpServletRequest.class},
(proxy, method, args) -> {
if ("getRequestURI".equals(method.getName())) return "/train/me/teacher";
return null;
}
);
// 调用全局异常处理
AjaxResult result = exceptionHandler.handleAccessDeniedException(ex, request);
assertEquals(403, result.get("code"));
assertEquals("没有权限,请联系管理员授权", result.get("msg"));
}
@Test
public void testAdminUserWithTeacherIdentity_AccessAllowed()
{
currentTeacherId = "T_MOCK_01";
// Admin角色用户同时拥有Teacher档案时,正常访问
AjaxResult result = teacherController.getTeacherProfile();
assertEquals(200, result.get("code"));
TrainTeacher teacher = (TrainTeacher) result.get("data");
assertEquals("T_MOCK_01", teacher.getTId());
assertEquals("李老师", teacher.gettName());
}
@Test
public void testSensitiveData_StudentProfileJsonNeverLeaksCiphertextOrHash() throws Exception
{
currentStudentId = "S_MOCK_01";
AjaxResult result = studentController.getStudentProfile();
assertEquals(200, result.get("code"));
assertNotNull(result.get("data"));
String json = objectMapper.writeValueAsString(result);
// 严格断言:JSON响应体严禁包含任何密文或查重哈希字段
assertFalse("JSON不得泄露密文字段 idCardCiphertext", json.contains("idCardCiphertext"));
assertFalse("JSON不得泄露哈希字段 idCardHash", json.contains("idCardHash"));
assertFalse("JSON不得泄露数据库内部密钥字段 secretKey", json.contains("secretKey"));
// 必须暴露脱敏展示字段
assertTrue("JSON必须包含脱敏掩码字段 idCardMasked", json.contains("idCardMasked"));
assertTrue("JSON必须包含末4位字段 idCardLast4", json.contains("idCardLast4"));
}
@Test
public void testSensitiveData_TeacherStudentsMasksPhone() throws Exception
{
currentTeacherId = "T_MOCK_01";
TableDataInfo dataInfo = teacherController.getMyClassStudents("PLAN_LEGAL");
assertEquals(200, dataInfo.getCode());
String json = objectMapper.writeValueAsString(dataInfo);
assertFalse("学员列表严禁泄露完整11位手机号", json.contains("13812345678"));
assertTrue("学员列表必须展示掩码脱敏手机号", json.contains("138****5678"));
}
}
@@ -0,0 +1,225 @@
package com.ruoyi.web.controller.train;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Collections;
import org.junit.Before;
import org.junit.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainLesson;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.vo.StudentDetailVO;
import com.ruoyi.train.domain.vo.StudentItemVO;
import com.ruoyi.train.mapper.TrainClassReportMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.mapper.TrainLessonMapper;
import com.ruoyi.train.mapper.TrainStudentCheckinMapper;
import com.ruoyi.train.mapper.TrainStudentPortraitMapper;
import com.ruoyi.train.mapper.TrainStudyDurationMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.service.ITrainStudentService;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* RBAC 与 IDOR (Insecure Direct Object Reference) 安全渗透专项测试。
*
* @author ruoyi
*/
public class RbacIdorSecurityTest
{
private TrainTeacherMeController teacherController;
private TrainStudentMeController studentController;
private ITrainingIdentityService identityService;
private TrainLessonMapper lessonMapper;
private String loggedInTeacherId = "T_AUTHENTICATED";
private String loggedInStudentId = "S_AUTHENTICATED";
@Before
public void setUp()
{
identityService = (ITrainingIdentityService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainingIdentityService.class},
(proxy, method, args) -> {
String name = method.getName();
if ("assertTeacherIdentity".equals(name))
{
return loggedInTeacherId;
}
if ("assertStudentIdentity".equals(name))
{
return loggedInStudentId;
}
return null;
}
);
lessonMapper = (TrainLessonMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainLessonMapper.class},
(proxy, method, args) -> {
String name = method.getName();
if ("countTeacherInPlan".equals(name))
{
String tId = (String) args[0];
String planId = (String) args[1];
// 仅允许当前教师访问班级 "PLAN_AUTHORIZED"
return ("T_AUTHENTICATED".equals(tId) && "PLAN_AUTHORIZED".equals(planId)) ? 1 : 0;
}
if ("selectTrainLessonById".equals(name))
{
Long lessonId = (Long) args[0];
TrainLesson lesson = new TrainLesson();
lesson.setLessonId(lessonId);
if (Long.valueOf(888L).equals(lessonId))
{
lesson.setTId("T_AUTHENTICATED");
}
else
{
lesson.setTId("T_VICTIM");
}
return lesson;
}
return null;
}
);
TrainTeacherMapper teacherMapper = (TrainTeacherMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainTeacherMapper.class},
(proxy, method, args) -> null
);
TrainEnrollmentMapper enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainEnrollmentMapper.class},
(proxy, method, args) -> Collections.emptyList()
);
TrainStudentCheckinMapper checkinMapper = (TrainStudentCheckinMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentCheckinMapper.class},
(proxy, method, args) -> Collections.emptyList()
);
teacherController = new TrainTeacherMeController(identityService, teacherMapper, lessonMapper, enrollmentMapper, checkinMapper);
ITrainStudentService studentService = (ITrainStudentService) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{ITrainStudentService.class},
(proxy, method, args) -> new StudentDetailVO()
);
TrainClassReportMapper reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainClassReportMapper.class},
(proxy, method, args) -> null
);
TrainStudyDurationMapper durationMapper = (TrainStudyDurationMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudyDurationMapper.class},
(proxy, method, args) -> Collections.emptyList()
);
TrainStudentPortraitMapper portraitMapper = (TrainStudentPortraitMapper) Proxy.newProxyInstance(
getClass().getClassLoader(),
new Class<?>[]{TrainStudentPortraitMapper.class},
(proxy, method, args) -> null
);
studentController = new TrainStudentMeController(
identityService, studentService, enrollmentMapper,
lessonMapper, reportMapper, checkinMapper,
durationMapper, portraitMapper
);
}
@Test
public void teacherCannotProbeForeignClassStudents()
{
// 攻击向量:试图遍历不存在任教关系的 planId 获取学员花名册
String[] probePlanIds = {"PLAN_FOREIGN_1", "PLAN_FOREIGN_2", "PLAN_SYS_ADMIN", "../../../etc/passwd"};
for (String foreignPlanId : probePlanIds)
{
try
{
teacherController.getMyClassStudents(foreignPlanId);
fail("IDOR探测必须被拦截: " + foreignPlanId);
}
catch (ServiceException e)
{
assertEquals("您并非该培训班的任课教师,无权查看学员名册", e.getMessage());
}
}
}
@Test
public void teacherCannotProbeForeignLessonAttendance()
{
// 攻击向量:试图直接修改 lessonId 读取受害者课次的考勤流水
Long[] probeLessonIds = {100L, 101L, 9999L};
for (Long lessonId : probeLessonIds)
{
try
{
teacherController.getLessonAttendance(lessonId);
fail("IDOR课次探测必须被拦截: lessonId=" + lessonId);
}
catch (ServiceException e)
{
assertEquals("您并非该课次的授课教师,无权查看出勤记录", e.getMessage());
}
}
}
@Test
public void teacherCanOnlyAccessAuthorizedResources()
{
// 访问授权班级与课次
assertNotNull(teacherController.getMyClassStudents("PLAN_AUTHORIZED"));
assertNotNull(teacherController.getLessonAttendance(888L));
}
@Test
public void studentViewsMustNeverExposeSensitiveCiphertextOrHash()
{
// 验证学员向外部返回的视图类结构,严禁含有密文字段或查重哈希
for (Field field : StudentItemVO.class.getDeclaredFields())
{
String name = field.getName().toLowerCase();
assertFalse("StudentItemVO 严禁包含明文或密文身份证字段: " + name, name.contains("ciphertext") || name.contains("hash"));
}
for (Field field : StudentDetailVO.class.getDeclaredFields())
{
String name = field.getName().toLowerCase();
assertFalse("StudentDetailVO 严禁包含明文或密文身份证字段: " + name, name.contains("ciphertext") || name.contains("hash"));
}
}
@Test
public void electronicBoardControllerHasZeroLegacyPermissions()
{
// 验证系统内班牌控制器完全收敛为 train:board:*
for (Method method : TrainElectronicBoardController.class.getDeclaredMethods())
{
if (method.isAnnotationPresent(PreAuthorize.class))
{
String perm = method.getAnnotation(PreAuthorize.class).value();
assertTrue("必须使用 train:board:* 权限: " + perm, perm.startsWith("@ss.hasPermi('train:board:"));
assertFalse("严禁包含 system:board:* 权限: " + perm, perm.contains("system:board:"));
}
}
}
}
@@ -0,0 +1,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));
}
}
}
@@ -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());
}
}
}
@@ -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);
}
@@ -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);
}
@@ -27,4 +27,8 @@ public interface TrainStudentPortraitMapper
*/
public List<TrainStudentPortrait> selectTrainStudentPortraitList(TrainStudentPortrait trainStudentPortrait);
/**
* 查询指定学员的最新多维成长画像
*/
public TrainStudentPortrait selectPortraitByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
}
@@ -27,4 +27,8 @@ public interface TrainStudyDurationMapper
*/
public List<TrainStudyDuration> selectTrainStudyDurationList(TrainStudyDuration trainStudyDuration);
/**
* 查询指定学员的学习时长记录列表 (按同步时间倒序)
*/
public List<TrainStudyDuration> selectDurationsByStudentId(@org.apache.ibatis.annotations.Param("studentId") String studentId);
}
@@ -66,4 +66,37 @@ public interface TrainTeacherMapper
* @return 结果
*/
public int deleteTrainTeacherByTIds(String[] tIds);
/**
* 根据系统用户ID查询教师信息
*
* @param userId 系统用户ID
* @return 教师信息
*/
public TrainTeacher selectTrainTeacherByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 统计指定系统用户ID绑定的有效教师数
*
* @param userId 系统用户ID
* @return 绑定数量
*/
public int countByUserId(@org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 绑定教师系统用户账号
*
* @param tId 教师工号
* @param userId 系统用户ID
* @return 影响行数
*/
public int bindUserId(@org.apache.ibatis.annotations.Param("tId") String tId, @org.apache.ibatis.annotations.Param("userId") Long userId);
/**
* 解绑教师系统用户账号
*
* @param tId 教师工号
* @return 影响行数
*/
public int unbindUserId(@org.apache.ibatis.annotations.Param("tId") String tId);
}
@@ -0,0 +1,21 @@
package com.ruoyi.train.mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 用户跨表并发悲观锁 Mapper
*
* @author ruoyi
*/
public interface TrainUserLockMapper
{
/**
* 悲观排他行锁:对当前绑定的系统用户账号加行锁,防止 Teacher / Student 跨表并发竞态
*
* @param userId 系统用户ID
* @return 锁定的用户ID
*/
@Select("select user_id from sys_user where user_id = #{userId} for update")
Long lockUserForUpdate(@Param("userId") Long userId);
}
@@ -0,0 +1,93 @@
package com.ruoyi.train.service;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
/**
* 培训业务身份解析与绑定服务契约接口。
*
* <p>负责统一处理 sys_user 与 train_teacher / train_student 之间的映射推导、
* 跨表互斥校验以及服务端身份断言,杜绝客户端主键篡改与 IDOR 越权。</p>
*
* @author ruoyi
*/
public interface ITrainingIdentityService
{
/**
* 获取当前登录用户的系统用户ID
*
* @return 当前用户ID
*/
Long getCurrentUserId();
/**
* 断言当前登录用户具备合规教师身份并返回教师工号 (tId)
*
* @return 教师工号 tId
* @throws com.ruoyi.common.exception.ServiceException 当未绑定教师档案或档案停用时抛出 403
*/
String assertTeacherIdentity();
/**
* 断言当前登录用户具备合规学员身份并返回学员编号 (sId)
*
* @return 学员编号 sId
* @throws com.ruoyi.common.exception.ServiceException 当未绑定学员档案或档案停用时抛出 403
*/
String assertStudentIdentity();
/**
* 根据系统用户ID查询绑定的有效教师档案
*
* @param userId 系统用户ID
* @return 教师实体,未绑定返回 null
*/
TrainTeacher getTeacherByUserId(Long userId);
/**
* 根据系统用户ID查询绑定的有效学员档案
*
* @param userId 系统用户ID
* @return 学员实体,未绑定返回 null
*/
TrainStudent getStudentByUserId(Long userId);
/**
* 校验系统用户绑定的跨表互斥性与唯一性
*
* @param userId 待绑定的系统用户ID
* @param targetType 目标业务主体类型 ("TEACHER" / "STUDENT")
* @param targetBusinessId 目标业务主键 (tId 或 sId)
*/
void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId);
/**
* 绑定教师业务实体与系统用户
*
* @param tId 教师工号
* @param userId 系统用户ID
*/
void bindTeacherUser(String tId, Long userId);
/**
* 绑定学员业务实体与系统用户
*
* @param sId 学员编号
* @param userId 系统用户ID
*/
void bindStudentUser(String sId, Long userId);
/**
* 解绑教师系统用户账号
*
* @param tId 教师工号
*/
void unbindTeacherUser(String tId);
/**
* 解绑学员系统用户账号
*
* @param sId 学员编号
*/
void unbindStudentUser(String sId);
}
@@ -0,0 +1,212 @@
package com.ruoyi.train.service.impl;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainStudent;
import com.ruoyi.train.domain.TrainTeacher;
import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.mapper.TrainTeacherMapper;
import com.ruoyi.train.mapper.TrainUserLockMapper;
import com.ruoyi.train.service.ITrainingIdentityService;
/**
* 培训业务身份解析与绑定服务实现类。
*
* @author ruoyi
*/
@Service
public class TrainingIdentityServiceImpl implements ITrainingIdentityService
{
private final TrainTeacherMapper teacherMapper;
private final TrainStudentMapper studentMapper;
private TrainUserLockMapper trainUserLockMapper;
/** 用于单机并发/单元测试环境提供额外的针对目标 userId 的原子锁保障 */
private final ConcurrentHashMap<Long, ReentrantLock> userLocks = new ConcurrentHashMap<>();
public TrainingIdentityServiceImpl(TrainTeacherMapper teacherMapper, TrainStudentMapper studentMapper)
{
this.teacherMapper = teacherMapper;
this.studentMapper = studentMapper;
}
@Autowired(required = false)
public void setTrainUserLockMapper(TrainUserLockMapper trainUserLockMapper)
{
this.trainUserLockMapper = trainUserLockMapper;
}
@Override
public Long getCurrentUserId()
{
return SecurityUtils.getUserId();
}
@Override
public String assertTeacherIdentity()
{
Long userId = getCurrentUserId();
TrainTeacher teacher = teacherMapper.selectTrainTeacherByUserId(userId);
if (teacher == null)
{
throw new ServiceException("当前登录账号未绑定任何教师档案,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
}
if (teacher.getStatus() != null && teacher.getStatus() == 0)
{
throw new ServiceException("当前教师档案已被停用,禁止访问教师专属工作台", HttpStatus.FORBIDDEN);
}
return teacher.getTId();
}
@Override
public String assertStudentIdentity()
{
Long userId = getCurrentUserId();
TrainStudent student = studentMapper.selectTrainStudentByUserId(userId);
if (student == null)
{
throw new ServiceException("当前登录账号未绑定任何学员档案,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
}
if (student.getStatus() != null && student.getStatus() == 0)
{
throw new ServiceException("当前学员档案已被停用,禁止访问学员个人培训中心", HttpStatus.FORBIDDEN);
}
return student.getsId();
}
@Override
public TrainTeacher getTeacherByUserId(Long userId)
{
if (userId == null)
{
return null;
}
return teacherMapper.selectTrainTeacherByUserId(userId);
}
@Override
public TrainStudent getStudentByUserId(Long userId)
{
if (userId == null)
{
return null;
}
return studentMapper.selectTrainStudentByUserId(userId);
}
@Override
public void validateUserIdentityExclusive(Long userId, String targetType, String targetBusinessId)
{
if (userId == null)
{
return;
}
// 正式一期身份规则:
// Admin是系统Role,不是业务身份。Admin角色允许绑定Teacher业务档案;
// Teacher与Student业务身份严格互斥,同一系统用户不得同时绑定Teacher与Student。
if ("TEACHER".equalsIgnoreCase(targetType))
{
TrainStudent existingStudent = studentMapper.selectTrainStudentByUserId(userId);
if (existingStudent != null)
{
throw new ServiceException("该系统登录账号已绑定为学员身份,不可重复绑定为教师");
}
TrainTeacher existingTeacher = teacherMapper.selectTrainTeacherByUserId(userId);
if (existingTeacher != null && (targetBusinessId == null || !existingTeacher.getTId().equals(targetBusinessId)))
{
throw new ServiceException("该系统登录账号已被其他教师档案绑定,不可重复绑定");
}
}
else if ("STUDENT".equalsIgnoreCase(targetType))
{
TrainTeacher existingTeacher = teacherMapper.selectTrainTeacherByUserId(userId);
if (existingTeacher != null)
{
throw new ServiceException("该系统登录账号已绑定为教师身份,不可重复绑定为学员");
}
TrainStudent existingStudent = studentMapper.selectTrainStudentByUserId(userId);
if (existingStudent != null && (targetBusinessId == null || !existingStudent.getsId().equals(targetBusinessId)))
{
throw new ServiceException("该系统登录账号已被其他学员档案绑定,不可重复绑定");
}
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void bindTeacherUser(String tId, Long userId)
{
if (userId == null)
{
throw new ServiceException("绑定目标系统用户ID不能为空");
}
// 1. 跨表悲观排他锁:在事务内锁定 sys_user 共同资源
if (trainUserLockMapper != null)
{
trainUserLockMapper.lockUserForUpdate(userId);
}
// 应用层同步保障(用于多线程单测或脱机环境)
ReentrantLock lock = userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
lock.lock();
try
{
// 2. 跨表互斥与重复绑定校验
validateUserIdentityExclusive(userId, "TEACHER", tId);
// 3. 执行绑定更新
teacherMapper.bindUserId(tId, userId);
}
finally
{
lock.unlock();
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void bindStudentUser(String sId, Long userId)
{
if (userId == null)
{
throw new ServiceException("绑定目标系统用户ID不能为空");
}
// 1. 跨表悲观排他锁:在事务内锁定 sys_user 共同资源
if (trainUserLockMapper != null)
{
trainUserLockMapper.lockUserForUpdate(userId);
}
// 应用层同步保障(用于多线程单测或脱机环境)
ReentrantLock lock = userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
lock.lock();
try
{
// 2. 跨表互斥与重复绑定校验
validateUserIdentityExclusive(userId, "STUDENT", sId);
// 3. 执行绑定更新
studentMapper.bindUserId(sId, userId);
}
finally
{
lock.unlock();
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindTeacherUser(String tId)
{
teacherMapper.unbindUserId(tId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unbindStudentUser(String sId)
{
studentMapper.unbindUserId(sId);
}
}
@@ -57,4 +57,12 @@
#{verifyResult}, #{verifyNote}, #{createBy}, #{createTime}, #{remark}
)
</insert>
<!-- 查询指定学员的最新报到记录 -->
<select id="selectLatestReportByStudentId" parameterType="String" resultMap="TrainClassReportResult">
<include refid="selectTrainClassReportVo"/>
where e.student_id = #{studentId}
order by r.report_time desc, r.report_id desc
limit 1
</select>
</mapper>
@@ -179,4 +179,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
select count(1) from train_study_duration where enrollment_id = #{enrollmentId}
</select>
<!-- 查询指定学员的全部参训记录及班级信息 -->
<select id="selectEnrollmentsByStudentId" resultType="com.ruoyi.train.domain.vo.EnrollmentItemVO">
select e.enrollment_id as enrollmentId,
e.plan_id as planId,
e.student_id as studentId,
s.s_name as studentName,
s.s_gender as gender,
s.id_card_last4 as idCardMasked,
s.org_name as orgName,
s.s_phone as phone,
e.source_type as sourceType,
e.enrollment_status as enrollmentStatus,
e.accommodation_required as accommodationRequired,
e.join_time as joinTime,
e.complete_time as completeTime,
e.remark as remark
from train_enrollment e
left join train_student s on e.student_id = s.s_id
where e.student_id = #{studentId}
order by e.create_time desc
</select>
</mapper>
@@ -275,4 +275,115 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by l.start_time asc, l.lesson_id asc
</select>
<!-- 查询指定教师的授课课次日程 -->
<select id="selectScheduleLessonsByTeacherId" resultMap="TimetableLessonItemResult">
select l.lesson_id,
l.plan_course_id,
c.c_name as course_name,
l.t_id,
t.t_name as teacher_name,
l.start_time,
l.end_time,
l.venue_type,
l.class_room_id,
cr.room_name as class_room_name,
l.site_id,
ts.site_name,
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
b.board_sn,
l.remark
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
left join train_training_site ts on l.site_id = ts.site_id
left join (
select bind_classroom_id,
case when count(*) = 1 then min(board_sn) else null end as board_sn
from train_electronic_board
where del_flag = '0'
group by bind_classroom_id
) b on (l.venue_type = 1 and b.bind_classroom_id = l.class_room_id)
where l.t_id = #{tId}
<if test="startDate != null">
and l.start_time &gt;= #{startDate}
</if>
<if test="endDate != null">
and l.start_time &lt;= #{endDate}
</if>
order by l.start_time asc, l.lesson_id asc
</select>
<!-- 查询指定学员所在有效班级的排课日程 -->
<select id="selectScheduleLessonsByStudentId" resultMap="TimetableLessonItemResult">
select l.lesson_id,
l.plan_course_id,
c.c_name as course_name,
l.t_id,
t.t_name as teacher_name,
l.start_time,
l.end_time,
l.venue_type,
l.class_room_id,
cr.room_name as class_room_name,
l.site_id,
ts.site_name,
case when l.venue_type = 1 then cr.room_name else ts.site_name end as venue_name,
b.board_sn,
l.remark
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
left join train_course c on pc.c_id = c.c_id
left join train_teacher t on l.t_id = t.t_id
left join train_classroom_info cr on l.class_room_id = cr.class_room_id
left join train_training_site ts on l.site_id = ts.site_id
left join (
select bind_classroom_id,
case when count(*) = 1 then min(board_sn) else null end as board_sn
from train_electronic_board
where del_flag = '0'
group by bind_classroom_id
) b on (l.venue_type = 1 and b.bind_classroom_id = l.class_room_id)
where pc.plan_id in (
select plan_id from train_enrollment
where student_id = #{studentId} and enrollment_status in (0, 1, 4)
)
<if test="startDate != null">
and l.start_time &gt;= #{startDate}
</if>
<if test="endDate != null">
and l.start_time &lt;= #{endDate}
</if>
order by l.start_time asc, l.lesson_id asc
</select>
<!-- 校验教师是否在计划中具有授课课次 -->
<select id="countTeacherInPlan" resultType="int">
select count(1)
from train_lesson l
inner join train_plan_course pc on l.plan_course_id = pc.plan_course_id
where l.t_id = #{tId} and pc.plan_id = #{planId}
</select>
<!-- 查询教师参与任教的培训班列表 -->
<select id="selectPlansByTeacherId" resultType="com.ruoyi.train.domain.TrainClassPlan">
select distinct p.plan_id as planId,
p.plan_name as planName,
p.plan_status as planStatus,
p.start_time as startTime,
p.end_time as endTime,
p.recruit_num as recruitNum,
p.del_flag as delFlag
from train_class_plan p
inner join train_plan_course pc on p.plan_id = pc.plan_id
inner join train_lesson l on pc.plan_course_id = l.plan_course_id
where l.t_id = #{tId}
and p.del_flag = '0'
<if test="planStatus != null">
and p.plan_status = #{planStatus}
</if>
order by p.start_time desc
</select>
</mapper>
@@ -87,4 +87,19 @@
</set>
where checkin_id = #{checkinId}
</update>
<!-- 查询指定学员的考勤打卡流水 -->
<select id="selectCheckinsByStudentId" resultMap="TrainStudentCheckinResult">
<include refid="selectTrainStudentCheckinVo"/>
where e.student_id = #{studentId}
<if test="planId != null and planId != ''">and e.plan_id = #{planId}</if>
order by a.sign_in_time desc, a.checkin_id desc
</select>
<!-- 查询指定课次的学员考勤打卡流水 -->
<select id="selectCheckinsByLessonId" resultMap="TrainStudentCheckinResult">
<include refid="selectTrainStudentCheckinVo"/>
where a.lesson_id = #{lessonId}
order by s.s_name asc, a.checkin_id asc
</select>
</mapper>
@@ -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>
@@ -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();
}
}
}
@@ -32,6 +32,7 @@ public class LegacyAnalyticsV2ServiceTest
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
}
public int insertTrainClassReport(TrainClassReport report) { return 0; }
public TrainClassReport selectLatestReportByStudentId(String studentId) { return null; }
});
assertEquals(0, service.selectTrainClassReportList(new TrainClassReport()).size());
TrainClassReport query = new TrainClassReport();
@@ -53,6 +54,8 @@ public class LegacyAnalyticsV2ServiceTest
}
public int insertTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; }
public int updateTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; }
public List<TrainStudentCheckin> selectCheckinsByStudentId(String sId, String pId, String m) { return Collections.emptyList(); }
public List<TrainStudentCheckin> selectCheckinsByLessonId(Long lId) { return Collections.emptyList(); }
});
assertEquals(0, service.selectTrainStudentCheckinList(new TrainStudentCheckin()).size());
TrainStudentCheckin query = new TrainStudentCheckin();
@@ -71,6 +74,7 @@ public class LegacyAnalyticsV2ServiceTest
public List<TrainStudyDuration> selectTrainStudyDurationList(TrainStudyDuration query) {
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
}
public List<TrainStudyDuration> selectDurationsByStudentId(String studentId) { return Collections.emptyList(); }
});
assertEquals(0, service.selectTrainStudyDurationList(new TrainStudyDuration()).size());
TrainStudyDuration query = new TrainStudyDuration();
@@ -89,6 +93,7 @@ public class LegacyAnalyticsV2ServiceTest
public List<TrainStudentPortrait> selectTrainStudentPortraitList(TrainStudentPortrait query) {
return query.getEnrollmentId() == null ? Collections.emptyList() : Arrays.asList(row);
}
public TrainStudentPortrait selectPortraitByStudentId(String studentId) { return null; }
});
assertEquals(0, service.selectTrainStudentPortraitList(new TrainStudentPortrait()).size());
TrainStudentPortrait query = new TrainStudentPortrait();
@@ -1043,6 +1043,30 @@ public class SchedulingAppServiceTest
vo.setEndTime(l.getEndTime());
return vo;
}
@Override
public List<TimetableLessonItemVO> selectScheduleLessonsByTeacherId(String tId, Date startDate, Date endDate)
{
return Collections.emptyList();
}
@Override
public List<TimetableLessonItemVO> selectScheduleLessonsByStudentId(String studentId, Date startDate, Date endDate)
{
return Collections.emptyList();
}
@Override
public int countTeacherInPlan(String tId, String planId)
{
return 0;
}
@Override
public List<TrainClassPlan> selectPlansByTeacherId(String tId, Integer planStatus)
{
return Collections.emptyList();
}
}
/**
@@ -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;
}
}
}
@@ -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("该系统登录账号已绑定为教师身份,不可重复绑定为学员");
}
}
}
```
+21 -22
View File
@@ -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`