diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java index f9c60763d..2b692b0a4 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java @@ -125,7 +125,7 @@ public class BusinessTransactionIndependenceTest { new Class[]{TrainEnrollmentMapper.class}, (proxy, method, args) -> { String name = method.getName(); - if ("selectTrainEnrollmentById".equals(name)) return enrollment; + if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByIdForUpdate".equals(name)) return enrollment; if ("confirmEnrollmentReport".equals(name)) { enrollment.setEnrollmentStatus(1); return 1; diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java index 408810320..dc25b6197 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AttendanceActionContractTest.java @@ -71,6 +71,22 @@ public class AttendanceActionContractTest checkinsByEnrollLesson.put(c.getEnrollmentId() + "_" + c.getLessonId(), c); return 1; } + if ("signOutIfUnsigned".equals(name)) + { + Long cid = (Long) args[0]; + Date soTime = (Date) args[1]; + String op = (String) args[2]; + Date ut = (Date) args[3]; + TrainStudentCheckin c = checkinsById.get(cid); + if (c != null && c.getSignInTime() != null && c.getSignOutTime() == null) + { + c.setSignOutTime(soTime); + c.setUpdateBy(op); + c.setUpdateTime(ut); + return 1; + } + return 0; + } if ("updateTrainStudentCheckin".equals(name)) { TrainStudentCheckin c = (TrainStudentCheckin) args[0]; @@ -88,7 +104,7 @@ public class AttendanceActionContractTest public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String name = method.getName(); - if ("selectTrainEnrollmentById".equals(name)) + if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByIdForUpdate".equals(name)) { return enrollments.get((Long) args[0]); } diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java index ab48e558c..85abc0e42 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImplTest.java @@ -748,6 +748,27 @@ public class EnrollmentAppServiceImplTest return store.get(enrollmentId); } + @Override + public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long enrollmentId) + { + return store.get(enrollmentId); + } + + @Override + public List selectEnrollmentsForUpdateByPlanId(String planId) + { + List list = new ArrayList<>(); + for (TrainEnrollment e : store.values()) + { + if (e.getPlanId().equals(planId)) + { + list.add(e); + } + } + list.sort(java.util.Comparator.comparing(TrainEnrollment::getEnrollmentId)); + return list; + } + @Override public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId) { @@ -1096,6 +1117,18 @@ public class EnrollmentAppServiceImplTest return enrollmentService.selectTrainEnrollmentById(enrollmentId); } + @Override + public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long enrollmentId) + { + return enrollmentService.selectTrainEnrollmentByIdForUpdate(enrollmentId); + } + + @Override + public List selectEnrollmentsForUpdateByPlanId(String planId) + { + return enrollmentService.selectEnrollmentsForUpdateByPlanId(planId); + } + @Override public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java index 865665a8f..7df658b02 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/LegacyAnalyticsV2ServiceTest.java @@ -56,6 +56,7 @@ public class LegacyAnalyticsV2ServiceTest public int updateTrainStudentCheckin(TrainStudentCheckin checkin) { return 0; } public List selectCheckinsByStudentId(String sId, String pId, String m) { return Collections.emptyList(); } public List selectCheckinsByLessonId(Long lId) { return Collections.emptyList(); } + public int signOutIfUnsigned(Long cId, java.util.Date soTime, String op, java.util.Date ut) { return 0; } }); assertEquals(0, service.selectTrainStudentCheckinList(new TrainStudentCheckin()).size()); TrainStudentCheckin query = new TrainStudentCheckin(); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageBCoreBusinessBehaviorTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageBCoreBusinessBehaviorTest.java index 40f0039e9..755a6365d 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageBCoreBusinessBehaviorTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PackageBCoreBusinessBehaviorTest.java @@ -50,6 +50,10 @@ public class PackageBCoreBusinessBehaviorTest // DMS Reconcile 监控计数 private List dmsReconcileInvocations; + // 行锁调用追踪 + private List selectForUpdateInvocations; + private List planEnrollmentsForUpdateInvocations; + @Before public void setUp() { @@ -61,6 +65,8 @@ public class PackageBCoreBusinessBehaviorTest planCourseTable = new HashMap<>(); studentTable = new HashMap<>(); dmsReconcileInvocations = new ArrayList<>(); + selectForUpdateInvocations = new ArrayList<>(); + planEnrollmentsForUpdateInvocations = new ArrayList<>(); // 初始化基础班级、课程与学员 TrainClassPlan defaultPlan = new TrainClassPlan(); @@ -100,6 +106,27 @@ public class PackageBCoreBusinessBehaviorTest @Override public TrainEnrollment selectTrainEnrollmentById(Long id) { return enrollmentTable.get(id); } @Override + public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long id) + { + selectForUpdateInvocations.add(id); + return enrollmentTable.get(id); + } + @Override + public List selectEnrollmentsForUpdateByPlanId(String planId) + { + planEnrollmentsForUpdateInvocations.add(planId); + List list = new ArrayList<>(); + for (TrainEnrollment e : enrollmentTable.values()) + { + if (e.getPlanId().equals(planId)) + { + list.add(e); + } + } + list.sort(Comparator.comparing(TrainEnrollment::getEnrollmentId)); + return list; + } + @Override public int updateEnrollmentStatus(Long id, Integer status, String remark, String op, Date time) { TrainEnrollment e = enrollmentTable.get(id); @@ -522,7 +549,7 @@ public class PackageBCoreBusinessBehaviorTest assertTrue("batchInsertTrainEnrollment 必须固定为 0", batchSnippet.contains("#{item.sourceType}, 0,")); - // 2. 行为验证:输入 DTO 携带 status=4 或 1 时,批量入库后状态一律固定为 0 + // 2. 真实 Service 行为验证:输入 DTO 携带 status=4 或 1 时,经过 Service 处理后传递给 Mapper 的状态一律固定为 0 TrainEnrollment item1 = new TrainEnrollment(); item1.setPlanId("PLAN-001"); item1.setStudentId("STU-001"); @@ -534,9 +561,36 @@ public class PackageBCoreBusinessBehaviorTest item2.setEnrollmentStatus(1); // 恶意输入1 List list = Arrays.asList(item1, item2); - for (TrainEnrollment item : list) item.setEnrollmentStatus(0); - assertEquals(Integer.valueOf(0), item1.getEnrollmentStatus()); - assertEquals(Integer.valueOf(0), item2.getEnrollmentStatus()); + final List captured = new ArrayList<>(); + + TrainEnrollmentMapper capturingMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance( + TrainEnrollmentMapper.class.getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + if ("batchInsertTrainEnrollment".equals(method.getName())) + { + List passed = (List) args[0]; + for (TrainEnrollment e : passed) + { + TrainEnrollment copy = new TrainEnrollment(); + copy.setStudentId(e.getStudentId()); + copy.setEnrollmentStatus(e.getEnrollmentStatus()); + captured.add(copy); + } + return passed.size(); + } + return null; + } + ); + + TrainEnrollmentServiceImpl realService = new TrainEnrollmentServiceImpl(); + setField(realService, "trainEnrollmentMapper", capturingMapper); + + realService.batchInsertTrainEnrollment(list); + + assertEquals("Mapper 必须收到 2 条记录", 2, captured.size()); + assertEquals("即使输入为4,Service传递给Mapper的第1条状态必须强制为0", Integer.valueOf(0), captured.get(0).getEnrollmentStatus()); + assertEquals("即使输入为1,Service传递给Mapper的第2条状态必须强制为0", Integer.valueOf(0), captured.get(1).getEnrollmentStatus()); } // ========================================================================= @@ -935,6 +989,209 @@ public class PackageBCoreBusinessBehaviorTest normalRowsBefore, normalRowsAfter); } + // ========================================================================= + // 30. 并发硬化与事务契约测试 (B28 ~ B34) + // ========================================================================= + + /** B28 & B29: 并发双 signOut 原子条件更新 CAS 守卫:最多1次成功,不能覆盖首次时间 (MOCK_CONCURRENT_BEHAVIOR_TEST) */ + @Test + public void testB28_B29_ConcurrentSignOutAtomicGuard() throws Exception + { + Long enrollmentId = 8801L; + Long lessonId = 1001L; + createEnrollment(enrollmentId, "PLAN-001", "STU-8801", 1); + Date signInTime = new Date(System.currentTimeMillis() - 7200000); // 2小时前 + createCheckin(enrollmentId, lessonId, signInTime); + + int threadCount = 2; + java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(threadCount); + java.util.concurrent.CyclicBarrier barrier = new java.util.concurrent.CyclicBarrier(threadCount); + java.util.concurrent.atomic.AtomicInteger successCount = new java.util.concurrent.atomic.AtomicInteger(0); + java.util.concurrent.atomic.AtomicInteger failureCount = new java.util.concurrent.atomic.AtomicInteger(0); + java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(threadCount); + + Date time1 = new Date(System.currentTimeMillis() - 3600000); // 1小时前 + Date time2 = new Date(); // 当前时间 + + executor.submit(() -> { + try { + barrier.await(); + AttendanceSignOutDTO dto = new AttendanceSignOutDTO(); + dto.setEnrollmentId(enrollmentId); + dto.setLessonId(lessonId); + dto.setSignOutTime(time1); + attendanceAppService.signOut(dto, "thread-1"); + successCount.incrementAndGet(); + } catch (Exception e) { + failureCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + + executor.submit(() -> { + try { + barrier.await(); + AttendanceSignOutDTO dto = new AttendanceSignOutDTO(); + dto.setEnrollmentId(enrollmentId); + dto.setLessonId(lessonId); + dto.setSignOutTime(time2); + attendanceAppService.signOut(dto, "thread-2"); + successCount.incrementAndGet(); + } catch (Exception e) { + failureCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + + latch.await(5, java.util.concurrent.TimeUnit.SECONDS); + executor.shutdown(); + + assertEquals("并发双签退只能有1次成功", 1, successCount.get()); + assertEquals("并发双签退必须有1次失败被拒绝", 1, failureCount.get()); + + TrainStudentCheckin checkin = checkinTable.values().stream() + .filter(c -> c.getEnrollmentId().equals(enrollmentId) && c.getLessonId().equals(lessonId)) + .findFirst().orElse(null); + assertNotNull(checkin); + assertNotNull("必须已记录签退时间", checkin.getSignOutTime()); + assertTrue("签退时间不能被覆盖", checkin.getSignOutTime().equals(time1) || checkin.getSignOutTime().equals(time2)); + } + + /** B30: updateEnrollmentStatus 使用 enrollment FOR UPDATE 消除 TOCTOU */ + @Test + public void testB30_UpdateEnrollmentStatusUsesForUpdateLock() + { + selectForUpdateInvocations.clear(); + Long enrollmentId = 301L; + createEnrollment(enrollmentId, "PLAN-001", "STU-301", 1); + createReport(enrollmentId, 301L); + + EnrollmentStatusUpdateDTO dto = new EnrollmentStatusUpdateDTO(); + dto.setEnrollmentStatus(2); // 请假 + dto.setRemark("事假"); + enrollmentAppService.updateEnrollmentStatus(enrollmentId, dto, "admin"); + + assertTrue("updateEnrollmentStatus 必须通过 selectTrainEnrollmentByIdForUpdate 获取行级锁", + selectForUpdateInvocations.contains(enrollmentId)); + } + + /** B31: confirmReport 使用同一 enrollment FOR UPDATE 消除 TOCTOU */ + @Test + public void testB31_ConfirmReportUsesForUpdateLock() + { + selectForUpdateInvocations.clear(); + Long enrollmentId = 302L; + createEnrollment(enrollmentId, "PLAN-001", "STU-302", 0); + + ReportConfirmDTO dto = new ReportConfirmDTO(); + dto.setReportWay(4); + enrollmentAppService.confirmReport(enrollmentId, dto, "admin"); + + assertTrue("confirmReport 必须通过 selectTrainEnrollmentByIdForUpdate 获取行级锁", + selectForUpdateInvocations.contains(enrollmentId)); + } + + /** B32: plan completion 必须先按稳定顺序锁定该 plan 下全部 enrollment 行,再执行 precheck */ + @Test + public void testB32_PlanCompletionLocksAllEnrollmentsBeforePrecheck() + { + planEnrollmentsForUpdateInvocations.clear(); + String planId = "PLAN-B32"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + createEnrollment(321L, planId, "STU-321", 1); + createReport(321L, 321L); + + planAppService.completePlan(planId, "lead_admin"); + + assertTrue("结训预检必须调用 selectEnrollmentsForUpdateByPlanId 锁定全班学员记录", + planEnrollmentsForUpdateInvocations.contains(planId)); + } + + /** B33: batchInsert 真实 Service 调用强制 status=0 拦截非法入参 */ + @Test + public void testB33_BatchInsertRealServiceForcesZero() + { + TrainEnrollment e1 = new TrainEnrollment(); + e1.setPlanId("PLAN-001"); + e1.setStudentId("STU-331"); + e1.setEnrollmentStatus(4); + + final List captured = new ArrayList<>(); + TrainEnrollmentMapper capturingMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance( + TrainEnrollmentMapper.class.getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + if ("batchInsertTrainEnrollment".equals(method.getName())) + { + List passed = (List) args[0]; + for (TrainEnrollment item : passed) { + TrainEnrollment copy = new TrainEnrollment(); + copy.setEnrollmentStatus(item.getEnrollmentStatus()); + captured.add(copy); + } + return passed.size(); + } + return null; + } + ); + + TrainEnrollmentServiceImpl realService = new TrainEnrollmentServiceImpl(); + setField(realService, "trainEnrollmentMapper", capturingMapper); + realService.batchInsertTrainEnrollment(Collections.singletonList(e1)); + + assertEquals(1, captured.size()); + assertEquals("Service 必须把 enrollmentStatus 强制覆写为 0", Integer.valueOf(0), captured.get(0).getEnrollmentStatus()); + } + + /** B34: adapter 异常必须完整向外传播,禁止 PlanAppServiceImpl 吃掉异常继续完成 */ + @Test + public void testB34_AdapterExceptionPropagatesOutward() + { + String planId = "PLAN-B34"; + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setPlanStatus(1); + planTable.put(planId, plan); + + createEnrollment(341L, planId, "STU-341", 0); // 待报到,预检必然抛出异常 + + try + { + planAppService.completePlan(planId, "operator"); + fail("Adapter 预检异常必须向外抛出,禁止结训成功"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("WAITING_REPORT_ENROLLMENTS_EXIST")); + // 验证计划自身状态绝未被修改为完成 (2) + assertEquals("计划状态绝不能被更新为完成", Integer.valueOf(1), planTable.get(planId).getPlanStatus()); + } + } + + /** 事务注解契约验证:验证 completePlan 为事务入口,completeEnrollments 为 MANDATORY 事务 */ + @Test + public void testPlanCompletionTransactionAnnotationContract() throws Exception + { + java.lang.reflect.Method completePlanMethod = PlanAppServiceImpl.class.getMethod("completePlan", String.class, String.class); + org.springframework.transaction.annotation.Transactional planTx = + completePlanMethod.getAnnotation(org.springframework.transaction.annotation.Transactional.class); + assertNotNull("PlanAppServiceImpl.completePlan 必须标记 @Transactional", planTx); + assertEquals(Exception.class, planTx.rollbackFor()[0]); + + java.lang.reflect.Method completeEnrollmentsMethod = EnrollmentPlanCompletionAdapter.class.getMethod("completeEnrollments", String.class, String.class, Date.class); + org.springframework.transaction.annotation.Transactional enrollTx = + completeEnrollmentsMethod.getAnnotation(org.springframework.transaction.annotation.Transactional.class); + assertNotNull("EnrollmentPlanCompletionAdapter.completeEnrollments 必须标记 @Transactional", enrollTx); + assertEquals("结训学员归档事务传播机制必须为 MANDATORY", + org.springframework.transaction.annotation.Propagation.MANDATORY, enrollTx.propagation()); + } + // ========================================================================= // Helper Methods // ========================================================================= @@ -962,6 +1219,33 @@ public class PackageBCoreBusinessBehaviorTest return r; } + private TrainStudentCheckin createCheckin(Long enrollmentId, Long lessonId, Date signInTime) + { + TrainStudentCheckin c = new TrainStudentCheckin(); + long id = checkinTable.size() + 100L; + c.setCheckinId(id); + c.setEnrollmentId(enrollmentId); + c.setLessonId(lessonId); + c.setSignInTime(signInTime); + c.setAttendanceStatus(1); + checkinTable.put(id, c); + return c; + } + + private void setField(Object target, String fieldName, Object value) + { + try + { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + catch (Exception e) + { + throw new RuntimeException("Failed to set field " + fieldName + " on " + target, e); + } + } + private TrainEnrollmentMapper createMockEnrollmentMapper() { return (TrainEnrollmentMapper) Proxy.newProxyInstance( @@ -970,6 +1254,23 @@ public class PackageBCoreBusinessBehaviorTest (proxy, method, args) -> { String name = method.getName(); if ("selectTrainEnrollmentById".equals(name)) return enrollmentTable.get(args[0]); + if ("selectTrainEnrollmentByIdForUpdate".equals(name)) + { + selectForUpdateInvocations.add((Long) args[0]); + return enrollmentTable.get(args[0]); + } + if ("selectEnrollmentsForUpdateByPlanId".equals(name)) + { + String pid = (String) args[0]; + planEnrollmentsForUpdateInvocations.add(pid); + List list = new ArrayList<>(); + for (TrainEnrollment e : enrollmentTable.values()) + { + if (e.getPlanId().equals(pid)) list.add(e); + } + list.sort(Comparator.comparing(TrainEnrollment::getEnrollmentId)); + return list; + } if ("confirmEnrollmentReport".equals(name)) { Long eid = (Long) args[0]; @@ -1042,10 +1343,18 @@ public class PackageBCoreBusinessBehaviorTest TrainClassPlanMapper.class.getClassLoader(), new Class[]{TrainClassPlanMapper.class}, (proxy, method, args) -> { - if ("selectTrainClassPlanByPlanId".equals(method.getName()) || "selectPlanForUpdate".equals(method.getName())) + if ("selectTrainClassPlanByPlanId".equals(method.getName()) || "selectPlanForUpdate".equals(method.getName()) + || "selectTrainClassPlanByPlanIdForUpdate".equals(method.getName())) { return planTable.get(args[0]); } + if ("updateTrainClassPlanStatus".equals(method.getName())) + { + TrainClassPlan p = (TrainClassPlan) args[0]; + TrainClassPlan exist = planTable.get(p.getPlanId()); + if (exist != null) exist.setPlanStatus(p.getPlanStatus()); + return 1; + } return null; } ); @@ -1080,6 +1389,25 @@ public class PackageBCoreBusinessBehaviorTest checkinTable.put(id, c); return 1; } + if ("signOutIfUnsigned".equals(name)) + { + Long cid = (Long) args[0]; + Date soTime = (Date) args[1]; + String op = (String) args[2]; + Date ut = (Date) args[3]; + synchronized (checkinTable) + { + TrainStudentCheckin c = checkinTable.get(cid); + if (c != null && c.getSignInTime() != null && c.getSignOutTime() == null) + { + c.setSignOutTime(soTime); + c.setUpdateBy(op); + c.setUpdateTime(ut); + return 1; + } + return 0; + } + } if ("updateTrainStudentCheckin".equals(name)) { TrainStudentCheckin c = (TrainStudentCheckin) args[0]; diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java index c62809fd1..05731bab1 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/PlanEnrollmentCompletionIntegrationTest.java @@ -308,6 +308,27 @@ public class PlanEnrollmentCompletionIntegrationTest return enrollmentStore.get(enrollmentId); } + @Override + public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long enrollmentId) + { + return enrollmentStore.get(enrollmentId); + } + + @Override + public List selectEnrollmentsForUpdateByPlanId(String planId) + { + List list = new ArrayList<>(); + for (TrainEnrollment e : enrollmentStore.values()) + { + if (e.getPlanId().equals(planId)) + { + list.add(e); + } + } + list.sort(java.util.Comparator.comparing(TrainEnrollment::getEnrollmentId)); + return list; + } + @Override public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportActionContractTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportActionContractTest.java index 772f352ae..e54340fe0 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportActionContractTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ReportActionContractTest.java @@ -53,7 +53,7 @@ public class ReportActionContractTest public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String name = method.getName(); - if ("selectTrainEnrollmentById".equals(name)) + if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByIdForUpdate".equals(name)) { return enrollments.get((Long) args[0]); } diff --git a/scripts/qa/ValidateRealDbRollback.java b/scripts/qa/ValidateRealDbRollback.java new file mode 100644 index 000000000..b19adc54e --- /dev/null +++ b/scripts/qa/ValidateRealDbRollback.java @@ -0,0 +1,143 @@ +package scripts.qa; + +import java.io.File; +import java.nio.file.Files; +import java.sql.*; + +/** + * 真实 Kingbase 数据库 ROLLBACK-ONLY 事务回滚验证脚本。 + * 严禁 COMMIT 任何测试数据。验证真实数据库事务原子性与回滚机制。 + */ +public class ValidateRealDbRollback { + public static void main(String[] args) throws Exception { + Class.forName("com.kingbase8.Driver"); + + File envFile = new File(System.getProperty("user.home"), ".railwaysystem-qa.env"); + String pass = ""; + if (envFile.exists()) { + for (String line : Files.readAllLines(envFile.toPath())) { + line = line.trim(); + if (line.startsWith("QA_DB_PASSWORD=")) { + pass = line.substring("QA_DB_PASSWORD=".length()).trim(); + if (pass.startsWith("\"") && pass.endsWith("\"")) { + pass = pass.substring(1, pass.length() - 1); + } + } + } + } + if (pass.isEmpty()) { + throw new IllegalStateException("QA_DB_PASSWORD is required in ~/.railwaysystem-qa.env"); + } + + // 尝试直连或通过 SSH 端口转发 (14321) 连接 + String dbUrl = "jdbc:kingbase8://127.0.0.1:14321/railwaysystem_integrated_qa_20260916?useUnicode=true&characterEncoding=utf8"; + try (Connection conn = DriverManager.getConnection(dbUrl, "kingbase", pass)) { + System.out.println("CONNECTED_TO_DB=TRUE"); + + // 1. 验证当前数据库身份 + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT current_database(), current_user")) { + if (rs.next()) { + System.out.println("CURRENT_DATABASE=" + rs.getString(1)); + System.out.println("CURRENT_USER=" + rs.getString(2)); + if (!"railwaysystem_integrated_qa_20260916".equals(rs.getString(1))) { + throw new IllegalStateException("数据库身份不符,安全终止!"); + } + } + } + + // 2. 选择一条安全的已有记录读取原始状态 + String targetPlanId = null; + int originalPlanStatus = -1; + long targetEnrollmentId = -1; + int originalEnrollmentStatus = -1; + + String selectSampleSql = "SELECT p.plan_id, p.plan_status, e.enrollment_id, e.enrollment_status " + + "FROM train_class_plan p " + + "JOIN train_enrollment e ON e.plan_id = p.plan_id " + + "LIMIT 1"; + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery(selectSampleSql)) { + if (rs.next()) { + targetPlanId = rs.getString(1); + originalPlanStatus = rs.getInt(2); + targetEnrollmentId = rs.getLong(3); + originalEnrollmentStatus = rs.getInt(4); + } + } + + if (targetPlanId == null) { + System.out.println("NO_SAMPLE_DATA_FOUND"); + return; + } + + System.out.println(String.format("SAMPLE_BEFORE: plan_id=%s, plan_status=%d, enrollment_id=%d, enrollment_status=%d", + targetPlanId, originalPlanStatus, targetEnrollmentId, originalEnrollmentStatus)); + + // 3. 开启事务 (禁用 AutoCommit) + conn.setAutoCommit(false); + + try { + // 在未提交事务中模拟结训更新 + int mockPlanTargetStatus = (originalPlanStatus == 2) ? 1 : 2; + int mockEnrollmentTargetStatus = (originalEnrollmentStatus == 4) ? 1 : 4; + + try (PreparedStatement pst = conn.prepareStatement( + "UPDATE train_class_plan SET plan_status = ?, update_time = now() WHERE plan_id = ?")) { + pst.setInt(1, mockPlanTargetStatus); + pst.setString(2, targetPlanId); + pst.executeUpdate(); + } + + try (PreparedStatement pst = conn.prepareStatement( + "UPDATE train_enrollment SET enrollment_status = ?, complete_time = now() WHERE enrollment_id = ?")) { + pst.setInt(1, mockEnrollmentTargetStatus); + pst.setLong(2, targetEnrollmentId); + pst.executeUpdate(); + } + + // 事务内查看临时修改值 + int inTxPlanStatus = -1; + int inTxEnrollStatus = -1; + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT p.plan_status, e.enrollment_status FROM train_class_plan p JOIN train_enrollment e ON e.plan_id = p.plan_id WHERE p.plan_id = '" + targetPlanId + "' AND e.enrollment_id = " + targetEnrollmentId)) { + if (rs.next()) { + inTxPlanStatus = rs.getInt(1); + inTxEnrollStatus = rs.getInt(2); + } + } + System.out.println(String.format("IN_TRANSACTION_MODIFIED: plan_status=%d, enrollment_status=%d", inTxPlanStatus, inTxEnrollStatus)); + + // 4. 显式执行主动 ROLLBACK (严禁 COMMIT) + conn.rollback(); + System.out.println("TRANSACTION_ROLLED_BACK=TRUE"); + + } catch (Exception ex) { + conn.rollback(); + throw ex; + } finally { + conn.setAutoCommit(true); + } + + // 5. 验证回滚后数据完全恢复原样 + int afterRollbackPlanStatus = -1; + int afterRollbackEnrollStatus = -1; + try (Statement st = conn.createStatement(); + ResultSet rs = st.executeQuery("SELECT p.plan_status, e.enrollment_status FROM train_class_plan p JOIN train_enrollment e ON e.plan_id = p.plan_id WHERE p.plan_id = '" + targetPlanId + "' AND e.enrollment_id = " + targetEnrollmentId)) { + if (rs.next()) { + afterRollbackPlanStatus = rs.getInt(1); + afterRollbackEnrollStatus = rs.getInt(2); + } + } + System.out.println(String.format("AFTER_ROLLBACK_VERIFIED: plan_status=%d, enrollment_status=%d", afterRollbackPlanStatus, afterRollbackEnrollStatus)); + + boolean intact = (originalPlanStatus == afterRollbackPlanStatus) && (originalEnrollmentStatus == afterRollbackEnrollStatus); + if (intact) { + System.out.println("REAL_DB_TRANSACTION_ROLLBACK_VALIDATION=PASS"); + } else { + System.err.println("REAL_DB_TRANSACTION_ROLLBACK_VALIDATION=FAIL (Data altered!)"); + System.exit(1); + } + } + } +}