测试门禁:覆盖外部不确定性与并发派发场景
This commit is contained in:
1 parent
a12b2e3543
commit
0a637bfbbc
7 files changed
+1134
-84
No files matched your search
+32
-33
@@ -89,54 +89,53 @@ public class AccommodationAndAuthApiControllerContractTest
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 TrainDormAuthController 的现有路径兼容性契约
|
||||
* 验证 TrainAccessManagementController 统一门禁契约与权限边界 (MIN-08 / MIN-09)
|
||||
*/
|
||||
@Test
|
||||
public void testDormAuthControllerContract() throws Exception
|
||||
{
|
||||
Class<TrainDormAuthController> clazz = TrainDormAuthController.class;
|
||||
// 1. 验证旧 Controller 彻底下线 (MIN-08)
|
||||
try {
|
||||
Class.forName("com.ruoyi.web.controller.train.TrainDormAuthController");
|
||||
fail("TrainDormAuthController 必须彻底移除 (MIN-08)");
|
||||
} catch (ClassNotFoundException expected) {}
|
||||
|
||||
// 2. 验证 TrainAccessManagementController 统一对外暴露所有读写端点
|
||||
Class<TrainAccessManagementController> clazz = TrainAccessManagementController.class;
|
||||
RequestMapping classMapping = clazz.getAnnotation(RequestMapping.class);
|
||||
assertNotNull(classMapping);
|
||||
assertEquals("/train/auth", classMapping.value()[0]);
|
||||
assertEquals("/train/access", classMapping.value()[0]);
|
||||
|
||||
// 1. GET /train/auth/list
|
||||
Method listMethod = clazz.getMethod("list", TrainDormAuth.class);
|
||||
// GET /train/access/auth/list
|
||||
Method listMethod = clazz.getMethod("listAuth", TrainDormAuth.class);
|
||||
assertNotNull(listMethod.getAnnotation(GetMapping.class));
|
||||
assertEquals("/list", listMethod.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertEquals("@ss.hasPermi('system:auth:list')", listMethod.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("/auth/list", listMethod.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertEquals("@ss.hasPermi('train:access:query')", listMethod.getAnnotation(PreAuthorize.class).value());
|
||||
|
||||
// 2. GET /train/auth/{authId}
|
||||
Method infoMethod = clazz.getMethod("getInfo", Long.class);
|
||||
// GET /train/access/auth/{authId}
|
||||
Method infoMethod = clazz.getMethod("getAuthDetail", Long.class);
|
||||
assertNotNull(infoMethod.getAnnotation(GetMapping.class));
|
||||
assertEquals("/{authId}", infoMethod.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertEquals("@ss.hasPermi('system:auth:query')", infoMethod.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("/auth/{authId}", infoMethod.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertEquals("@ss.hasPermi('train:access:query')", infoMethod.getAnnotation(PreAuthorize.class).value());
|
||||
|
||||
// 3. GET /train/auth/accommodation/{accommodationId}
|
||||
Method accInfoMethod = clazz.getMethod("getInfoByAccommodationId", Long.class);
|
||||
// GET /train/access/auth/accommodation/{accommodationId}
|
||||
Method accInfoMethod = clazz.getMethod("getAuthByAccommodationId", Long.class);
|
||||
assertNotNull(accInfoMethod.getAnnotation(GetMapping.class));
|
||||
assertEquals("/accommodation/{accommodationId}", accInfoMethod.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertEquals("@ss.hasPermi('system:auth:query')", accInfoMethod.getAnnotation(PreAuthorize.class).value());
|
||||
assertEquals("/auth/accommodation/{accommodationId}", accInfoMethod.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertEquals("@ss.hasPermi('train:access:query')", accInfoMethod.getAnnotation(PreAuthorize.class).value());
|
||||
|
||||
// 4. 验证 Package A 安全架构:TrainDormAuthController 彻底移除写端点 (add, edit, revoke, remove)
|
||||
try {
|
||||
clazz.getMethod("add", TrainDormAuth.class);
|
||||
fail("TrainDormAuthController 不得存在 POST add 写端点");
|
||||
} catch (NoSuchMethodException expected) {}
|
||||
// GET /train/access/capabilities
|
||||
Method capMethod = clazz.getMethod("getCapabilities");
|
||||
assertNotNull(capMethod.getAnnotation(GetMapping.class));
|
||||
assertEquals("/capabilities", capMethod.getAnnotation(GetMapping.class).value()[0]);
|
||||
assertEquals("@ss.hasPermi('train:access:query')", capMethod.getAnnotation(PreAuthorize.class).value());
|
||||
|
||||
try {
|
||||
clazz.getMethod("edit", TrainDormAuth.class);
|
||||
fail("TrainDormAuthController 不得存在 PUT edit 写端点");
|
||||
} catch (NoSuchMethodException expected) {}
|
||||
// 3. 验证 MIN-09 权限最小化:issue 与 revoke 端点收敛为 train:access:credential
|
||||
Method issueMethod = clazz.getMethod("issueCredential", Long.class);
|
||||
assertEquals("@ss.hasPermi('train:access:credential')", issueMethod.getAnnotation(PreAuthorize.class).value());
|
||||
|
||||
try {
|
||||
clazz.getMethod("revoke", Long.class);
|
||||
fail("TrainDormAuthController 不得存在 POST revoke 写端点");
|
||||
} catch (NoSuchMethodException expected) {}
|
||||
|
||||
try {
|
||||
clazz.getMethod("remove", Long[].class);
|
||||
fail("TrainDormAuthController 不得存在 DELETE remove 写端点");
|
||||
} catch (NoSuchMethodException expected) {}
|
||||
Method revokeMethod = clazz.getMethod("revokeAuth", Long.class);
|
||||
assertEquals("@ss.hasPermi('train:access:credential')", revokeMethod.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
}
|
||||
|
||||
+16
-17
@@ -6,7 +6,8 @@ import com.ruoyi.train.domain.TrainEnrollment;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.domain.TrainExternalResourceMapping;
|
||||
import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult;
|
||||
import com.ruoyi.web.controller.train.TrainDormAuthController;
|
||||
import com.ruoyi.train.integration.dms.dto.IssueResultVO;
|
||||
import com.ruoyi.web.controller.train.TrainAccessManagementController;
|
||||
import org.junit.Test;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@@ -65,25 +66,24 @@ public class CredentialOneTimeDisplaySecurityTest {
|
||||
|
||||
/**
|
||||
* 指标 2:LOG_PLAINTEXT_EXPOSURE = 0
|
||||
* 证明结果对象与关键传输类的 toString() 绝对不输出密码明文
|
||||
* 证明结果对象与关键传输类的 toString() 绝对不输出密码明文,
|
||||
* 且 MIN-05 证明 ReconciliationResult 彻底移除了 oneTimePlaintextCredential
|
||||
*/
|
||||
@Test
|
||||
public void testLogPlaintextExposureIsZero() {
|
||||
String sensitivePassword = "SecretPassword987654";
|
||||
|
||||
ReconciliationResult result = ReconciliationResult.grantRequired(1001L, 2001L, true, true, "Success");
|
||||
result.setOneTimePlaintextCredential(sensitivePassword);
|
||||
IssueResultVO vo = new IssueResultVO(1001L, sensitivePassword, "2026-09-21 00:00:00", "2026-09-30 23:59:59", "ACTIVE", "Success");
|
||||
String str = vo.toString();
|
||||
assertFalse("IssueResultVO.toString() must NOT contain plaintext password", str.contains(sensitivePassword));
|
||||
assertTrue("IssueResultVO.toString() should redact sensitive fields", str.contains("[PROTECTED]"));
|
||||
|
||||
String str = result.toString();
|
||||
assertFalse("ReconciliationResult.toString() must NOT contain plaintext password", str.contains(sensitivePassword));
|
||||
assertTrue("ReconciliationResult.toString() should redact sensitive fields", str.contains("[PROTECTED]"));
|
||||
|
||||
// 验证 transient 关键字修饰
|
||||
// MIN-05: 验证 ReconciliationResult 彻底删除了 oneTimePlaintextCredential 字段,避免日志与序列化泄露
|
||||
try {
|
||||
Field credentialField = ReconciliationResult.class.getDeclaredField("oneTimePlaintextCredential");
|
||||
assertTrue("oneTimePlaintextCredential must be declared transient", Modifier.isTransient(credentialField.getModifiers()));
|
||||
} catch (NoSuchFieldException e) {
|
||||
fail("Field oneTimePlaintextCredential must exist in ReconciliationResult");
|
||||
ReconciliationResult.class.getDeclaredField("oneTimePlaintextCredential");
|
||||
fail("Field oneTimePlaintextCredential must NOT exist in ReconciliationResult (MIN-05)");
|
||||
} catch (NoSuchFieldException expected) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ public class CredentialOneTimeDisplaySecurityTest {
|
||||
*/
|
||||
@Test
|
||||
public void testRetrievalEndpointCountIsZero() {
|
||||
Class<?> controllerClass = TrainDormAuthController.class;
|
||||
Class<?> controllerClass = TrainAccessManagementController.class;
|
||||
int retrievalEndpointCount = 0;
|
||||
List<String> leakEndpoints = new ArrayList<>();
|
||||
|
||||
@@ -144,11 +144,10 @@ public class CredentialOneTimeDisplaySecurityTest {
|
||||
*/
|
||||
@Test
|
||||
public void testAdminOneTimeDisplayBehavior() {
|
||||
ReconciliationResult result = ReconciliationResult.grantRequired(1001L, 2001L, true, true, "Success");
|
||||
result.setOneTimePlaintextCredential("123456");
|
||||
IssueResultVO vo = new IssueResultVO(1001L, "123456", "2026-09-21 00:00:00", "2026-09-30 23:59:59", "ACTIVE", "Success");
|
||||
|
||||
// 本次可读取
|
||||
assertEquals("123456", result.getOneTimePlaintextCredential());
|
||||
assertEquals("123456", vo.getOneTimePlaintextCredential());
|
||||
|
||||
// 模拟关闭/二次读取:持久化实体 TrainDormAuth 根本没有该属性
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
|
||||
+44
-30
@@ -440,40 +440,16 @@ public class DmsAccessSafetyPackageATest {
|
||||
}
|
||||
|
||||
/**
|
||||
* L. TrainDormAuthController:不存在任何写 Mapping (POST/PUT/DELETE)
|
||||
* L. TrainDormAuthController:已彻底删除 (MIN-08),统一收归至 TrainAccessManagementController
|
||||
*/
|
||||
@Test
|
||||
public void testL_TrainDormAuthController_ZeroWriteMappings() {
|
||||
Class<?> clazz = TrainDormAuthController.class;
|
||||
int writeMappingCount = 0;
|
||||
|
||||
for (Method m : clazz.getDeclaredMethods()) {
|
||||
if (m.isAnnotationPresent(PostMapping.class)) {
|
||||
// export 接口属于安全只读查询导出,其余所有 POST 必须被禁止
|
||||
if (!"export".equals(m.getName())) {
|
||||
writeMappingCount++;
|
||||
}
|
||||
}
|
||||
if (m.isAnnotationPresent(PutMapping.class)) {
|
||||
writeMappingCount++;
|
||||
}
|
||||
if (m.isAnnotationPresent(DeleteMapping.class)) {
|
||||
writeMappingCount++;
|
||||
}
|
||||
if (m.isAnnotationPresent(RequestMapping.class)) {
|
||||
RequestMapping rm = m.getAnnotation(RequestMapping.class);
|
||||
for (RequestMethod method : rm.method()) {
|
||||
if (method == RequestMethod.POST && !"export".equals(m.getName())) {
|
||||
writeMappingCount++;
|
||||
}
|
||||
if (method == RequestMethod.PUT || method == RequestMethod.DELETE) {
|
||||
writeMappingCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
Class.forName("com.ruoyi.web.controller.train.TrainDormAuthController");
|
||||
fail("MIN-08: TrainDormAuthController 必须彻底删除,统一收归至 TrainAccessManagementController");
|
||||
} catch (ClassNotFoundException expected) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
assertEquals("TrainDormAuthController must have ZERO write mutation endpoints", 0, writeMappingCount);
|
||||
}
|
||||
|
||||
/* ==================== 辅助测试工具与 Mock 桩 ==================== */
|
||||
@@ -572,6 +548,17 @@ public class DmsAccessSafetyPackageATest {
|
||||
if ("selectTrainAccommodationByAccommodationId".equals(name)) {
|
||||
return accommodationDb.get((Long) args[0]);
|
||||
}
|
||||
if ("countActiveByDormIdAndBedNo".equals(name)) {
|
||||
String did = (String) args[0];
|
||||
String bno = (String) args[1];
|
||||
Long excId = (Long) args[2];
|
||||
if (did == null || bno == null || bno.trim().isEmpty()) return 0;
|
||||
return (int) accommodationDb.values().stream()
|
||||
.filter(a -> (excId == null || !excId.equals(a.getAccommodationId()))
|
||||
&& did.equals(a.getDormId()) && bno.equals(a.getBedNo())
|
||||
&& a.getAccommodationStatus() != null && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1))
|
||||
.count();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
@@ -691,6 +678,33 @@ public class DmsAccessSafetyPackageATest {
|
||||
operationDb.put(op.getOperationId(), op);
|
||||
return op;
|
||||
}
|
||||
if ("claimOperationForDispatch".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null && TrainExternalOperation.STATUS_CREATED.equals(op.getOperationStatus())) {
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_DISPATCHING);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if ("selectLatestGrantOperationByDoorAuthId".equals(name)) {
|
||||
Long authId = (Long) args[0];
|
||||
TrainExternalOperation latest = null;
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (authId.equals(op.getDoorAuthId()) && TrainExternalOperation.OP_TYPE_GRANT.equals(op.getOperationType())) {
|
||||
if (latest == null || (op.getOperationId() != null && op.getOperationId() > latest.getOperationId())) {
|
||||
latest = op;
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
if ("markStaleDispatchingAsAmbiguous".equals(name)) {
|
||||
return 0;
|
||||
}
|
||||
if ("selectStaleDispatchingOperations".equals(name)) {
|
||||
return new ArrayList<TrainExternalOperation>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
+973
@@ -0,0 +1,973 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainAccommodation;
|
||||
import com.ruoyi.train.domain.TrainClassPlan;
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainDormitoryInfo;
|
||||
import com.ruoyi.train.domain.TrainEnrollment;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.domain.TrainExternalResourceMapping;
|
||||
import com.ruoyi.train.domain.TrainStudent;
|
||||
import com.ruoyi.train.integration.dms.config.DmsProperties;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
|
||||
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
|
||||
import com.ruoyi.train.integration.dms.dto.IssueResultVO;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsBusinessException;
|
||||
import com.ruoyi.train.integration.dms.exception.DmsTransportException;
|
||||
import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
|
||||
import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator;
|
||||
import com.ruoyi.train.integration.dms.orchestration.BatchReconciliationResult;
|
||||
import com.ruoyi.train.integration.dms.orchestration.ReconciliationDecision;
|
||||
import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult;
|
||||
import com.ruoyi.train.mapper.TrainAccommodationMapper;
|
||||
import com.ruoyi.train.mapper.TrainClassPlanMapper;
|
||||
import com.ruoyi.train.mapper.TrainDormAuthMapper;
|
||||
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
|
||||
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
|
||||
import com.ruoyi.train.mapper.TrainExternalOperationMapper;
|
||||
import com.ruoyi.train.mapper.TrainStudentMapper;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
import com.ruoyi.train.service.IExternalResourceMappingService;
|
||||
import com.ruoyi.train.service.ITrainDormAuthService;
|
||||
import com.ruoyi.train.service.impl.ExternalOperationServiceImpl;
|
||||
import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl;
|
||||
import com.ruoyi.train.service.impl.TrainDormAuthServiceImpl;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
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.Future;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* PACKAGE A.1 — DMS 一致性加固与并发安全集成测试
|
||||
* 覆盖验收用例:
|
||||
* T1: 两个线程同时 claim 同一 CREATED operation,恰好一个 affectedRows=1,外部网关调用恰好 1 次
|
||||
* T2: afterCommit 与 poller 模拟竞态,网关调用恰好 1 次
|
||||
* T3: GRANT 超时与模糊结果(AMBIGUOUS):禁止重复 Issue,网关总调用次数保持 1
|
||||
* T4: 迟到 Poller 结果防护:旧 GRANT SUCCESS 在 auth 处于 PENDING_REVOKE / REVOKED / ERROR 时不得复活为 ACTIVE
|
||||
* T5: DMS disabled + ACTIVE + externalAuthId 为空:不得直接进入 REVOKED,必须进入 ERROR / AMBIGUOUS
|
||||
* T6: DMS disabled + PENDING_GRANT 且无历史外部操作:允许安全本地进入 REVOKED
|
||||
* T7: 批量撤权 2 success + 1 failure:BatchReconciliationResult 计算为 PARTIAL_FAILURE 且 success=false
|
||||
* T8: WAITING_FOR_DMS:externalInvoked=false, queued>0, dispatched=0, outcome=WAITING_FOR_DMS
|
||||
* T9: 同宿舍同一有效床位(status in 0,1)重复分配:Service 拦截并抛出 BED_ALREADY_OCCUPIED
|
||||
* T10: 更新自身床位排除自身 ID,不误报冲突
|
||||
* T11: 退宿后(status=2或3),该床位可安全重新分配
|
||||
* 以及补充的外部不确定性场景(连接重置、响应解析失败、厂商业务明确拒绝)。
|
||||
*/
|
||||
public class DmsConsistencyHardeningPackageA1Test {
|
||||
|
||||
private AccessAuthorizationOrchestrator orchestrator;
|
||||
private DmsProperties dmsProperties;
|
||||
private MockCredentialGateway gateway;
|
||||
private ITrainDormAuthService dormAuthService;
|
||||
private IExternalOperationService operationService;
|
||||
private TrainAccommodationServiceImpl accommodationService;
|
||||
|
||||
private Map<Long, TrainEnrollment> enrollmentDb;
|
||||
private Map<Long, TrainAccommodation> accommodationDb;
|
||||
private Map<Long, TrainDormAuth> dormAuthDb;
|
||||
private Map<String, TrainStudent> studentDb;
|
||||
private Map<String, TrainClassPlan> planDb;
|
||||
private Map<String, TrainExternalResourceMapping> resourceMappingDb;
|
||||
private Map<Long, TrainExternalOperation> operationDb;
|
||||
|
||||
private AtomicLong authSeq;
|
||||
private AtomicLong opSeq;
|
||||
private AtomicLong accSeq;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
enrollmentDb = new ConcurrentHashMap<>();
|
||||
accommodationDb = new ConcurrentHashMap<>();
|
||||
dormAuthDb = new ConcurrentHashMap<>();
|
||||
studentDb = new ConcurrentHashMap<>();
|
||||
planDb = new ConcurrentHashMap<>();
|
||||
resourceMappingDb = new ConcurrentHashMap<>();
|
||||
operationDb = new ConcurrentHashMap<>();
|
||||
|
||||
authSeq = new AtomicLong(100);
|
||||
opSeq = new AtomicLong(500);
|
||||
accSeq = new AtomicLong(1000);
|
||||
|
||||
dmsProperties = new DmsProperties();
|
||||
dmsProperties.setEnabled(true);
|
||||
dmsProperties.setAppId("test-app");
|
||||
dmsProperties.setAppSecret("test-secret");
|
||||
dmsProperties.setBaseUrl("http://mock-dms.local");
|
||||
|
||||
gateway = new MockCredentialGateway();
|
||||
|
||||
TrainDormAuthServiceImpl authServiceImpl = new TrainDormAuthServiceImpl();
|
||||
setPrivateField(authServiceImpl, "trainDormAuthMapper", createMockDormAuthMapper());
|
||||
dormAuthService = authServiceImpl;
|
||||
|
||||
ExternalOperationServiceImpl opServiceImpl = new ExternalOperationServiceImpl();
|
||||
setPrivateField(opServiceImpl, "operationMapper", createMockOperationMapper());
|
||||
setPrivateField(opServiceImpl, "dormAuthMapper", createMockDormAuthMapper());
|
||||
operationService = opServiceImpl;
|
||||
|
||||
accommodationService = new TrainAccommodationServiceImpl();
|
||||
setPrivateField(accommodationService, "trainAccommodationMapper", createMockAccommodationMapper());
|
||||
setPrivateField(accommodationService, "trainEnrollmentMapper", createMockEnrollmentMapper());
|
||||
setPrivateField(accommodationService, "trainDormitoryInfoMapper", createMockDormitoryInfoMapper());
|
||||
|
||||
orchestrator = new AccessAuthorizationOrchestrator(
|
||||
createMockEnrollmentMapper(),
|
||||
createMockAccommodationMapper(),
|
||||
createMockDormAuthMapper(),
|
||||
dormAuthService,
|
||||
createMockMappingService(),
|
||||
operationService,
|
||||
gateway,
|
||||
createMockStudentMapper(),
|
||||
createMockClassPlanMapper(),
|
||||
dmsProperties
|
||||
);
|
||||
|
||||
setupBaseFixtures();
|
||||
}
|
||||
|
||||
private void setupBaseFixtures() {
|
||||
TrainStudent s = new TrainStudent();
|
||||
s.setsId("STU001");
|
||||
s.setsName("张三");
|
||||
s.setsPhone("13812345678");
|
||||
studentDb.put("STU001", s);
|
||||
|
||||
TrainClassPlan p = new TrainClassPlan();
|
||||
p.setPlanId("PLAN001");
|
||||
p.setPlanName("信号系统专业培训");
|
||||
p.setStartTime(new Date(System.currentTimeMillis() - 86400000L));
|
||||
p.setEndTime(new Date(System.currentTimeMillis() + 86400000L * 10));
|
||||
planDb.put("PLAN001", p);
|
||||
|
||||
TrainEnrollment e = new TrainEnrollment();
|
||||
e.setEnrollmentId(101L);
|
||||
e.setStudentId("STU001");
|
||||
e.setPlanId("PLAN001");
|
||||
e.setEnrollmentStatus(1);
|
||||
enrollmentDb.put(101L, e);
|
||||
|
||||
TrainAccommodation a = new TrainAccommodation();
|
||||
a.setAccommodationId(201L);
|
||||
a.setEnrollmentId(101L);
|
||||
a.setDormId("DORM-A");
|
||||
a.setBedNo("01");
|
||||
a.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
|
||||
accommodationDb.put(201L, a);
|
||||
|
||||
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
|
||||
room.setDormId("DORM-A");
|
||||
room.setExternalResourceType("ROOM");
|
||||
room.setExternalResourceId("EXT-ROOM-101");
|
||||
room.setMappingStatus("ACTIVE");
|
||||
resourceMappingDb.put("ROOM:DORM-A", room);
|
||||
|
||||
TrainExternalResourceMapping lock = new TrainExternalResourceMapping();
|
||||
lock.setDormId("DORM-A");
|
||||
lock.setExternalResourceType("LOCK");
|
||||
lock.setExternalResourceId("EXT-LOCK-201");
|
||||
lock.setExternalHardwareId("HW-201");
|
||||
lock.setMappingStatus("ACTIVE");
|
||||
lock.setIsPrimary(1);
|
||||
resourceMappingDb.put("LOCK:DORM-A:EXT-LOCK-201", lock);
|
||||
}
|
||||
|
||||
/**
|
||||
* T1: 两个线程同时 claim 同一 CREATED operation
|
||||
* 验证原子性:恰好一个 affectedRows == 1,外部网关调用恰好 1 次
|
||||
*/
|
||||
@Test
|
||||
public void testT1_ConcurrentClaim_ExactlyOneWinnerAndOneGatewayCall() throws Exception {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(opSeq.incrementAndGet());
|
||||
op.setOperationType(TrainExternalOperation.OP_TYPE_REVOKE);
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_CREATED);
|
||||
op.setExternalKeyId("88001");
|
||||
operationDb.put(op.getOperationId(), op);
|
||||
|
||||
int threadCount = 2;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
CountDownLatch readyLatch = new CountDownLatch(threadCount);
|
||||
CountDownLatch startLatch = new CountDownLatch(1);
|
||||
|
||||
List<Future<Boolean>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
futures.add(executor.submit(() -> {
|
||||
readyLatch.countDown();
|
||||
startLatch.await();
|
||||
boolean claimed = operationService.claimOperationForDispatch(op.getOperationId());
|
||||
if (claimed) {
|
||||
gateway.revokeOnlinePassword(Long.parseLong(op.getExternalKeyId()));
|
||||
}
|
||||
return claimed;
|
||||
}));
|
||||
}
|
||||
|
||||
readyLatch.await();
|
||||
startLatch.countDown();
|
||||
|
||||
int successClaims = 0;
|
||||
for (Future<Boolean> f : futures) {
|
||||
if (f.get()) {
|
||||
successClaims++;
|
||||
}
|
||||
}
|
||||
executor.shutdown();
|
||||
|
||||
assertEquals("恰好一个线程能成功 claim 该 operation", 1, successClaims);
|
||||
assertEquals("外部网关调用次数必须且只能为 1 次", 1, gateway.revokeCallCount.get());
|
||||
assertEquals("操作状态必须已推进为 DISPATCHING", TrainExternalOperation.STATUS_DISPATCHING, op.getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* T2: afterCommit 与 Poller 模拟竞争
|
||||
* 验证原子派发 Claim 能够完全消除双重调用风险
|
||||
*/
|
||||
@Test
|
||||
public void testT2_AfterCommitAndPollerRace_DoubleDispatchPrevented() throws Exception {
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setOperationId(opSeq.incrementAndGet());
|
||||
op.setOperationType(TrainExternalOperation.OP_TYPE_REVOKE);
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_CREATED);
|
||||
op.setExternalKeyId("88002");
|
||||
operationDb.put(op.getOperationId(), op);
|
||||
|
||||
// 模拟 afterCommit 线程先完成 claim
|
||||
boolean afterCommitClaim = operationService.claimOperationForDispatch(op.getOperationId());
|
||||
assertTrue("afterCommit 应该成功 claim", afterCommitClaim);
|
||||
|
||||
// 模拟 Poller 线程几乎同时捞到同一 operation 并尝试 claim
|
||||
boolean pollerClaim = operationService.claimOperationForDispatch(op.getOperationId());
|
||||
assertFalse("Poller 必须被原子排他拦截", pollerClaim);
|
||||
|
||||
if (afterCommitClaim) {
|
||||
gateway.revokeOnlinePassword(Long.parseLong(op.getExternalKeyId()));
|
||||
}
|
||||
if (pollerClaim) {
|
||||
gateway.revokeOnlinePassword(Long.parseLong(op.getExternalKeyId()));
|
||||
}
|
||||
|
||||
assertEquals("仅允许一个派发者执行外部调用", 1, gateway.revokeCallCount.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* T3: GRANT 超时与模糊结果 (AMBIGUOUS):
|
||||
* 外部 Mutation 发生 Read Timeout 时,状态进入 AMBIGUOUS,后续重复 Issue 必须被严格阻断
|
||||
*/
|
||||
@Test
|
||||
public void testT3_GrantTimeout_AmbiguousStatus_ReissueGuardBlocksSecondCall() {
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(authSeq.incrementAndGet());
|
||||
auth.setAccommodationId(201L);
|
||||
auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
|
||||
auth.setCredentialType("ONLINE_PASSWORD");
|
||||
dormAuthDb.put(201L, auth);
|
||||
|
||||
// 配置网关模拟 Read Timeout 异常
|
||||
gateway.shouldThrowTimeout = true;
|
||||
|
||||
IssueResultVO result1 = orchestrator.issueAuthorization(auth.getAuthId(), "admin");
|
||||
assertNotNull(result1);
|
||||
assertEquals("超时必须标记为 AMBIGUOUS_EXTERNAL_MUTATION", "AMBIGUOUS_EXTERNAL_MUTATION", result1.getStatus());
|
||||
assertEquals("授权状态必须置为 ERROR", TrainDormAuth.AUTH_STATUS_ERROR, auth.getAuthStatus());
|
||||
assertTrue("lastError 必须包含 AMBIGUOUS_EXTERNAL_MUTATION", auth.getLastError().contains("AMBIGUOUS_EXTERNAL_MUTATION"));
|
||||
|
||||
// 检查流水状态
|
||||
TrainExternalOperation op = operationService.selectLatestGrantOperationByDoorAuthId(auth.getAuthId());
|
||||
assertNotNull(op);
|
||||
assertEquals("流水状态必须进入 AMBIGUOUS", TrainExternalOperation.STATUS_AMBIGUOUS, op.getOperationStatus());
|
||||
assertEquals(1, gateway.issueCallCount.get());
|
||||
|
||||
// 第二次尝试 Issue:即使 authStatus=ERROR,由于最新 GRANT op 为 AMBIGUOUS,守卫必须阻断重发
|
||||
gateway.shouldThrowTimeout = false; // 假设网络恢复
|
||||
IssueResultVO result2 = orchestrator.issueAuthorization(auth.getAuthId(), "admin");
|
||||
|
||||
assertNotNull(result2);
|
||||
assertEquals("第二次下发必须被拒并要求人工核对", "AMBIGUOUS_EXTERNAL_MUTATION", result2.getStatus());
|
||||
assertTrue(result2.getMessage().contains("禁止重复下发"));
|
||||
assertEquals("网关总调用次数必须依然为 1,严禁产生孤儿凭据!", 1, gateway.issueCallCount.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* T4: 迟到 Poller 结果防护:
|
||||
* 旧 GRANT SUCCESS 迟到返回时,若 auth 已进入 PENDING_REVOKE / REVOKED / ERROR,绝对不得复活为 ACTIVE
|
||||
*/
|
||||
@Test
|
||||
public void testT4_LatePollerGrantSuccess_DoesNotResurrectAuthStatus() {
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(authSeq.incrementAndGet());
|
||||
auth.setAccommodationId(201L);
|
||||
auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_REVOKED);
|
||||
auth.setLatestOperationId(999L);
|
||||
dormAuthDb.put(201L, auth);
|
||||
|
||||
// 模拟一个迟到的旧 GRANT operation (ID=999) 成功返回
|
||||
TrainExternalOperation oldGrantOp = new TrainExternalOperation();
|
||||
oldGrantOp.setOperationId(999L);
|
||||
oldGrantOp.setDoorAuthId(auth.getAuthId());
|
||||
oldGrantOp.setOperationType(TrainExternalOperation.OP_TYPE_GRANT);
|
||||
oldGrantOp.setOperationStatus(TrainExternalOperation.STATUS_SUCCESS);
|
||||
|
||||
int updated = dormAuthService.updateAuthStatusByPoller(
|
||||
auth.getAuthId(),
|
||||
TrainDormAuth.AUTH_STATUS_ACTIVE,
|
||||
oldGrantOp.getOperationId(),
|
||||
oldGrantOp.getOperationType(),
|
||||
null,
|
||||
"poller"
|
||||
);
|
||||
|
||||
assertEquals("迟到结果不得更新授权状态", 0, updated);
|
||||
TrainDormAuth current = dormAuthService.selectTrainDormAuthByAuthId(auth.getAuthId());
|
||||
assertEquals("授权状态必须严格保持 REVOKED,不得被复活为 ACTIVE", TrainDormAuth.AUTH_STATUS_REVOKED, current.getAuthStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* T5: DMS disabled + ACTIVE + externalAuthId 为空:
|
||||
* 无法确认是否存在外部凭证,绝对不得直接写为 REVOKED,必须转为 ERROR / AMBIGUOUS
|
||||
*/
|
||||
@Test
|
||||
public void testT5_DmsDisabled_ActiveWithNullExternalAuthId_DoesNotDirectlyRevoke() {
|
||||
dmsProperties.setEnabled(false);
|
||||
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(authSeq.incrementAndGet());
|
||||
auth.setAccommodationId(201L);
|
||||
auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
|
||||
auth.setExternalAuthId(null); // externalAuthId 为空
|
||||
dormAuthDb.put(201L, auth);
|
||||
|
||||
ReconciliationResult result = orchestrator.revokeAuthorization(auth.getAuthId(), "admin");
|
||||
assertNotNull(result);
|
||||
assertFalse("不得返回成功", result.isSuccess());
|
||||
assertEquals("必须标记为 FAILED 决策", ReconciliationDecision.FAILED, result.getDecision());
|
||||
|
||||
TrainDormAuth current = dormAuthService.selectTrainDormAuthByAuthId(auth.getAuthId());
|
||||
assertEquals("状态必须进入 ERROR", TrainDormAuth.AUTH_STATUS_ERROR, current.getAuthStatus());
|
||||
assertTrue("必须明确指出外部凭证不确定", current.getLastError().contains("AMBIGUOUS_EXTERNAL_CREDENTIAL"));
|
||||
assertNotEquals("绝对不得直接写为 REVOKED", TrainDormAuth.AUTH_STATUS_REVOKED, current.getAuthStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* T6: DMS disabled + PENDING_GRANT 且无历史外部操作:
|
||||
* 可完全证明从未产生外部凭据,安全在本地转为 REVOKED
|
||||
*/
|
||||
@Test
|
||||
public void testT6_DmsDisabled_PendingGrantWithoutExternalOp_SafelyRevokesLocally() {
|
||||
dmsProperties.setEnabled(false);
|
||||
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(authSeq.incrementAndGet());
|
||||
auth.setAccommodationId(201L);
|
||||
auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
|
||||
auth.setExternalAuthId(null);
|
||||
dormAuthDb.put(201L, auth);
|
||||
|
||||
ReconciliationResult result = orchestrator.revokeAuthorization(auth.getAuthId(), "admin");
|
||||
assertNotNull(result);
|
||||
assertTrue("本地注销应成功", result.isSuccess());
|
||||
assertEquals(ReconciliationDecision.NO_ACTION, result.getDecision());
|
||||
|
||||
TrainDormAuth current = dormAuthService.selectTrainDormAuthByAuthId(auth.getAuthId());
|
||||
assertEquals("可安全推进为已注销", TrainDormAuth.AUTH_STATUS_REVOKED, current.getAuthStatus());
|
||||
assertEquals("未调用外部网关", 0, gateway.revokeCallCount.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* T7: 批量撤权 2 success + 1 failure:
|
||||
* BatchReconciliationResult outcome 必须为 PARTIAL_FAILURE 且 success=false (MAJ-03 / MAJ-04)
|
||||
*/
|
||||
@Test
|
||||
public void testT7_BatchRevoke_TwoSuccessOneFailure_YieldsPartialFailure() {
|
||||
TrainDormAuth auth1 = new TrainDormAuth();
|
||||
auth1.setAuthId(authSeq.incrementAndGet());
|
||||
auth1.setAccommodationId(201L);
|
||||
auth1.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
|
||||
auth1.setExternalAuthId("88001");
|
||||
dormAuthDb.put(201L, auth1);
|
||||
|
||||
TrainDormAuth auth2 = new TrainDormAuth();
|
||||
auth2.setAuthId(authSeq.incrementAndGet());
|
||||
auth2.setAccommodationId(202L);
|
||||
auth2.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
|
||||
auth2.setExternalAuthId("88002");
|
||||
dormAuthDb.put(202L, auth2);
|
||||
|
||||
TrainDormAuth auth3 = new TrainDormAuth();
|
||||
auth3.setAuthId(authSeq.incrementAndGet());
|
||||
auth3.setAccommodationId(203L);
|
||||
auth3.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
|
||||
auth3.setExternalAuthId("88003");
|
||||
dormAuthDb.put(203L, auth3);
|
||||
|
||||
// 指定第 3 个调用网关时抛出明确拒绝异常
|
||||
gateway.failOnKeyId = 88003L;
|
||||
|
||||
List<TrainDormAuth> auths = new ArrayList<>();
|
||||
auths.add(auth1);
|
||||
auths.add(auth2);
|
||||
auths.add(auth3);
|
||||
|
||||
BatchReconciliationResult batchResult = orchestrator.executeBatchRevokeForAuths(101L, auths, "TEST_TERMINATION", "admin");
|
||||
assertNotNull(batchResult);
|
||||
assertEquals("total 应为 3", 3, batchResult.getTotal());
|
||||
assertEquals("dispatched 应为 2", 2, batchResult.getDispatched());
|
||||
assertEquals("failed 应为 1", 1, batchResult.getFailed());
|
||||
assertEquals("outcome 必须为 PARTIAL_FAILURE", BatchReconciliationResult.OUTCOME_PARTIAL_FAILURE, batchResult.getOutcome());
|
||||
assertFalse("有失败时总体 success 必须为 false", batchResult.isSuccess());
|
||||
}
|
||||
|
||||
/**
|
||||
* T8: WAITING_FOR_DMS 批量语义:
|
||||
* DMS disabled 时,已有 externalAuthId 的记录建立 REVOKE CREATED 入队,
|
||||
* 明确 queued > 0, dispatched == 0, externalInvoked == false, outcome == WAITING_FOR_DMS
|
||||
*/
|
||||
@Test
|
||||
public void testT8_DmsDisabled_QueuedRevoke_SemanticsAccurate() {
|
||||
dmsProperties.setEnabled(false);
|
||||
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(authSeq.incrementAndGet());
|
||||
auth.setAccommodationId(201L);
|
||||
auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
|
||||
auth.setExternalAuthId("88009");
|
||||
dormAuthDb.put(201L, auth);
|
||||
|
||||
BatchReconciliationResult batchResult = orchestrator.executeBatchRevokeForAuths(
|
||||
101L,
|
||||
Collections.singletonList(auth),
|
||||
"TEST_DISABLED_BATCH",
|
||||
"admin"
|
||||
);
|
||||
|
||||
assertNotNull(batchResult);
|
||||
assertEquals("queued 必须为 1", 1, batchResult.getQueued());
|
||||
assertEquals("dispatched 必须为 0", 0, batchResult.getDispatched());
|
||||
assertEquals("failed 必须为 0", 0, batchResult.getFailed());
|
||||
assertFalse("externalInvoked 必须为 false", batchResult.isExternalInvoked());
|
||||
assertEquals("outcome 必须为 WAITING_FOR_DMS", BatchReconciliationResult.OUTCOME_WAITING_FOR_DMS, batchResult.getOutcome());
|
||||
|
||||
// 验证确实创建了 CREATED 状态的 REVOKE 操作供未来恢复时消费
|
||||
TrainExternalOperation op = operationDb.values().stream()
|
||||
.filter(o -> auth.getAuthId().equals(o.getDoorAuthId()) && TrainExternalOperation.OP_TYPE_REVOKE.equals(o.getOperationType()))
|
||||
.findFirst().orElse(null);
|
||||
assertNotNull(op);
|
||||
assertEquals(TrainExternalOperation.STATUS_CREATED, op.getOperationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* T9: 同一宿舍同一有效床位(status in 0,1)重复分配:
|
||||
* Service 必须预检拦截并抛出明确业务错误 BED_ALREADY_OCCUPIED
|
||||
*/
|
||||
@Test
|
||||
public void testT9_ActiveBedDuplication_ServiceGuardRejects() {
|
||||
TrainAccommodation acc1 = new TrainAccommodation();
|
||||
acc1.setEnrollmentId(102L);
|
||||
acc1.setDormId("DORM-B");
|
||||
acc1.setBedNo("02");
|
||||
acc1.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
|
||||
accommodationService.insertTrainAccommodation(acc1);
|
||||
|
||||
// 第二个学员尝试分配同宿舍同床号
|
||||
TrainAccommodation acc2 = new TrainAccommodation();
|
||||
acc2.setEnrollmentId(103L);
|
||||
acc2.setDormId("DORM-B");
|
||||
acc2.setBedNo("02");
|
||||
acc2.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
|
||||
|
||||
try {
|
||||
accommodationService.insertTrainAccommodation(acc2);
|
||||
fail("同宿舍同有效床位重复分配必须抛出 ServiceException");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue("必须提示 BED_ALREADY_OCCUPIED", e.getMessage().contains("BED_ALREADY_OCCUPIED"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* T10: 更新自身住宿信息时排除自身 ID,正常允许保存自身床位
|
||||
*/
|
||||
@Test
|
||||
public void testT10_SelfAccommodationUpdate_DoesNotTriggerConflict() {
|
||||
TrainAccommodation acc = accommodationService.selectTrainAccommodationByAccommodationId(201L);
|
||||
assertNotNull(acc);
|
||||
|
||||
// 更新备注,保留原床位
|
||||
acc.setRemark("更新住宿备注信息");
|
||||
int updated = accommodationService.updateTrainAccommodation(acc);
|
||||
assertEquals("更新自身床位应成功", 1, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* T11: 退宿后(status=2或3),该床位可安全重新分配
|
||||
*/
|
||||
@Test
|
||||
public void testT11_AfterCheckout_SameBedCanBeReallocated() {
|
||||
TrainAccommodation acc1 = new TrainAccommodation();
|
||||
acc1.setEnrollmentId(104L);
|
||||
acc1.setDormId("DORM-D");
|
||||
acc1.setBedNo("04");
|
||||
acc1.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_IN);
|
||||
accommodationService.insertTrainAccommodation(acc1);
|
||||
|
||||
// 学员 104 退宿
|
||||
accommodationService.checkOut(acc1.getAccommodationId(), "admin");
|
||||
|
||||
// 学员 105 入住同一床位
|
||||
TrainAccommodation acc2 = new TrainAccommodation();
|
||||
acc2.setEnrollmentId(105L);
|
||||
acc2.setDormId("DORM-D");
|
||||
acc2.setBedNo("04");
|
||||
acc2.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
|
||||
|
||||
int inserted = accommodationService.insertTrainAccommodation(acc2);
|
||||
assertEquals("退宿后同一床位可以被重新分配", 1, inserted);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景测试:厂商明确业务拒绝 (DEFINITIVE_FAILURE)
|
||||
* 允许 operation -> FAILED, auth -> ERROR,未来允许在修复业务前置后重新受控下发
|
||||
*/
|
||||
@Test
|
||||
public void testDefinitiveFailure_VendorReject_MarksFailedAndAllowsReissueAfterFix() {
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(authSeq.incrementAndGet());
|
||||
auth.setAccommodationId(201L);
|
||||
auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
|
||||
auth.setCredentialType("ONLINE_PASSWORD");
|
||||
dormAuthDb.put(201L, auth);
|
||||
|
||||
gateway.shouldRejectBusiness = true;
|
||||
|
||||
IssueResultVO result = orchestrator.issueAuthorization(auth.getAuthId(), "admin");
|
||||
assertNotNull(result);
|
||||
assertEquals("明确失败", "FAILED", result.getStatus());
|
||||
|
||||
TrainExternalOperation op = operationService.selectLatestGrantOperationByDoorAuthId(auth.getAuthId());
|
||||
assertNotNull(op);
|
||||
assertEquals("明确失败必须为 FAILED,而非 AMBIGUOUS", TrainExternalOperation.STATUS_FAILED, op.getOperationStatus());
|
||||
|
||||
// 修复厂商侧原因后,再次 Issue 允许重新下发
|
||||
gateway.shouldRejectBusiness = false;
|
||||
IssueResultVO retryResult = orchestrator.issueAuthorization(auth.getAuthId(), "admin");
|
||||
assertNotNull(retryResult);
|
||||
assertEquals("重新下发成功", "ACCEPTED", retryResult.getStatus());
|
||||
assertEquals("网关总共被调用 2 次(1 次失败 + 1 次成功)", 2, gateway.issueCallCount.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景测试:长时间 DISPATCHING 崩溃恢复转为 AMBIGUOUS,禁止自动重新派发
|
||||
*/
|
||||
@Test
|
||||
public void testStaleDispatchingCrashRecovery_TransitionsToAmbiguous() {
|
||||
TrainExternalOperation staleOp = new TrainExternalOperation();
|
||||
staleOp.setOperationId(opSeq.incrementAndGet());
|
||||
staleOp.setOperationType(TrainExternalOperation.OP_TYPE_GRANT);
|
||||
staleOp.setOperationStatus(TrainExternalOperation.STATUS_DISPATCHING);
|
||||
staleOp.setUpdateTime(new Date(System.currentTimeMillis() - 120000L)); // 2分钟前
|
||||
operationDb.put(staleOp.getOperationId(), staleOp);
|
||||
|
||||
Date staleThreshold = new Date(System.currentTimeMillis() - 60000L);
|
||||
int recovered = operationService.markStaleDispatchingAsAmbiguous(staleThreshold);
|
||||
assertEquals("长时间处于 DISPATCHING 必须被识别", 1, recovered);
|
||||
assertEquals("状态必须变为 AMBIGUOUS,绝对不得自动重试!", TrainExternalOperation.STATUS_AMBIGUOUS, staleOp.getOperationStatus());
|
||||
}
|
||||
|
||||
/* ==================== 辅助 Mock 类 ==================== */
|
||||
|
||||
private static class MockCredentialGateway implements ExternalCredentialGateway {
|
||||
final AtomicInteger issueCallCount = new AtomicInteger(0);
|
||||
final AtomicInteger revokeCallCount = new AtomicInteger(0);
|
||||
boolean shouldThrowTimeout = false;
|
||||
boolean shouldRejectBusiness = false;
|
||||
Long failOnKeyId = null;
|
||||
|
||||
@Override
|
||||
public DmsPasswordCreateResponse issueOnlinePassword(Long lockId, String holderName, String phone,
|
||||
String plaintextPassword, String validStart, String validUntil) {
|
||||
issueCallCount.incrementAndGet();
|
||||
|
||||
if (shouldThrowTimeout) {
|
||||
throw new DmsTransportException("SocketTimeoutException: Read timed out after HTTP write", new SocketTimeoutException());
|
||||
}
|
||||
if (shouldRejectBusiness) {
|
||||
throw new DmsBusinessException("VENDOR_CARD_FULL", "VENDOR_CARD_FULL (DEFINITIVE_FAILURE)");
|
||||
}
|
||||
|
||||
DmsPasswordCreateResponse resp = new DmsPasswordCreateResponse();
|
||||
resp.setId(88000L + issueCallCount.get());
|
||||
resp.setOperationId(99000L + issueCallCount.get());
|
||||
resp.setLockHardwareId("HW-" + lockId);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DmsPasswordDeleteResponse revokeOnlinePassword(Long keyId) {
|
||||
revokeCallCount.incrementAndGet();
|
||||
|
||||
if (failOnKeyId != null && failOnKeyId.equals(keyId)) {
|
||||
throw new DmsTransportException("DmsBusinessException: KEY_NOT_FOUND (DEFINITIVE_FAILURE)");
|
||||
}
|
||||
|
||||
DmsPasswordDeleteResponse resp = new DmsPasswordDeleteResponse();
|
||||
resp.setOperationId(99000L + revokeCallCount.get());
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
|
||||
private TrainExternalOperationMapper createMockOperationMapper() {
|
||||
return (TrainExternalOperationMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainExternalOperationMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("insertExternalOperation".equals(name) || "insertTrainExternalOperation".equals(name)) {
|
||||
TrainExternalOperation op = (TrainExternalOperation) args[0];
|
||||
if (op.getOperationId() == null) {
|
||||
op.setOperationId(opSeq.incrementAndGet());
|
||||
}
|
||||
operationDb.put(op.getOperationId(), op);
|
||||
return 1;
|
||||
}
|
||||
if ("selectByIdempotencyKey".equals(name)) {
|
||||
String key = (String) args[0];
|
||||
return operationDb.values().stream().filter(o -> key.equals(o.getIdempotencyKey())).findFirst().orElse(null);
|
||||
}
|
||||
if ("selectExternalOperationById".equals(name) || "selectTrainExternalOperationByOperationId".equals(name)) {
|
||||
return operationDb.get((Long) args[0]);
|
||||
}
|
||||
if ("updateExternalOperationStatus".equals(name) || "updateTrainExternalOperation".equals(name)) {
|
||||
TrainExternalOperation op = (TrainExternalOperation) args[0];
|
||||
operationDb.put(op.getOperationId(), op);
|
||||
return 1;
|
||||
}
|
||||
if ("updateExternalReference".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
String extOpId = (String) args[1];
|
||||
String extHwId = (String) args[2];
|
||||
String extKeyId = (String) args[3];
|
||||
String operator = (String) args[4];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
if (extOpId != null) op.setExternalOperationId(extOpId);
|
||||
if (extHwId != null) op.setExternalHardwareId(extHwId);
|
||||
if (extKeyId != null) op.setExternalKeyId(extKeyId);
|
||||
op.setUpdateBy(operator);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if ("claimOperationForDispatch".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null) {
|
||||
synchronized (op) {
|
||||
if (TrainExternalOperation.STATUS_CREATED.equals(op.getOperationStatus())) {
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_DISPATCHING);
|
||||
op.setUpdateTime(new Date());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if ("selectTrainExternalOperationByOperationId".equals(name)) {
|
||||
return operationDb.get((Long) args[0]);
|
||||
}
|
||||
if ("selectLatestGrantOperationByDoorAuthId".equals(name)) {
|
||||
Long authId = (Long) args[0];
|
||||
TrainExternalOperation latest = null;
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (authId.equals(op.getDoorAuthId()) && TrainExternalOperation.OP_TYPE_GRANT.equals(op.getOperationType())) {
|
||||
if (latest == null || (op.getOperationId() != null && op.getOperationId() > latest.getOperationId())) {
|
||||
latest = op;
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
if ("markStaleDispatchingAsAmbiguous".equals(name)) {
|
||||
Date cutoff = (Date) args[0];
|
||||
int count = 0;
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (TrainExternalOperation.STATUS_DISPATCHING.equals(op.getOperationStatus())) {
|
||||
Date ut = op.getUpdateTime() != null ? op.getUpdateTime() : op.getCreateTime();
|
||||
if (ut != null && ut.before(cutoff)) {
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_AMBIGUOUS);
|
||||
op.setLastErrorCode("DISPATCHING_CRASH_OR_TIMEOUT");
|
||||
op.setLastErrorMessage("Operation remained in DISPATCHING too long. Marked AMBIGUOUS for safety.");
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
if ("selectStaleDispatchingOperations".equals(name)) {
|
||||
return new ArrayList<TrainExternalOperation>();
|
||||
}
|
||||
if ("selectTrainExternalOperationList".equals(name)) {
|
||||
return new ArrayList<>(operationDb.values());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainAccommodationMapper createMockAccommodationMapper() {
|
||||
return (TrainAccommodationMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainAccommodationMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectTrainAccommodationByAccommodationId".equals(name)) {
|
||||
return accommodationDb.get((Long) args[0]);
|
||||
}
|
||||
if ("selectActiveAccommodationByEnrollmentId".equals(name)) {
|
||||
Long eid = (Long) args[0];
|
||||
return accommodationDb.values().stream()
|
||||
.filter(a -> eid.equals(a.getEnrollmentId()) && (a.getAccommodationStatus() != null && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1)))
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
if ("countActiveByDormIdAndBedNo".equals(name)) {
|
||||
String dormId = (String) args[0];
|
||||
String bedNo = (String) args[1];
|
||||
Long excludeId = (Long) args[2];
|
||||
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 ("countActiveByDormId".equals(name)) {
|
||||
String dormId = (String) args[0];
|
||||
return (int) accommodationDb.values().stream()
|
||||
.filter(a -> dormId.equals(a.getDormId())
|
||||
&& a.getAccommodationStatus() != null && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1))
|
||||
.count();
|
||||
}
|
||||
if ("insertTrainAccommodation".equals(name)) {
|
||||
TrainAccommodation a = (TrainAccommodation) args[0];
|
||||
a.setAccommodationId(accSeq.incrementAndGet());
|
||||
accommodationDb.put(a.getAccommodationId(), a);
|
||||
return 1;
|
||||
}
|
||||
if ("updateTrainAccommodation".equals(name)) {
|
||||
TrainAccommodation a = (TrainAccommodation) args[0];
|
||||
accommodationDb.put(a.getAccommodationId(), a);
|
||||
return 1;
|
||||
}
|
||||
if ("updateAccommodationStatus".equals(name)) {
|
||||
Long aid = (Long) args[0];
|
||||
Integer st = (Integer) args[1];
|
||||
TrainAccommodation a = accommodationDb.get(aid);
|
||||
if (a != null) {
|
||||
a.setAccommodationStatus(st);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if ("selectTrainAccommodationList".equals(name)) {
|
||||
return new ArrayList<>(accommodationDb.values());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainDormAuthMapper createMockDormAuthMapper() {
|
||||
return (TrainDormAuthMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainDormAuthMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("selectTrainDormAuthByAccommodationId".equals(name)) {
|
||||
return dormAuthDb.get((Long) args[0]);
|
||||
}
|
||||
if ("selectTrainDormAuthByAuthId".equals(name)) {
|
||||
Long aid = (Long) args[0];
|
||||
return dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null);
|
||||
}
|
||||
if ("insertTrainDormAuth".equals(name)) {
|
||||
TrainDormAuth a = (TrainDormAuth) args[0];
|
||||
if (a.getAuthId() == null) a.setAuthId(authSeq.incrementAndGet());
|
||||
dormAuthDb.put(a.getAccommodationId(), a);
|
||||
return 1;
|
||||
}
|
||||
if ("updateTrainDormAuth".equals(name)) {
|
||||
TrainDormAuth a = (TrainDormAuth) args[0];
|
||||
TrainDormAuth exist = null;
|
||||
if (a.getAccommodationId() != null) {
|
||||
exist = dormAuthDb.get(a.getAccommodationId());
|
||||
}
|
||||
if (exist == null && a.getAuthId() != null) {
|
||||
exist = dormAuthDb.values().stream().filter(x -> a.getAuthId().equals(x.getAuthId())).findFirst().orElse(null);
|
||||
}
|
||||
if (exist != null) {
|
||||
if (a.getAuthStatus() != null) exist.setAuthStatus(a.getAuthStatus());
|
||||
if (a.getLastError() != null) exist.setLastError(a.getLastError());
|
||||
if (a.getLatestOperationId() != null) exist.setLatestOperationId(a.getLatestOperationId());
|
||||
if (a.getExternalAuthId() != null) exist.setExternalAuthId(a.getExternalAuthId());
|
||||
if (a.getUpdateBy() != null) exist.setUpdateBy(a.getUpdateBy());
|
||||
if (a.getUpdateTime() != null) exist.setUpdateTime(a.getUpdateTime());
|
||||
return 1;
|
||||
} else if (a.getAccommodationId() != null) {
|
||||
dormAuthDb.put(a.getAccommodationId(), a);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if ("clearLastError".equals(name)) {
|
||||
Long aid = (Long) args[0];
|
||||
TrainDormAuth a = dormAuthDb.values().stream().filter(x -> aid.equals(x.getAuthId())).findFirst().orElse(null);
|
||||
if (a != null) a.setLastError(null);
|
||||
return 1;
|
||||
}
|
||||
if ("updateAuthStatusByPoller".equals(name)) {
|
||||
Long authId = (Long) args[0];
|
||||
String newStatus = (String) args[1];
|
||||
Long opId = (Long) args[2];
|
||||
String opType = (String) args[3];
|
||||
String lastError = (String) args[4];
|
||||
String operator = (String) args[5];
|
||||
|
||||
TrainDormAuth a = dormAuthDb.values().stream().filter(x -> authId.equals(x.getAuthId())).findFirst().orElse(null);
|
||||
if (a == null) return 0;
|
||||
|
||||
if (opId != null && a.getLatestOperationId() != null && !opId.equals(a.getLatestOperationId())) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (TrainExternalOperation.OP_TYPE_GRANT.equals(opType) && TrainDormAuth.AUTH_STATUS_ACTIVE.equals(newStatus)) {
|
||||
if (!TrainDormAuth.AUTH_STATUS_GRANTING.equals(a.getAuthStatus())) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (TrainExternalOperation.OP_TYPE_REVOKE.equals(opType) && TrainDormAuth.AUTH_STATUS_REVOKED.equals(newStatus)) {
|
||||
if (!TrainDormAuth.AUTH_STATUS_REVOKING.equals(a.getAuthStatus())) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
a.setAuthStatus(newStatus);
|
||||
if (lastError != null) a.setLastError(lastError);
|
||||
return 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainEnrollmentMapper createMockEnrollmentMapper() {
|
||||
return (TrainEnrollmentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainEnrollmentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainEnrollmentById".equals(method.getName())) {
|
||||
Long eid = (Long) args[0];
|
||||
TrainEnrollment e = enrollmentDb.get(eid);
|
||||
if (e != null) return e;
|
||||
TrainEnrollment dummy = new TrainEnrollment();
|
||||
dummy.setEnrollmentId(eid);
|
||||
dummy.setStudentId("STU001");
|
||||
dummy.setPlanId("PLAN001");
|
||||
dummy.setEnrollmentStatus(1);
|
||||
return dummy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainStudentMapper createMockStudentMapper() {
|
||||
return (TrainStudentMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainStudentMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainStudentBySId".equals(method.getName())) {
|
||||
return studentDb.get((String) args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainClassPlanMapper createMockClassPlanMapper() {
|
||||
return (TrainClassPlanMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainClassPlanMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainClassPlanByPlanId".equals(method.getName())) {
|
||||
return planDb.get((String) args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private IExternalResourceMappingService createMockMappingService() {
|
||||
return (IExternalResourceMappingService) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{IExternalResourceMappingService.class},
|
||||
(proxy, method, args) -> {
|
||||
String name = method.getName();
|
||||
if ("getActiveRoomMapping".equals(name)) {
|
||||
String dormId = (String) args[1];
|
||||
return resourceMappingDb.get("ROOM:" + dormId);
|
||||
}
|
||||
if ("getActiveLockMappings".equals(name)) {
|
||||
String dormId = (String) args[1];
|
||||
List<TrainExternalResourceMapping> locks = new ArrayList<>();
|
||||
for (Map.Entry<String, TrainExternalResourceMapping> entry : resourceMappingDb.entrySet()) {
|
||||
if (entry.getKey().startsWith("LOCK:" + dormId)) {
|
||||
locks.add(entry.getValue());
|
||||
}
|
||||
}
|
||||
return locks;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private TrainDormitoryInfoMapper createMockDormitoryInfoMapper() {
|
||||
return (TrainDormitoryInfoMapper) Proxy.newProxyInstance(
|
||||
getClass().getClassLoader(),
|
||||
new Class<?>[]{TrainDormitoryInfoMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) {
|
||||
String dormId = (String) args[0];
|
||||
TrainDormitoryInfo d = new TrainDormitoryInfo();
|
||||
d.setDormId(dormId);
|
||||
d.setCapacity(4);
|
||||
d.setDormStatus(1);
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private void setPrivateField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field f = target.getClass().getDeclaredField(fieldName);
|
||||
f.setAccessible(true);
|
||||
f.set(target, value);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -258,11 +258,11 @@ public class PreCustomerStabilizationFinalFixesContractTest
|
||||
assertNotNull(accPre);
|
||||
assertEquals("@ss.hasPermi('train:accommodation:remove')", accPre.value());
|
||||
|
||||
// 门禁撤销:TrainDormAuthController 彻底移除写接口 revoke,收归至 TrainAccessManagementController
|
||||
// 门禁控制:TrainDormAuthController 彻底移除,统一收归至 TrainAccessManagementController (MIN-08)
|
||||
try {
|
||||
TrainDormAuthController.class.getMethod("revoke", Long.class);
|
||||
fail("TrainDormAuthController 不得存在 revoke 写接口");
|
||||
} catch (NoSuchMethodException expected) {}
|
||||
Class.forName("com.ruoyi.web.controller.train.TrainDormAuthController");
|
||||
fail("MIN-08: TrainDormAuthController 必须彻底删除,统一收归至 TrainAccessManagementController");
|
||||
} catch (ClassNotFoundException expected) {}
|
||||
|
||||
|
||||
// 成绩删除
|
||||
|
||||
+27
@@ -831,6 +831,33 @@ public class AccessAuthorizationOrchestrationTest {
|
||||
}
|
||||
return op;
|
||||
}
|
||||
if ("claimOperationForDispatch".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null && TrainExternalOperation.STATUS_CREATED.equals(op.getOperationStatus())) {
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_DISPATCHING);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if ("selectLatestGrantOperationByDoorAuthId".equals(name)) {
|
||||
Long authId = (Long) args[0];
|
||||
TrainExternalOperation latest = null;
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (authId.equals(op.getDoorAuthId()) && TrainExternalOperation.OP_TYPE_GRANT.equals(op.getOperationType())) {
|
||||
if (latest == null || (op.getOperationId() != null && op.getOperationId() > latest.getOperationId())) {
|
||||
latest = op;
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
if ("markStaleDispatchingAsAmbiguous".equals(name)) {
|
||||
return 0;
|
||||
}
|
||||
if ("selectStaleDispatchingOperations".equals(name)) {
|
||||
return new ArrayList<TrainExternalOperation>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
+38
@@ -852,6 +852,17 @@ public class DmsMockE2EScenariosSuiteTest {
|
||||
if ("selectTrainAccommodationByAccommodationId".equals(name)) {
|
||||
return accommodationDb.get((Long) args[0]);
|
||||
}
|
||||
if ("countActiveByDormIdAndBedNo".equals(name)) {
|
||||
String did = (String) args[0];
|
||||
String bno = (String) args[1];
|
||||
Long excId = (Long) args[2];
|
||||
if (did == null || bno == null || bno.trim().isEmpty()) return 0;
|
||||
return (int) accommodationDb.values().stream()
|
||||
.filter(a -> (excId == null || !excId.equals(a.getAccommodationId()))
|
||||
&& did.equals(a.getDormId()) && bno.equals(a.getBedNo())
|
||||
&& a.getAccommodationStatus() != null && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1))
|
||||
.count();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
@@ -992,6 +1003,33 @@ public class DmsMockE2EScenariosSuiteTest {
|
||||
}
|
||||
return op;
|
||||
}
|
||||
if ("claimOperationForDispatch".equals(name)) {
|
||||
Long opId = (Long) args[0];
|
||||
TrainExternalOperation op = operationDb.get(opId);
|
||||
if (op != null && TrainExternalOperation.STATUS_CREATED.equals(op.getOperationStatus())) {
|
||||
op.setOperationStatus(TrainExternalOperation.STATUS_DISPATCHING);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if ("selectLatestGrantOperationByDoorAuthId".equals(name)) {
|
||||
Long authId = (Long) args[0];
|
||||
TrainExternalOperation latest = null;
|
||||
for (TrainExternalOperation op : operationDb.values()) {
|
||||
if (authId.equals(op.getDoorAuthId()) && TrainExternalOperation.OP_TYPE_GRANT.equals(op.getOperationType())) {
|
||||
if (latest == null || (op.getOperationId() != null && op.getOperationId() > latest.getOperationId())) {
|
||||
latest = op;
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
if ("markStaleDispatchingAsAmbiguous".equals(name)) {
|
||||
return 0;
|
||||
}
|
||||
if ("selectStaleDispatchingOperations".equals(name)) {
|
||||
return new ArrayList<TrainExternalOperation>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user