测试住宿:覆盖跨计划床位时间冲突与并发分配
This commit is contained in:
1 parent
fbfec1a2ec
commit
b0917fc36f
5 files changed
+857
-2
No files matched your search
+16
-1
@@ -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);
|
||||
|
||||
+53
-1
@@ -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)
|
||||
{
|
||||
|
||||
+756
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
{
|
||||
|
||||
+6
@@ -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)
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user