测试业务:补齐状态并发与批量新增真实行为验证
This commit is contained in:
1 parent
612f24d5f1
commit
fd8e0f7477
8 files changed
+550
-8
No files matched your search
+1
-1
@@ -125,7 +125,7 @@ public class BusinessTransactionIndependenceTest {
|
|||||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||||
(proxy, method, args) -> {
|
(proxy, method, args) -> {
|
||||||
String name = method.getName();
|
String name = method.getName();
|
||||||
if ("selectTrainEnrollmentById".equals(name)) return enrollment;
|
if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByIdForUpdate".equals(name)) return enrollment;
|
||||||
if ("confirmEnrollmentReport".equals(name)) {
|
if ("confirmEnrollmentReport".equals(name)) {
|
||||||
enrollment.setEnrollmentStatus(1);
|
enrollment.setEnrollmentStatus(1);
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
+17
-1
@@ -71,6 +71,22 @@ public class AttendanceActionContractTest
|
|||||||
checkinsByEnrollLesson.put(c.getEnrollmentId() + "_" + c.getLessonId(), c);
|
checkinsByEnrollLesson.put(c.getEnrollmentId() + "_" + c.getLessonId(), c);
|
||||||
return 1;
|
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))
|
if ("updateTrainStudentCheckin".equals(name))
|
||||||
{
|
{
|
||||||
TrainStudentCheckin c = (TrainStudentCheckin) args[0];
|
TrainStudentCheckin c = (TrainStudentCheckin) args[0];
|
||||||
@@ -88,7 +104,7 @@ public class AttendanceActionContractTest
|
|||||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
|
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
|
||||||
{
|
{
|
||||||
String name = method.getName();
|
String name = method.getName();
|
||||||
if ("selectTrainEnrollmentById".equals(name))
|
if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByIdForUpdate".equals(name))
|
||||||
{
|
{
|
||||||
return enrollments.get((Long) args[0]);
|
return enrollments.get((Long) args[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
@@ -748,6 +748,27 @@ public class EnrollmentAppServiceImplTest
|
|||||||
return store.get(enrollmentId);
|
return store.get(enrollmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long enrollmentId)
|
||||||
|
{
|
||||||
|
return store.get(enrollmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TrainEnrollment> selectEnrollmentsForUpdateByPlanId(String planId)
|
||||||
|
{
|
||||||
|
List<TrainEnrollment> 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
|
@Override
|
||||||
public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId)
|
public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId)
|
||||||
{
|
{
|
||||||
@@ -1096,6 +1117,18 @@ public class EnrollmentAppServiceImplTest
|
|||||||
return enrollmentService.selectTrainEnrollmentById(enrollmentId);
|
return enrollmentService.selectTrainEnrollmentById(enrollmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long enrollmentId)
|
||||||
|
{
|
||||||
|
return enrollmentService.selectTrainEnrollmentByIdForUpdate(enrollmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TrainEnrollment> selectEnrollmentsForUpdateByPlanId(String planId)
|
||||||
|
{
|
||||||
|
return enrollmentService.selectEnrollmentsForUpdateByPlanId(planId);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId)
|
public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
@@ -56,6 +56,7 @@ public class LegacyAnalyticsV2ServiceTest
|
|||||||
public int updateTrainStudentCheckin(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> selectCheckinsByStudentId(String sId, String pId, String m) { return Collections.emptyList(); }
|
||||||
public List<TrainStudentCheckin> selectCheckinsByLessonId(Long lId) { return Collections.emptyList(); }
|
public List<TrainStudentCheckin> 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());
|
assertEquals(0, service.selectTrainStudentCheckinList(new TrainStudentCheckin()).size());
|
||||||
TrainStudentCheckin query = new TrainStudentCheckin();
|
TrainStudentCheckin query = new TrainStudentCheckin();
|
||||||
|
|||||||
+333
-5
@@ -50,6 +50,10 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
// DMS Reconcile 监控计数
|
// DMS Reconcile 监控计数
|
||||||
private List<Long> dmsReconcileInvocations;
|
private List<Long> dmsReconcileInvocations;
|
||||||
|
|
||||||
|
// 行锁调用追踪
|
||||||
|
private List<Long> selectForUpdateInvocations;
|
||||||
|
private List<String> planEnrollmentsForUpdateInvocations;
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
public void setUp()
|
public void setUp()
|
||||||
{
|
{
|
||||||
@@ -61,6 +65,8 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
planCourseTable = new HashMap<>();
|
planCourseTable = new HashMap<>();
|
||||||
studentTable = new HashMap<>();
|
studentTable = new HashMap<>();
|
||||||
dmsReconcileInvocations = new ArrayList<>();
|
dmsReconcileInvocations = new ArrayList<>();
|
||||||
|
selectForUpdateInvocations = new ArrayList<>();
|
||||||
|
planEnrollmentsForUpdateInvocations = new ArrayList<>();
|
||||||
|
|
||||||
// 初始化基础班级、课程与学员
|
// 初始化基础班级、课程与学员
|
||||||
TrainClassPlan defaultPlan = new TrainClassPlan();
|
TrainClassPlan defaultPlan = new TrainClassPlan();
|
||||||
@@ -100,6 +106,27 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
@Override
|
@Override
|
||||||
public TrainEnrollment selectTrainEnrollmentById(Long id) { return enrollmentTable.get(id); }
|
public TrainEnrollment selectTrainEnrollmentById(Long id) { return enrollmentTable.get(id); }
|
||||||
@Override
|
@Override
|
||||||
|
public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long id)
|
||||||
|
{
|
||||||
|
selectForUpdateInvocations.add(id);
|
||||||
|
return enrollmentTable.get(id);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public List<TrainEnrollment> selectEnrollmentsForUpdateByPlanId(String planId)
|
||||||
|
{
|
||||||
|
planEnrollmentsForUpdateInvocations.add(planId);
|
||||||
|
List<TrainEnrollment> 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)
|
public int updateEnrollmentStatus(Long id, Integer status, String remark, String op, Date time)
|
||||||
{
|
{
|
||||||
TrainEnrollment e = enrollmentTable.get(id);
|
TrainEnrollment e = enrollmentTable.get(id);
|
||||||
@@ -522,7 +549,7 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
assertTrue("batchInsertTrainEnrollment 必须固定为 0",
|
assertTrue("batchInsertTrainEnrollment 必须固定为 0",
|
||||||
batchSnippet.contains("#{item.sourceType}, 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();
|
TrainEnrollment item1 = new TrainEnrollment();
|
||||||
item1.setPlanId("PLAN-001");
|
item1.setPlanId("PLAN-001");
|
||||||
item1.setStudentId("STU-001");
|
item1.setStudentId("STU-001");
|
||||||
@@ -534,9 +561,36 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
item2.setEnrollmentStatus(1); // 恶意输入1
|
item2.setEnrollmentStatus(1); // 恶意输入1
|
||||||
|
|
||||||
List<TrainEnrollment> list = Arrays.asList(item1, item2);
|
List<TrainEnrollment> list = Arrays.asList(item1, item2);
|
||||||
for (TrainEnrollment item : list) item.setEnrollmentStatus(0);
|
final List<TrainEnrollment> captured = new ArrayList<>();
|
||||||
assertEquals(Integer.valueOf(0), item1.getEnrollmentStatus());
|
|
||||||
assertEquals(Integer.valueOf(0), item2.getEnrollmentStatus());
|
TrainEnrollmentMapper capturingMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||||
|
TrainEnrollmentMapper.class.getClassLoader(),
|
||||||
|
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("batchInsertTrainEnrollment".equals(method.getName()))
|
||||||
|
{
|
||||||
|
List<TrainEnrollment> passed = (List<TrainEnrollment>) 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);
|
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<TrainEnrollment> captured = new ArrayList<>();
|
||||||
|
TrainEnrollmentMapper capturingMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||||
|
TrainEnrollmentMapper.class.getClassLoader(),
|
||||||
|
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||||
|
(proxy, method, args) -> {
|
||||||
|
if ("batchInsertTrainEnrollment".equals(method.getName()))
|
||||||
|
{
|
||||||
|
List<TrainEnrollment> passed = (List<TrainEnrollment>) 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
|
// Helper Methods
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -962,6 +1219,33 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
return r;
|
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()
|
private TrainEnrollmentMapper createMockEnrollmentMapper()
|
||||||
{
|
{
|
||||||
return (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
return (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||||
@@ -970,6 +1254,23 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
(proxy, method, args) -> {
|
(proxy, method, args) -> {
|
||||||
String name = method.getName();
|
String name = method.getName();
|
||||||
if ("selectTrainEnrollmentById".equals(name)) return enrollmentTable.get(args[0]);
|
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<TrainEnrollment> 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))
|
if ("confirmEnrollmentReport".equals(name))
|
||||||
{
|
{
|
||||||
Long eid = (Long) args[0];
|
Long eid = (Long) args[0];
|
||||||
@@ -1042,10 +1343,18 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
TrainClassPlanMapper.class.getClassLoader(),
|
TrainClassPlanMapper.class.getClassLoader(),
|
||||||
new Class<?>[]{TrainClassPlanMapper.class},
|
new Class<?>[]{TrainClassPlanMapper.class},
|
||||||
(proxy, method, args) -> {
|
(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]);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1080,6 +1389,25 @@ public class PackageBCoreBusinessBehaviorTest
|
|||||||
checkinTable.put(id, c);
|
checkinTable.put(id, c);
|
||||||
return 1;
|
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))
|
if ("updateTrainStudentCheckin".equals(name))
|
||||||
{
|
{
|
||||||
TrainStudentCheckin c = (TrainStudentCheckin) args[0];
|
TrainStudentCheckin c = (TrainStudentCheckin) args[0];
|
||||||
|
|||||||
+21
@@ -308,6 +308,27 @@ public class PlanEnrollmentCompletionIntegrationTest
|
|||||||
return enrollmentStore.get(enrollmentId);
|
return enrollmentStore.get(enrollmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public TrainEnrollment selectTrainEnrollmentByIdForUpdate(Long enrollmentId)
|
||||||
|
{
|
||||||
|
return enrollmentStore.get(enrollmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TrainEnrollment> selectEnrollmentsForUpdateByPlanId(String planId)
|
||||||
|
{
|
||||||
|
List<TrainEnrollment> 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
|
@Override
|
||||||
public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId)
|
public TrainEnrollment selectTrainEnrollmentByPlanIdAndStudentId(String planId, String studentId)
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ public class ReportActionContractTest
|
|||||||
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
|
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable
|
||||||
{
|
{
|
||||||
String name = method.getName();
|
String name = method.getName();
|
||||||
if ("selectTrainEnrollmentById".equals(name))
|
if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByIdForUpdate".equals(name))
|
||||||
{
|
{
|
||||||
return enrollments.get((Long) args[0]);
|
return enrollments.get((Long) args[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user