测试住宿:覆盖跨计划床位时间冲突与并发分配

This commit is contained in:
wangzhibiao committed 2026-09-21 16:14:27 +08:00
1 parent fbfec1a2ec
commit b0917fc36f
5 files changed
+857 -2

No files matched your search

@@ -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);
@@ -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<String, com.ruoyi.train.domain.TrainClassPlan> 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)
{
@@ -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<String, TrainDormitoryInfo> dormStore;
private Map<Long, TrainEnrollment> enrollmentStore;
private Map<String, TrainClassPlan> planStore;
private Map<String, ReentrantLock> dormLocks;
private List<String> lastLockedDormIds;
private ThreadLocal<List<ReentrantLock>> heldLocks;
private volatile boolean concurrencyLockEnabled = false;
private SimpleDateFormat sdf;
@org.junit.After
public void tearDown()
{
releaseCurrentThreadLocks();
}
private void releaseCurrentThreadLocks()
{
if (heldLocks != null)
{
List<ReentrantLock> 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<String> 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<Long, TrainAccommodation> store = new ConcurrentHashMap<>();
private Map<Long, TrainEnrollment> enrollmentStore;
private Map<String, TrainClassPlan> planStore;
private long idSeq = 1000L;
public void setContext(Map<Long, TrainEnrollment> eStore, Map<String, TrainClassPlan> 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<TrainAccommodation> selectAccommodationHistoryByEnrollmentId(Long enrollmentId)
{
return Collections.emptyList();
}
@Override
public List<TrainAccommodation> selectTrainAccommodationList(TrainAccommodation accommodation)
{
return new ArrayList<>(store.values());
}
@Override
public List<TrainAccommodation> 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;
}
}
}
@@ -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)
{
@@ -334,6 +334,12 @@ public class DormitoryServiceTest
return store.get(dormId);
}
@Override
public TrainDormitoryInfo selectTrainDormitoryInfoForUpdate(String dormId)
{
return store.get(dormId);
}
@Override
public List<TrainDormitoryInfo> selectTrainDormitoryInfoList(TrainDormitoryInfo query)
{