diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java index ed3988570..e6fb5df42 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/AccommodationAndAuthApiControllerContractTest.java @@ -89,54 +89,53 @@ public class AccommodationAndAuthApiControllerContractTest } /** - * 验证 TrainDormAuthController 的现有路径兼容性契约 + * 验证 TrainAccessManagementController 统一门禁契约与权限边界 (MIN-08 / MIN-09) */ @Test public void testDormAuthControllerContract() throws Exception { - Class 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 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()); } } diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java index c89504294..20130c526 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/CredentialOneTimeDisplaySecurityTest.java @@ -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 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(); diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsAccessSafetyPackageATest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsAccessSafetyPackageATest.java index d67ef6e17..0fc60c64b 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsAccessSafetyPackageATest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsAccessSafetyPackageATest.java @@ -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(); + } return null; } ); diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java new file mode 100644 index 000000000..f1befabcf --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsConsistencyHardeningPackageA1Test.java @@ -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 enrollmentDb; + private Map accommodationDb; + private Map dormAuthDb; + private Map studentDb; + private Map planDb; + private Map resourceMappingDb; + private Map 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> 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 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 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(); + } + 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 locks = new ArrayList<>(); + for (Map.Entry 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); + } +} diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java index fc30b3141..3d0d07c7b 100644 --- a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/PreCustomerStabilizationFinalFixesContractTest.java @@ -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) {} // 成绩删除 diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java index 3ec50cf11..2a4a8f12e 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java @@ -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(); + } return null; } ); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java index 2feeb87fa..5244cd6dd 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/DmsMockE2EScenariosSuiteTest.java @@ -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(); + } return null; } );