diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java index f1befabcf..fe6499c3d 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java @@ -127,6 +127,7 @@ public class DmsConsistencyHardeningPackageA1Test { setPrivateField(accommodationService, "trainAccommodationMapper", createMockAccommodationMapper()); setPrivateField(accommodationService, "trainEnrollmentMapper", createMockEnrollmentMapper()); setPrivateField(accommodationService, "trainDormitoryInfoMapper", createMockDormitoryInfoMapper()); + setPrivateField(accommodationService, "trainClassPlanMapper", createMockClassPlanMapper()); orchestrator = new AccessAuthorizationOrchestrator( createMockEnrollmentMapper(), @@ -756,6 +757,20 @@ public class DmsConsistencyHardeningPackageA1Test { && a.getAccommodationStatus() != null && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1)) .count(); } + if ("countOverlappingActiveBedReservations".equals(name)) { + String dormId = (String) args[0]; + String bedNo = (String) args[1]; + Long excludeId = (Long) args[4]; + if (dormId == null || bedNo == null || bedNo.trim().isEmpty()) return 0; + return (int) accommodationDb.values().stream() + .filter(a -> (excludeId == null || !excludeId.equals(a.getAccommodationId())) + && dormId.equals(a.getDormId()) && bedNo.equals(a.getBedNo()) + && a.getAccommodationStatus() != null && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1)) + .count(); + } + if ("countUnresolvedActiveBedReservations".equals(name)) { + return 0; + } if ("countActiveByDormId".equals(name)) { String dormId = (String) args[0]; return (int) accommodationDb.values().stream() @@ -952,7 +967,7 @@ public class DmsConsistencyHardeningPackageA1Test { getClass().getClassLoader(), new Class[]{TrainDormitoryInfoMapper.class}, (proxy, method, args) -> { - if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) { + if ("selectTrainDormitoryInfoByDormId".equals(method.getName()) || "selectTrainDormitoryInfoForUpdate".equals(method.getName())) { String dormId = (String) args[0]; TrainDormitoryInfo d = new TrainDormitoryInfo(); d.setDormId(dormId); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java index d0661e7e3..f8d0b206c 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java @@ -46,7 +46,7 @@ public class AccommodationServiceTest TrainDormitoryInfoMapper.class.getClassLoader(), new Class[]{TrainDormitoryInfoMapper.class}, (proxy, method, args) -> { - if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) + if ("selectTrainDormitoryInfoByDormId".equals(method.getName()) || "selectTrainDormitoryInfoForUpdate".equals(method.getName())) { return dormStore.get(args[0]); } @@ -68,9 +68,30 @@ public class AccommodationServiceTest } ); + Map planStore = new HashMap<>(); + com.ruoyi.train.domain.TrainClassPlan defaultPlan = new com.ruoyi.train.domain.TrainClassPlan(); + defaultPlan.setPlanId("PLAN_2026_01"); + defaultPlan.setStartTime(new Date(System.currentTimeMillis() - 86400000L)); + defaultPlan.setEndTime(new Date(System.currentTimeMillis() + 86400000L * 7)); + planStore.put(defaultPlan.getPlanId(), defaultPlan); + + com.ruoyi.train.mapper.TrainClassPlanMapper planMapperProxy = (com.ruoyi.train.mapper.TrainClassPlanMapper) Proxy.newProxyInstance( + com.ruoyi.train.mapper.TrainClassPlanMapper.class.getClassLoader(), + new Class[]{com.ruoyi.train.mapper.TrainClassPlanMapper.class}, + (proxy, method, args) -> { + if ("selectTrainClassPlanByPlanId".equals(method.getName())) + { + return planStore.get(args[0]); + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + setField(accommodationService, "trainAccommodationMapper", mockAccommodationMapper); setField(accommodationService, "trainDormitoryInfoMapper", dormMapperProxy); setField(accommodationService, "trainEnrollmentMapper", enrollMapperProxy); + setField(accommodationService, "trainClassPlanMapper", planMapperProxy); // 初始化预设基础数据 TrainDormitoryInfo dorm101 = new TrainDormitoryInfo(); @@ -634,6 +655,37 @@ public class AccommodationServiceTest return count; } + @Override + public int countOverlappingActiveBedReservations(String dormId, String bedNo, Date targetStartTime, Date targetEndTime, Long excludeAccommodationId) + { + if (dormId == null || bedNo == null || bedNo.trim().isEmpty()) { + return 0; + } + int count = 0; + for (TrainAccommodation a : store.values()) + { + if (excludeAccommodationId != null && excludeAccommodationId.equals(a.getAccommodationId())) + { + continue; + } + if (dormId.equals(a.getDormId()) && bedNo.equals(a.getBedNo())) + { + if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())) + { + count++; + } + } + } + return count; + } + + @Override + public int countUnresolvedActiveBedReservations(String dormId, String bedNo, Long excludeAccommodationId) + { + return 0; + } + @Override public int insertTrainAccommodation(TrainAccommodation accommodation) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/BedOccupancyModelCTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/BedOccupancyModelCTest.java new file mode 100644 index 000000000..9034f9685 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/BedOccupancyModelCTest.java @@ -0,0 +1,756 @@ +package com.ruoyi.train.service.impl; + +import static org.junit.Assert.assertEquals; +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.Proxy; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; +import org.junit.Before; +import org.junit.Test; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.train.domain.TrainAccommodation; +import com.ruoyi.train.domain.TrainClassPlan; +import com.ruoyi.train.domain.TrainDormitoryInfo; +import com.ruoyi.train.domain.TrainEnrollment; +import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainClassPlanMapper; +import com.ruoyi.train.mapper.TrainDormitoryInfoMapper; +import com.ruoyi.train.mapper.TrainEnrollmentMapper; + +/** + * PACKAGE A.2 — Model C 床位占用冲突行为测试 (B1 ~ B10) + */ +public class BedOccupancyModelCTest +{ + private TrainAccommodationServiceImpl accommodationService; + private ModelCTestAccommodationMapper mockAccMapper; + private Map dormStore; + private Map enrollmentStore; + private Map planStore; + private Map dormLocks; + private List lastLockedDormIds; + private ThreadLocal> heldLocks; + private volatile boolean concurrencyLockEnabled = false; + private SimpleDateFormat sdf; + + @org.junit.After + public void tearDown() + { + releaseCurrentThreadLocks(); + } + + private void releaseCurrentThreadLocks() + { + if (heldLocks != null) + { + List locks = heldLocks.get(); + for (ReentrantLock lock : locks) + { + while (lock.isHeldByCurrentThread() && lock.getHoldCount() > 0) + { + lock.unlock(); + } + } + locks.clear(); + } + } + + @Before + public void setUp() throws Exception + { + accommodationService = new TrainAccommodationServiceImpl(); + mockAccMapper = new ModelCTestAccommodationMapper(); + dormStore = new ConcurrentHashMap<>(); + enrollmentStore = new ConcurrentHashMap<>(); + planStore = new ConcurrentHashMap<>(); + dormLocks = new ConcurrentHashMap<>(); + lastLockedDormIds = Collections.synchronizedList(new ArrayList<>()); + heldLocks = ThreadLocal.withInitial(ArrayList::new); + concurrencyLockEnabled = false; + mockAccMapper.setContext(enrollmentStore, planStore); + sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + + TrainDormitoryInfoMapper dormMapperProxy = (TrainDormitoryInfoMapper) Proxy.newProxyInstance( + TrainDormitoryInfoMapper.class.getClassLoader(), + new Class[]{TrainDormitoryInfoMapper.class}, + (proxy, method, args) -> { + String dormId = (String) args[0]; + if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) + { + return dormStore.get(dormId); + } + if ("selectTrainDormitoryInfoForUpdate".equals(method.getName())) + { + lastLockedDormIds.add(dormId); + if (concurrencyLockEnabled) + { + ReentrantLock lock = dormLocks.computeIfAbsent(dormId, k -> new ReentrantLock()); + lock.lock(); + heldLocks.get().add(lock); + } + return dormStore.get(dormId); + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + TrainEnrollmentMapper enrollMapperProxy = (TrainEnrollmentMapper) Proxy.newProxyInstance( + TrainEnrollmentMapper.class.getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainEnrollmentById".equals(method.getName())) + { + return enrollmentStore.get(args[0]); + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + TrainClassPlanMapper planMapperProxy = (TrainClassPlanMapper) Proxy.newProxyInstance( + TrainClassPlanMapper.class.getClassLoader(), + new Class[]{TrainClassPlanMapper.class}, + (proxy, method, args) -> { + if ("selectTrainClassPlanByPlanId".equals(method.getName())) + { + return planStore.get(args[0]); + } + if (method.getReturnType().equals(int.class)) return 0; + return null; + } + ); + + setField(accommodationService, "trainAccommodationMapper", mockAccMapper); + setField(accommodationService, "trainDormitoryInfoMapper", dormMapperProxy); + setField(accommodationService, "trainEnrollmentMapper", enrollMapperProxy); + setField(accommodationService, "trainClassPlanMapper", planMapperProxy); + + // 初始化基准宿舍 + TrainDormitoryInfo dorm101 = new TrainDormitoryInfo(); + dorm101.setDormId("DORM_101"); + dorm101.setDormBuilding("培训1号楼"); + dorm101.setDormFloor("1"); + dorm101.setDormRoom("101"); + dorm101.setCapacity(2); + dorm101.setDormStatus(1); + dormStore.put(dorm101.getDormId(), dorm101); + + TrainDormitoryInfo dorm102 = new TrainDormitoryInfo(); + dorm102.setDormId("DORM_102"); + dorm102.setDormBuilding("培训1号楼"); + dorm102.setDormFloor("1"); + dorm102.setDormRoom("102"); + dorm102.setCapacity(2); + dorm102.setDormStatus(1); + dormStore.put(dorm102.getDormId(), dorm102); + + // 初始化基准培训计划 + TrainClassPlan planA = new TrainClassPlan(); + planA.setPlanId("PLAN_A"); + planA.setPlanName("计划A"); + planA.setStartTime(sdf.parse("2026-10-01 08:00:00")); + planA.setEndTime(sdf.parse("2026-10-10 18:00:00")); + planStore.put(planA.getPlanId(), planA); + + TrainClassPlan planB = new TrainClassPlan(); + planB.setPlanId("PLAN_B"); + planB.setPlanName("计划B-重叠"); + planB.setStartTime(sdf.parse("2026-10-05 08:00:00")); + planB.setEndTime(sdf.parse("2026-10-15 18:00:00")); + planStore.put(planB.getPlanId(), planB); + + TrainClassPlan planC = new TrainClassPlan(); + planC.setPlanId("PLAN_C"); + planC.setPlanName("计划C-完全不重叠"); + planC.setStartTime(sdf.parse("2026-10-12 08:00:00")); + planC.setEndTime(sdf.parse("2026-10-20 18:00:00")); + planStore.put(planC.getPlanId(), planC); + + TrainClassPlan planTouch = new TrainClassPlan(); + planTouch.setPlanId("PLAN_TOUCH"); + planTouch.setPlanName("计划Touch-边界接触"); + planTouch.setStartTime(sdf.parse("2026-10-10 18:00:00")); // 与 Plan A 边界重合 + planTouch.setEndTime(sdf.parse("2026-10-18 18:00:00")); + planStore.put(planTouch.getPlanId(), planTouch); + } + + private void setField(Object target, String fieldName, Object value) throws Exception + { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private TrainEnrollment createEnrollment(Long enrollmentId, String planId, String studentId) + { + TrainEnrollment e = new TrainEnrollment(); + e.setEnrollmentId(enrollmentId); + e.setPlanId(planId); + e.setStudentId(studentId); + enrollmentStore.put(enrollmentId, e); + return e; + } + + /** + * B1: same dorm, same bed, same plan -> conflict + */ + @Test + public void testB1_SamePlanConflict() + { + createEnrollment(101L, "PLAN_A", "STU_101"); + createEnrollment(102L, "PLAN_A", "STU_102"); + + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(101L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + assertEquals(1, accommodationService.insertTrainAccommodation(acc1)); + + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(102L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("同计划同宿舍同床位必须拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("BED_ALREADY_OCCUPIED")); + } + } + + /** + * B2: same dorm, same bed, different plans, time overlap -> conflict + */ + @Test + public void testB2_DifferentPlansTimeOverlapConflict() + { + createEnrollment(201L, "PLAN_A", "STU_201"); + createEnrollment(202L, "PLAN_B", "STU_202"); // Plan B [10-05, 10-15] 与 Plan A [10-01, 10-10] 重叠 + + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(201L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + assertEquals(1, accommodationService.insertTrainAccommodation(acc1)); + + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(202L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("跨计划时间重叠占用必须拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("BED_ALREADY_OCCUPIED")); + } + } + + /** + * B3: same dorm, same bed, different plans, completely non-overlapping -> allowed + */ + @Test + public void testB3_DifferentPlansNonOverlappingAllowed() + { + createEnrollment(301L, "PLAN_A", "STU_301"); // [10-01, 10-10] + createEnrollment(302L, "PLAN_C", "STU_302"); // [10-12, 10-20] 完全不重叠 + + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(301L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + assertEquals(1, accommodationService.insertTrainAccommodation(acc1)); + + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(302L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("1号床"); + int rows = accommodationService.insertTrainAccommodation(acc2); + assertEquals(1, rows); + assertNotNull(acc2.getAccommodationId()); + } + + /** + * B4: plan A.end == plan B.start -> allowed (边界接触不冲突) + */ + @Test + public void testB4_BoundaryTouchAllowed() + { + createEnrollment(401L, "PLAN_A", "STU_401"); // end = 2026-10-10 18:00:00 + createEnrollment(402L, "PLAN_TOUCH", "STU_402"); // start = 2026-10-10 18:00:00 + + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(401L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + assertEquals(1, accommodationService.insertTrainAccommodation(acc1)); + + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(402L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("1号床"); + int rows = accommodationService.insertTrainAccommodation(acc2); + assertEquals("边界相切 [start, end) 属于非正长度交集,必须允许分配", 1, rows); + } + + /** + * B5: different bed, same room, overlap -> allowed (只受capacity约束) + */ + @Test + public void testB5_DifferentBedSameRoomCapacityGuard() + { + createEnrollment(501L, "PLAN_A", "STU_501"); + createEnrollment(502L, "PLAN_A", "STU_502"); + createEnrollment(503L, "PLAN_A", "STU_503"); + + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(501L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + assertEquals(1, accommodationService.insertTrainAccommodation(acc1)); + + // 同房间不同床位,允许 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(502L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("2号床"); + assertEquals(1, accommodationService.insertTrainAccommodation(acc2)); + + // 超过核定容量 (capacity=2),第3条被容量拦截 + TrainAccommodation acc3 = new TrainAccommodation(); + acc3.setEnrollmentId(503L); + acc3.setDormId("DORM_101"); + acc3.setBedNo("3号床"); + try + { + accommodationService.insertTrainAccommodation(acc3); + fail("超过容量必须拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("宿舍容量已满")); + } + } + + /** + * B6: same bed, existing status=2 -> allowed (已退宿床位可立即重用) + */ + @Test + public void testB6_SameBedCheckedOutAllowed() + { + createEnrollment(601L, "PLAN_A", "STU_601"); + createEnrollment(602L, "PLAN_A", "STU_602"); + + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(601L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc1); + + // 办理退宿 + accommodationService.checkOut(acc1.getAccommodationId(), "operator"); + + // 另一学员申请同一床位,允许 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(602L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("1号床"); + int rows = accommodationService.insertTrainAccommodation(acc2); + assertEquals(1, rows); + } + + /** + * B7: target plan window invalid -> BED_OCCUPANCY_WINDOW_INVALID + */ + @Test + public void testB7_TargetPlanWindowInvalidFailClosed() throws Exception + { + // 1. 计划不存在 + createEnrollment(701L, "NON_EXIST_PLAN", "STU_701"); + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(701L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc1); + fail("计划不存在应抛出 BED_OCCUPANCY_WINDOW_INVALID"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("BED_OCCUPANCY_WINDOW_INVALID")); + } + + // 2. 计划时间为空 + TrainClassPlan nullTimePlan = new TrainClassPlan(); + nullTimePlan.setPlanId("PLAN_NULL_TIME"); + nullTimePlan.setPlanName("时间为空计划"); + planStore.put(nullTimePlan.getPlanId(), nullTimePlan); + + createEnrollment(702L, "PLAN_NULL_TIME", "STU_702"); + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(702L); + acc2.setDormId("DORM_101"); + acc2.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc2); + fail("计划时间为空应抛出 BED_OCCUPANCY_WINDOW_INVALID"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("BED_OCCUPANCY_WINDOW_INVALID")); + } + + // 3. 计划 end_time <= start_time + TrainClassPlan invertedPlan = new TrainClassPlan(); + invertedPlan.setPlanId("PLAN_INVERTED"); + invertedPlan.setStartTime(sdf.parse("2026-10-10 18:00:00")); + invertedPlan.setEndTime(sdf.parse("2026-10-01 08:00:00")); // 倒置 + planStore.put(invertedPlan.getPlanId(), invertedPlan); + + createEnrollment(703L, "PLAN_INVERTED", "STU_703"); + TrainAccommodation acc3 = new TrainAccommodation(); + acc3.setEnrollmentId(703L); + acc3.setDormId("DORM_101"); + acc3.setBedNo("1号床"); + try + { + accommodationService.insertTrainAccommodation(acc3); + fail("计划时间倒置应抛出 BED_OCCUPANCY_WINDOW_INVALID"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("BED_OCCUPANCY_WINDOW_INVALID")); + } + } + + /** + * B8: existing active accommodation 无法解析plan window -> fail closed + */ + @Test + public void testB8_ExistingActiveAccommodationUnresolvedFailClosed() throws Exception + { + // 预设一条 active accommodation,但其 enrollment/plan 窗口无法解析 + TrainAccommodation badAcc = new TrainAccommodation(); + badAcc.setEnrollmentId(888L); // 不在 enrollmentStore 中 + badAcc.setDormId("DORM_101"); + badAcc.setBedNo("1号床"); + badAcc.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + mockAccMapper.insertDirect(badAcc); + + // 新请求尝试分配同一床位 + createEnrollment(801L, "PLAN_A", "STU_801"); + TrainAccommodation targetAcc = new TrainAccommodation(); + targetAcc.setEnrollmentId(801L); + targetAcc.setDormId("DORM_101"); + targetAcc.setBedNo("1号床"); + + try + { + accommodationService.insertTrainAccommodation(targetAcc); + fail("已有活跃住宿无法解析时间窗口时,必须 Fail Closed 拦截并报错 BED_OCCUPANCY_WINDOW_UNRESOLVED"); + } + catch (ServiceException e) + { + assertTrue("必须提示 BED_OCCUPANCY_WINDOW_UNRESOLVED", e.getMessage().contains("BED_OCCUPANCY_WINDOW_UNRESOLVED")); + } + } + + /** + * B9: 两线程并发:same dorm, same bed, overlap -> 最多1成功 + */ + @Test + public void testB9_ConcurrentAssignmentRaceAtMostOneSucceeds() throws Exception + { + concurrencyLockEnabled = true; + createEnrollment(901L, "PLAN_A", "STU_901"); + createEnrollment(902L, "PLAN_A", "STU_902"); + + int threadCount = 2; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + List failReasons = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < threadCount; i++) + { + final long enrollId = 901L + i; + executor.submit(() -> { + try + { + startLatch.await(); // 同时起跑 + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(enrollId); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + successCount.incrementAndGet(); + } + catch (Exception e) + { + failCount.incrementAndGet(); + failReasons.add(e.getMessage()); + } + finally + { + releaseCurrentThreadLocks(); + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + doneLatch.await(); + executor.shutdown(); + + assertEquals("并发分配相同房间/床位/重叠计划,严格最多1条成功", 1, successCount.get()); + assertEquals("另1条必须被冲突拦截", 1, failCount.get()); + assertTrue("失败原因必须是床位冲突", failReasons.get(0).contains("BED_ALREADY_OCCUPIED")); + } + + /** + * B10: 调房到重叠已占床位 -> conflict + */ + @Test + public void testB10_TransferToOccupiedBedConflict() + { + createEnrollment(1001L, "PLAN_A", "STU_1001"); + createEnrollment(1002L, "PLAN_B", "STU_1002"); // Plan B 与 Plan A 重叠 + + // 学员1入住 DORM_101 1号床 + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setEnrollmentId(1001L); + acc1.setDormId("DORM_101"); + acc1.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc1); + + // 学员2入住 DORM_102 1号床 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setEnrollmentId(1002L); + acc2.setDormId("DORM_102"); + acc2.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc2); + + // 学员2试图调房到 DORM_101 1号床 (已被学员1重叠计划占用) + try + { + accommodationService.transfer(acc2.getAccommodationId(), "DORM_101", "1号床", "admin"); + fail("调房至已占床位必须拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("BED_ALREADY_OCCUPIED")); + } + } + + // ======================== 测试专用内存 Mapper 实现 ======================== + + private static class ModelCTestAccommodationMapper implements TrainAccommodationMapper + { + private final Map store = new ConcurrentHashMap<>(); + private Map enrollmentStore; + private Map planStore; + private long idSeq = 1000L; + + public void setContext(Map eStore, Map pStore) + { + this.enrollmentStore = eStore; + this.planStore = pStore; + } + + public void insertDirect(TrainAccommodation a) + { + a.setAccommodationId(++idSeq); + store.put(a.getAccommodationId(), a); + } + + @Override + public TrainAccommodation selectTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.get(accommodationId); + } + + @Override + public TrainAccommodation selectTrainAccommodationByEnrollmentId(Long enrollmentId) + { + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId())) return a; + } + return null; + } + + @Override + public TrainAccommodation selectActiveAccommodationByEnrollmentId(Long enrollmentId) + { + for (TrainAccommodation a : store.values()) + { + if (enrollmentId.equals(a.getEnrollmentId()) && + (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))) + { + return a; + } + } + return null; + } + + @Override + public List selectAccommodationHistoryByEnrollmentId(Long enrollmentId) + { + return Collections.emptyList(); + } + + @Override + public List selectTrainAccommodationList(TrainAccommodation accommodation) + { + return new ArrayList<>(store.values()); + } + + @Override + public List selectActiveAccommodationsByPlanId(String planId) + { + return Collections.emptyList(); + } + + @Override + public int countActiveByDormId(String dormId) + { + int count = 0; + for (TrainAccommodation a : store.values()) + { + if (dormId.equals(a.getDormId()) && + (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus()))) + { + count++; + } + } + return count; + } + + @Override + public int countActiveByDormIdAndBedNo(String dormId, String bedNo, Long excludeAccommodationId) + { + return 0; + } + + @Override + public int countOverlappingActiveBedReservations(String dormId, String bedNo, Date targetStartTime, Date targetEndTime, Long excludeAccommodationId) + { + if (dormId == null || bedNo == null || bedNo.trim().isEmpty()) return 0; + int count = 0; + for (TrainAccommodation a : store.values()) + { + if (excludeAccommodationId != null && excludeAccommodationId.equals(a.getAccommodationId())) continue; + if (!dormId.equals(a.getDormId()) || !bedNo.equals(a.getBedNo())) continue; + if (!TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + && !TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())) continue; + + // 读取已有 plan + TrainEnrollment e = enrollmentStore.get(a.getEnrollmentId()); + if (e == null || e.getPlanId() == null) continue; + TrainClassPlan p = planStore.get(e.getPlanId()); + if (p == null || p.getStartTime() == null || p.getEndTime() == null) continue; + + // Model C: start_time < targetEndTime && end_time > targetStartTime + if (p.getStartTime().before(targetEndTime) && p.getEndTime().after(targetStartTime)) + { + count++; + } + } + return count; + } + + @Override + public int countUnresolvedActiveBedReservations(String dormId, String bedNo, Long excludeAccommodationId) + { + if (dormId == null || bedNo == null || bedNo.trim().isEmpty()) return 0; + int count = 0; + for (TrainAccommodation a : store.values()) + { + if (excludeAccommodationId != null && excludeAccommodationId.equals(a.getAccommodationId())) continue; + if (!dormId.equals(a.getDormId()) || !bedNo.equals(a.getBedNo())) continue; + if (!TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + && !TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())) continue; + + TrainEnrollment e = enrollmentStore.get(a.getEnrollmentId()); + if (e == null || e.getPlanId() == null) + { + count++; + continue; + } + TrainClassPlan p = planStore.get(e.getPlanId()); + if (p == null || p.getStartTime() == null || p.getEndTime() == null || !p.getEndTime().after(p.getStartTime())) + { + count++; + } + } + return count; + } + + @Override + public synchronized int insertTrainAccommodation(TrainAccommodation accommodation) + { + accommodation.setAccommodationId(++idSeq); + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public synchronized int updateTrainAccommodation(TrainAccommodation accommodation) + { + store.put(accommodation.getAccommodationId(), accommodation); + return 1; + } + + @Override + public synchronized int updateAccommodationStatus(Long accommodationId, Integer accommodationStatus, String updateBy, Date updateTime) + { + TrainAccommodation a = store.get(accommodationId); + if (a != null) + { + a.setAccommodationStatus(accommodationStatus); + return 1; + } + return 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationId(Long accommodationId) + { + return store.remove(accommodationId) != null ? 1 : 0; + } + + @Override + public int deleteTrainAccommodationByAccommodationIds(Long[] accommodationIds) + { + return 0; + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java index 6aff46bec..138fdf392 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java @@ -581,6 +581,32 @@ public class DormAuthAlignmentTest return count; } + @Override + public int countOverlappingActiveBedReservations(String dormId, String bedNo, Date targetStartTime, Date targetEndTime, Long excludeAccommodationId) + { + if (dormId == null || bedNo == null || bedNo.trim().isEmpty()) return 0; + int count = 0; + for (TrainAccommodation a : store.values()) + { + if (excludeAccommodationId != null && excludeAccommodationId.equals(a.getAccommodationId())) continue; + if (dormId.equals(a.getDormId()) && bedNo.equals(a.getBedNo())) + { + if (TrainAccommodation.STATUS_PREALLOCATED.equals(a.getAccommodationStatus()) + || TrainAccommodation.STATUS_CHECKED_IN.equals(a.getAccommodationStatus())) + { + count++; + } + } + } + return count; + } + + @Override + public int countUnresolvedActiveBedReservations(String dormId, String bedNo, Long excludeAccommodationId) + { + return 0; + } + @Override public int insertTrainAccommodation(TrainAccommodation accommodation) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormitoryServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormitoryServiceTest.java index 1ee8a2645..a658c9bf8 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormitoryServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormitoryServiceTest.java @@ -334,6 +334,12 @@ public class DormitoryServiceTest return store.get(dormId); } + @Override + public TrainDormitoryInfo selectTrainDormitoryInfoForUpdate(String dormId) + { + return store.get(dormId); + } + @Override public List selectTrainDormitoryInfoList(TrainDormitoryInfo query) {