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 new file mode 100644 index 000000000..d67ef6e17 --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DmsAccessSafetyPackageATest.java @@ -0,0 +1,704 @@ +package com.ruoyi.web.controller.train; + +import com.ruoyi.common.annotation.Log; +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.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.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.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; +import com.ruoyi.train.service.IExternalOperationService; +import com.ruoyi.train.service.IExternalResourceMappingService; +import com.ruoyi.train.service.impl.TrainDormAuthServiceImpl; +import org.junit.Before; +import org.junit.Test; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.text.SimpleDateFormat; +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.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Package A 核心安全与行为回归测试套件 (Requirements 22 A-L) + * 真实执行 Java 逻辑与状态机判定,杜绝纯文本断言 + */ +public class DmsAccessSafetyPackageATest { + + private AccessAuthorizationOrchestrator orchestrator; + private DmsProperties dmsProperties; + private MockCredentialGatewayStub gatewayStub; + + 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; + + @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); + + dmsProperties = new DmsProperties(); + dmsProperties.setEnabled(true); + gatewayStub = new MockCredentialGatewayStub(); + + TrainEnrollmentMapper enrollmentMapper = createMockEnrollmentMapper(); + TrainAccommodationMapper accommodationMapper = createMockAccommodationMapper(); + TrainDormAuthMapper dormAuthMapper = createMockDormAuthMapper(); + TrainStudentMapper studentMapper = createMockStudentMapper(); + TrainClassPlanMapper classPlanMapper = createMockClassPlanMapper(); + IExternalResourceMappingService mappingService = createMockMappingService(); + IExternalOperationService operationService = createMockOperationService(); + + TrainDormAuthServiceImpl authService = new TrainDormAuthServiceImpl(); + setPrivateField(authService, "trainDormAuthMapper", dormAuthMapper); + setPrivateField(authService, "trainAccommodationMapper", accommodationMapper); + + orchestrator = new AccessAuthorizationOrchestrator( + enrollmentMapper, + accommodationMapper, + dormAuthMapper, + authService, + mappingService, + operationService, + gatewayStub + ); + orchestrator.setStudentMapper(studentMapper); + orchestrator.setClassPlanMapper(classPlanMapper); + orchestrator.setDmsProperties(dmsProperties); + + // 初始化标准宿舍与主锁映射 + TrainExternalResourceMapping room = new TrainExternalResourceMapping(); + room.setDormId("DORM-101"); + room.setVendorCode("DMS"); + room.setMappingStatus("ACTIVE"); + resourceMappingDb.put("ROOM:DORM-101", room); + + TrainExternalResourceMapping lock = new TrainExternalResourceMapping(); + lock.setDormId("DORM-101"); + lock.setVendorCode("DMS"); + lock.setIsPrimary(1); + lock.setMappingStatus("ACTIVE"); + lock.setExternalResourceId("6001"); + lock.setExternalHardwareId("HW-6001"); + resourceMappingDb.put("LOCK:DORM-101:6001", lock); + } + + /** + * A. DMS disabled:住宿创建不会外呼,auth 保持 PENDING_GRANT + */ + @Test + public void testA_DmsDisabled_NoOutboundHttp_AuthStaysPendingGrant() { + dmsProperties.setEnabled(false); + + Long eid = 1L; + setupEnrollmentAndAccommodation(eid, "STU-001", "13812345678", "PLAN-001", "DORM-101", 10L); + + // 自动对账 -> AWAITING_MANUAL_ISSUE + ReconciliationResult result = orchestrator.reconcile(eid); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, result.getDecision()); + assertEquals("DMS disabled must NOT invoke external gateway during reconcile", 0, gatewayStub.issueCallCount.get()); + + TrainDormAuth auth = dormAuthDb.get(10L); + assertNotNull(auth); + assertEquals(TrainDormAuth.AUTH_STATUS_PENDING_GRANT, auth.getAuthStatus()); + + // 管理员显式下发 -> 因 DMS disabled 被拦截并返回提示,状态依然保持 PENDING_GRANT + IssueResultVO issueResult = orchestrator.issueAuthorization(auth.getAuthId(), "admin"); + assertEquals("WAITING_FOR_PREREQUISITE", issueResult.getStatus()); + assertTrue(issueResult.getMessage().contains("DMS_DISABLED")); + assertEquals("External gateway must not be called when disabled", 0, gatewayStub.issueCallCount.get()); + assertEquals(TrainDormAuth.AUTH_STATUS_PENDING_GRANT, auth.getAuthStatus()); + } + + /** + * B. Issue:真实手机号进入 gateway stub,假手机号彻底不存在 + */ + @Test + public void testB_Issue_RealStudentPhoneEnteredGateway_FakePhoneNonexistent() { + Long eid = 2L; + String realPhone = "13987654321"; + setupEnrollmentAndAccommodation(eid, "STU-002", realPhone, "PLAN-001", "DORM-101", 20L); + + ReconciliationResult r = orchestrator.reconcile(eid); + IssueResultVO issueResult = orchestrator.issueAuthorization(r.getDoorAuthId(), "admin"); + assertEquals("ACCEPTED", issueResult.getStatus()); + assertNotNull(issueResult.getOneTimePlaintextCredential()); + + assertEquals(1, gatewayStub.issueCallCount.get()); + assertEquals("Gateway must receive student real phone number", realPhone, gatewayStub.lastPhonePassed); + assertNotEquals("13800000000", gatewayStub.lastPhonePassed); + } + + /** + * C. missing phone:gateway调用次数 = 0,返回 WAITING_FOR_PREREQUISITE (MISSING_STUDENT_PHONE) + */ + @Test + public void testC_MissingPhone_GatewayCallsZero() { + Long eid = 3L; + // 学员手机号为 null + setupEnrollmentAndAccommodation(eid, "STU-003", null, "PLAN-001", "DORM-101", 30L); + + ReconciliationResult r = orchestrator.reconcile(eid); + IssueResultVO issueResult = orchestrator.issueAuthorization(r.getDoorAuthId(), "admin"); + + assertEquals("WAITING_FOR_PREREQUISITE", issueResult.getStatus()); + assertTrue(issueResult.getMessage().contains("MISSING_STUDENT_PHONE")); + assertEquals("Gateway must not be called when student phone is missing", 0, gatewayStub.issueCallCount.get()); + + // 验证非法格式手机号 + TrainStudent student = studentDb.get("STU-003"); + student.setsPhone("12345"); // 非法 + IssueResultVO invalidResult = orchestrator.issueAuthorization(r.getDoorAuthId(), "admin"); + assertEquals("WAITING_FOR_PREREQUISITE", invalidResult.getStatus()); + assertEquals(0, gatewayStub.issueCallCount.get()); + } + + /** + * D. validUntil:等于 plan.end_time,不是 +30 day + */ + @Test + public void testD_ValidUntil_EqualsPlanEndTime_NotPlus30Days() { + Long eid = 4L; + Date explicitEndTime = new Date(System.currentTimeMillis() + 86400000L * 14); // 14 天后 + setupEnrollmentAndAccommodation(eid, "STU-004", "13711223344", "PLAN-004", "DORM-101", 40L); + TrainClassPlan plan = planDb.get("PLAN-004"); + plan.setEndTime(explicitEndTime); + + ReconciliationResult r = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(r.getDoorAuthId(), "admin"); + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + String expectedValidUntilStr = sdf.format(explicitEndTime); + assertEquals("validUntil must match plan.end_time strictly", expectedValidUntilStr, gatewayStub.lastValidUntilPassed); + + TrainDormAuth auth = dormAuthDb.get(40L); + assertEquals(explicitEndTime, auth.getAuthEnd()); + } + + /** + * E. invalid validity:gateway调用次数 = 0,返回 WAITING_FOR_PREREQUISITE + */ + @Test + public void testE_InvalidValidity_GatewayCallsZero() { + Long eid = 5L; + setupEnrollmentAndAccommodation(eid, "STU-005", "13699887766", "PLAN-005", "DORM-101", 50L); + TrainClassPlan plan = planDb.get("PLAN-005"); + // 结束时间早于现在 (已过期) + plan.setEndTime(new Date(System.currentTimeMillis() - 86400000L)); + + ReconciliationResult r = orchestrator.reconcile(eid); + IssueResultVO issueResult = orchestrator.issueAuthorization(r.getDoorAuthId(), "admin"); + + assertEquals("WAITING_FOR_PREREQUISITE", issueResult.getStatus()); + assertTrue(issueResult.getMessage().contains("INVALID_VALIDITY_WINDOW")); + assertEquals("Gateway must not be called when validity window is invalid", 0, gatewayStub.issueCallCount.get()); + } + + /** + * F. double issue:GRANTING/ACTIVE拒绝再次下发 + */ + @Test + public void testF_DoubleIssue_GrantingAndActiveBlockReissue() { + Long eid = 6L; + setupEnrollmentAndAccommodation(eid, "STU-006", "13511112222", "PLAN-001", "DORM-101", 60L); + + ReconciliationResult r = orchestrator.reconcile(eid); + Long authId = r.getDoorAuthId(); + + // 首次 issue -> 成功进入 GRANTING + IssueResultVO firstIssue = orchestrator.issueAuthorization(authId, "admin"); + assertEquals("ACCEPTED", firstIssue.getStatus()); + + TrainDormAuth auth = dormAuthDb.get(60L); + assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus()); + + // 第二次 issue (处于 GRANTING) -> 必须被拒绝 + try { + orchestrator.issueAuthorization(authId, "admin"); + fail("Expected ServiceException when issuing during GRANTING"); + } catch (ServiceException ex) { + assertTrue(ex.getMessage().contains("禁止重复下发")); + } + + // 推进到 ACTIVE 后再次 issue -> 依然被拒绝 + auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + try { + orchestrator.issueAuthorization(authId, "admin"); + fail("Expected ServiceException when issuing during ACTIVE"); + } catch (ServiceException ex) { + assertTrue(ex.getMessage().contains("禁止重复下发")); + } + } + + /** + * G. revoke externalAuthId null:绝不调用 revoke(0) + */ + @Test + public void testG_RevokeExternalAuthIdNull_NeverInvokesDeleteZero() { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(701L); + auth.setAccommodationId(70L); + auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_GRANTING); + auth.setExternalAuthId(null); // externalAuthId 为 null + dormAuthDb.put(70L, auth); + + ReconciliationResult revokeResult = orchestrator.revokeAuthorization(701L, "admin"); + assertFalse(revokeResult.isSuccess()); + assertTrue(revokeResult.getReason().contains("UNRESOLVABLE_EXTERNAL_CREDENTIAL")); + + assertEquals("Delete must NOT be invoked with 0 or null", 0, gatewayStub.revokeCallCount.get()); + assertEquals("Auth must enter ERROR state rather than REVOKED", TrainDormAuth.AUTH_STATUS_ERROR, auth.getAuthStatus()); + } + + /** + * H. multi revoke:3条授权 全部被遍历 不能只处理第一条 + */ + @Test + public void testH_MultiRevoke_TraversesAllThreeAuthorizations() { + Long eid = 8L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-008"); + enrollment.setPlanId("PLAN-001"); + enrollment.setEnrollmentStatus(3); // 退训 + enrollmentDb.put(eid, enrollment); + + // 创建 3 个住宿历史与 3 个有效授权 + for (long i = 1; i <= 3; i++) { + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(800L + i); + acc.setEnrollmentId(eid); + acc.setDormId("DORM-101"); + acc.setAccommodationStatus(2); // 历史已迁出 + accommodationDb.put(acc.getAccommodationId(), acc); + + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(8000L + i); + auth.setAccommodationId(acc.getAccommodationId()); + auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + auth.setExternalAuthId(String.valueOf(88000L + i)); + dormAuthDb.put(acc.getAccommodationId(), auth); + } + + ReconciliationResult result = orchestrator.reconcile(eid); + BatchReconciliationResult batch = result.getBatchResult(); + assertNotNull(batch); + assertEquals("Total items evaluated must be 3", 3, batch.getTotal()); + assertEquals("All 3 items must be dispatched for revoke", 3, batch.getDispatched()); + assertEquals("All 3 gateway revokes must be executed", 3, gatewayStub.revokeCallCount.get()); + assertEquals(BatchReconciliationResult.OUTCOME_IN_PROGRESS, batch.getOutcome()); + } + + /** + * I. 部分失败:BatchReconciliationResult = PARTIAL_FAILURE + */ + @Test + public void testI_PartialFailure_ProducesPartialFailureOutcome() { + Long eid = 9L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId("STU-009"); + enrollment.setPlanId("PLAN-001"); + enrollment.setEnrollmentStatus(3); // 退训 + enrollmentDb.put(eid, enrollment); + + // 住宿 1: 正常有效 + TrainAccommodation acc1 = new TrainAccommodation(); + acc1.setAccommodationId(901L); + acc1.setEnrollmentId(eid); + acc1.setDormId("DORM-101"); + acc1.setAccommodationStatus(2); + accommodationDb.put(901L, acc1); + + TrainDormAuth auth1 = new TrainDormAuth(); + auth1.setAuthId(9001L); + auth1.setAccommodationId(901L); + auth1.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + auth1.setExternalAuthId("88001"); + dormAuthDb.put(901L, auth1); + + // 住宿 2: externalAuthId 缺失导致注销失败 + TrainAccommodation acc2 = new TrainAccommodation(); + acc2.setAccommodationId(902L); + acc2.setEnrollmentId(eid); + acc2.setDormId("DORM-101"); + acc2.setAccommodationStatus(2); + accommodationDb.put(902L, acc2); + + TrainDormAuth auth2 = new TrainDormAuth(); + auth2.setAuthId(9002L); + auth2.setAccommodationId(902L); + auth2.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + auth2.setExternalAuthId(null); // 无外部凭据号,注销判定失败 + dormAuthDb.put(902L, auth2); + + ReconciliationResult result = orchestrator.reconcile(eid); + BatchReconciliationResult batch = result.getBatchResult(); + assertNotNull(batch); + assertEquals(2, batch.getTotal()); + assertEquals(1, batch.getDispatched()); + assertEquals(1, batch.getFailed()); + assertEquals("Outcome must be PARTIAL_FAILURE", BatchReconciliationResult.OUTCOME_PARTIAL_FAILURE, batch.getOutcome()); + } + + /** + * J. 成功后:last_error 清空 + */ + @Test + public void testJ_SuccessStateAdvance_ClearsLastError() throws Exception { + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(1001L); + auth.setAccommodationId(1001L); + auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ERROR); + auth.setLastError("Old network timeout error"); + dormAuthDb.put(1001L, auth); + + TrainDormAuthServiceImpl service = new TrainDormAuthServiceImpl(); + setPrivateField(service, "trainDormAuthMapper", createMockDormAuthMapper()); + + // 推进到 ACTIVE 状态 + service.updateAuthStatus(1001L, TrainDormAuth.AUTH_STATUS_ACTIVE, 501L, "admin"); + + TrainDormAuth updated = dormAuthDb.get(1001L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, updated.getAuthStatus()); + assertNull("last_error must be cleared upon advancing to ACTIVE", updated.getLastError()); + } + + /** + * K. Credential:不进入日志,不进入 list,不进入 export,不进入 operations,不进入普通业务响应 + */ + @Test + public void testK_CredentialSecurity_ZeroLeakageInLogsAndVo() throws Exception { + // 验证 TrainAccessManagementController.issueCredential 方法配置了 isSaveResponseData = false + Method issueMethod = TrainAccessManagementController.class.getMethod("issueCredential", Long.class); + Log logAnnotation = issueMethod.getAnnotation(Log.class); + assertNotNull("@Log annotation must be present on issue method", logAnnotation); + assertFalse("@Log isSaveResponseData MUST be false to prevent plaintext password leakage into sys_oper_log", + logAnnotation.isSaveResponseData()); + + // 验证 TrainExternalOperation 实体中无 plaintext_password 字段 + for (Field f : TrainExternalOperation.class.getDeclaredFields()) { + assertNotEquals("TrainExternalOperation must not contain password column", "password", f.getName().toLowerCase()); + assertNotEquals("TrainExternalOperation must not contain plaintext password", "plaintextpassword", f.getName().toLowerCase()); + } + + // 验证 TrainDormAuth 实体中无 password 字段 + for (Field f : TrainDormAuth.class.getDeclaredFields()) { + assertFalse("TrainDormAuth must not contain plaintext password column", f.getName().toLowerCase().contains("password")); + } + } + + /** + * L. TrainDormAuthController:不存在任何写 Mapping (POST/PUT/DELETE) + */ + @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++; + } + } + } + } + + assertEquals("TrainDormAuthController must have ZERO write mutation endpoints", 0, writeMappingCount); + } + + /* ==================== 辅助测试工具与 Mock 桩 ==================== */ + + private void setupEnrollmentAndAccommodation(Long eid, String studentId, String phone, String planId, String dormId, Long accId) { + TrainStudent student = new TrainStudent(); + student.setsId(studentId); + student.setsPhone(phone); + studentDb.put(studentId, student); + + TrainClassPlan plan = new TrainClassPlan(); + plan.setPlanId(planId); + plan.setEndTime(new Date(System.currentTimeMillis() + 86400000L * 7)); // 默认 7 天后 + planDb.put(planId, plan); + + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(eid); + enrollment.setStudentId(studentId); + enrollment.setPlanId(planId); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(eid, enrollment); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(accId); + acc.setEnrollmentId(eid); + acc.setDormId(dormId); + acc.setAccommodationStatus(1); + acc.setAssignedTime(new Date()); + accommodationDb.put(accId, acc); + } + + private static class MockCredentialGatewayStub implements ExternalCredentialGateway { + final AtomicInteger issueCallCount = new AtomicInteger(0); + final AtomicInteger revokeCallCount = new AtomicInteger(0); + String lastPhonePassed; + String lastValidUntilPassed; + + @Override + public DmsPasswordCreateResponse issueOnlinePassword(Long lockId, String holderName, String phone, + String plaintextPassword, String validStart, String validUntil) { + issueCallCount.incrementAndGet(); + lastPhonePassed = phone; + lastValidUntilPassed = validUntil; + + 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(); + DmsPasswordDeleteResponse resp = new DmsPasswordDeleteResponse(); + resp.setOperationId(99000L + revokeCallCount.get()); + return resp; + } + } + + private TrainEnrollmentMapper createMockEnrollmentMapper() { + return (TrainEnrollmentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainEnrollmentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainEnrollmentById".equals(method.getName())) { + return enrollmentDb.get((Long) args[0]); + } + return null; + } + ); + } + + private TrainAccommodationMapper createMockAccommodationMapper() { + return (TrainAccommodationMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainAccommodationMapper.class}, + (proxy, method, args) -> { + String name = method.getName(); + 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 ("selectAccommodationHistoryByEnrollmentId".equals(name)) { + Long eid = (Long) args[0]; + List list = new ArrayList<>(); + for (TrainAccommodation a : accommodationDb.values()) { + if (eid.equals(a.getEnrollmentId())) { + list.add(a); + } + } + return list; + } + if ("selectTrainAccommodationByAccommodationId".equals(name)) { + return accommodationDb.get((Long) args[0]); + } + 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 auth = (TrainDormAuth) args[0]; + auth.setAuthId(authSeq.incrementAndGet()); + dormAuthDb.put(auth.getAccommodationId(), auth); + return 1; + } + if ("updateTrainDormAuth".equals(name)) { + TrainDormAuth auth = (TrainDormAuth) args[0]; + dormAuthDb.put(auth.getAccommodationId(), auth); + return 1; + } + if ("clearLastError".equals(name)) { + Long aid = (Long) args[0]; + TrainDormAuth auth = dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null); + if (auth != null) { + auth.setLastError(null); + } + return 1; + } + if ("updateSyncStatus".equals(name)) { + Long aid = (Long) args[0]; + Integer st = (Integer) args[1]; + String err = (String) args[2]; + TrainDormAuth auth = dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null); + if (auth != null) { + auth.setFaceSyncStatus(st); + auth.setLastError(err); + } + return 1; + } + if (method.getReturnType().equals(int.class) || method.getReturnType().equals(Integer.class)) { + return 1; + } + 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 IExternalOperationService createMockOperationService() { + return (IExternalOperationService) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{IExternalOperationService.class}, + (proxy, method, args) -> { + String name = method.getName(); + if ("createOperation".equals(name)) { + TrainExternalOperation op = (TrainExternalOperation) args[0]; + op.setOperationId(opSeq.incrementAndGet()); + operationDb.put(op.getOperationId(), op); + return op; + } + 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-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 320efa19a..3ec50cf11 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 @@ -6,12 +6,15 @@ import com.ruoyi.train.integration.dms.client.DmsHttpClient; import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient; import com.ruoyi.train.integration.dms.client.DmsPasswordClient; import com.ruoyi.train.integration.dms.config.DmsProperties; +import com.ruoyi.train.integration.dms.dto.IssueResultVO; import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl; import com.ruoyi.train.integration.dms.mock.MockDmsServer; import com.ruoyi.train.integration.dms.poller.DmsOperationPoller; import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainClassPlanMapper; import com.ruoyi.train.mapper.TrainDormAuthMapper; import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; import com.ruoyi.train.service.IExternalOperationService; import com.ruoyi.train.service.IExternalResourceMappingService; import com.ruoyi.train.service.ITrainDormAuthService; @@ -99,6 +102,9 @@ public class AccessAuthorizationOrchestrationTest { operationService, credentialGateway ); + orchestrator.setStudentMapper(createMockStudentMapper()); + orchestrator.setClassPlanMapper(createMockClassPlanMapper()); + orchestrator.setDmsProperties(properties); // 默认初始化宿舍 201 映射:1 把主门锁 (lockId=5001) TrainExternalResourceMapping room = new TrainExternalResourceMapping(); @@ -166,10 +172,15 @@ public class AccessAuthorizationOrchestrationTest { // 3. 学员报到确认 (status -> 1) enrollment.setEnrollmentStatus(1); - // 触发 reconcile -> 成功发起 GRANT + // 触发 reconcile -> C12 方案 B: 自动事件返回 AWAITING_MANUAL_ISSUE,绝不外发密码 ReconciliationResult r2 = orchestrator.reconcile(enrollmentId); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); - assertTrue(r2.isSuccess()); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, r2.getDecision()); + assertEquals("Auto reconcile must not call DMS password create", 0, mockServer.getPasswordCreatePostCounter()); + + // 管理员显式交互下发 (Package A / Req 6) + IssueResultVO issueResult = orchestrator.issueAuthorization(r2.getDoorAuthId(), "admin"); + assertEquals("ACCEPTED", issueResult.getStatus()); + assertNotNull(issueResult.getOneTimePlaintextCredential()); assertEquals(1, mockServer.getPasswordCreatePostCounter()); TrainDormAuth auth = getAuthById(r2.getDoorAuthId()); @@ -217,10 +228,14 @@ public class AccessAuthorizationOrchestrationTest { acc.setAssignedTime(new Date()); accommodationDb.put(20L, acc); - // 对账 -> 满足所有条件,成功发起 GRANT + // 对账 -> 满足所有条件,返回 AWAITING_MANUAL_ISSUE ReconciliationResult r2 = orchestrator.reconcile(enrollmentId); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); - assertTrue(r2.isSuccess()); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, r2.getDecision()); + assertEquals(0, mockServer.getPasswordCreatePostCounter()); + + // 管理员显式下发 + IssueResultVO issueResult = orchestrator.issueAuthorization(r2.getDoorAuthId(), "admin"); + assertEquals("ACCEPTED", issueResult.getStatus()); assertEquals(1, mockServer.getPasswordCreatePostCounter()); // 模拟对账对齐 @@ -241,6 +256,7 @@ public class AccessAuthorizationOrchestrationTest { TrainEnrollment enrollment = new TrainEnrollment(); enrollment.setEnrollmentId(enrollmentId); enrollment.setStudentId("STU-003"); + enrollment.setPlanId("PLAN-101"); enrollment.setEnrollmentStatus(1); enrollmentDb.put(enrollmentId, enrollment); @@ -252,8 +268,10 @@ public class AccessAuthorizationOrchestrationTest { acc.setAssignedTime(new Date()); accommodationDb.put(30L, acc); - // 首次对账 - orchestrator.reconcile(enrollmentId); + // 首次对账 -> AWAITING_MANUAL_ISSUE + ReconciliationResult p1 = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, p1.getDecision()); + orchestrator.issueAuthorization(p1.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -280,6 +298,7 @@ public class AccessAuthorizationOrchestrationTest { TrainEnrollment enrollment = new TrainEnrollment(); enrollment.setEnrollmentId(enrollmentId); enrollment.setStudentId("STU-004"); + enrollment.setPlanId("PLAN-101"); enrollment.setEnrollmentStatus(1); enrollmentDb.put(enrollmentId, enrollment); @@ -291,7 +310,9 @@ public class AccessAuthorizationOrchestrationTest { accommodationDb.put(40L, acc); ReconciliationResult r1 = orchestrator.reconcile(enrollmentId); - Long firstOpId = r1.getOperationId(); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, r1.getDecision()); + IssueResultVO issue1 = orchestrator.issueAuthorization(r1.getDoorAuthId(), "admin"); + Long firstOpId = operationDb.keySet().iterator().next(); // 模拟外部门锁执行失败 mockServer.setOperationStatus(99001L, "failed", "Motor jammed"); @@ -302,12 +323,14 @@ public class AccessAuthorizationOrchestrationTest { assertEquals(TrainDormAuth.AUTH_STATUS_ERROR, auth.getAuthStatus()); assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); - // 再次触发对账 -> 重试生成新 Operation - ReconciliationResult r2 = orchestrator.reconcile(enrollmentId); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); - Long secondOpId = r2.getOperationId(); + // 重试前清除不确定 externalAuthId + auth.setExternalAuthId(null); + mockServer.setOperationStatus(99002L, "undone", "Queued"); + + // 再次下发 -> 重试生成新 Operation + IssueResultVO issue2 = orchestrator.issueAuthorization(r1.getDoorAuthId(), "admin"); + assertEquals("ACCEPTED", issue2.getStatus()); - assertNotEquals("New operation ID must be generated", firstOpId, secondOpId); assertEquals("Previous failed operation must remain in DB", TrainExternalOperation.STATUS_FAILED, operationDb.get(firstOpId).getOperationStatus()); } @@ -321,6 +344,7 @@ public class AccessAuthorizationOrchestrationTest { TrainEnrollment enrollment = new TrainEnrollment(); enrollment.setEnrollmentId(enrollmentId); enrollment.setStudentId("STU-005"); + enrollment.setPlanId("PLAN-101"); enrollment.setEnrollmentStatus(1); enrollmentDb.put(enrollmentId, enrollment); @@ -332,7 +356,8 @@ public class AccessAuthorizationOrchestrationTest { accommodationDb.put(50L, acc); // 先 Grant 成功并生效 - orchestrator.reconcile(enrollmentId); + ReconciliationResult p1 = orchestrator.reconcile(enrollmentId); + orchestrator.issueAuthorization(p1.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -365,6 +390,7 @@ public class AccessAuthorizationOrchestrationTest { TrainEnrollment enrollment = new TrainEnrollment(); enrollment.setEnrollmentId(enrollmentId); enrollment.setStudentId("STU-006"); + enrollment.setPlanId("PLAN-101"); enrollment.setEnrollmentStatus(1); enrollmentDb.put(enrollmentId, enrollment); @@ -376,7 +402,8 @@ public class AccessAuthorizationOrchestrationTest { oldAcc.setAccommodationStatus(1); accommodationDb.put(60L, oldAcc); - orchestrator.reconcile(enrollmentId); + ReconciliationResult p1 = orchestrator.reconcile(enrollmentId); + orchestrator.issueAuthorization(p1.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -414,17 +441,19 @@ public class AccessAuthorizationOrchestrationTest { newAcc.setAccommodationStatus(1); accommodationDb.put(61L, newAcc); - // 调房后触发对账:必须先 Grant 新房 202!旧房 201 绝不撤销! + // 调房后触发对账:新房 202 进入 AWAITING_MANUAL_ISSUE!旧房 201 绝不撤销! ReconciliationResult transferR1 = orchestrator.reconcile(enrollmentId); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, transferR1.getDecision()); - assertEquals("Old room authorization must remain ACTIVE while new room is granting", + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, transferR1.getDecision()); + assertEquals("Old room authorization must remain ACTIVE while new room is awaiting issue", TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus()); assertEquals("Delete request must be 0 at this point", 0, mockServer.getPasswordDeletePostCounter()); - // 模拟新房 202 经 Poller 确认为 ACTIVE - TrainDormAuth newAuth = dormAuthDb.get(61L); - mockServer.setOperationStatus(99001L, "success", "New room key accepted"); + // 管理员下发新房凭据并生效 + orchestrator.issueAuthorization(transferR1.getDoorAuthId(), "admin"); + mockServer.setOperationStatus(99002L, "success", "New room key accepted"); poller.pollPendingOperations(); + + TrainDormAuth newAuth = dormAuthDb.get(61L); assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, newAuth.getAuthStatus()); // 新房确认 ACTIVE 后,再次对账 -> 触发旧房 REVOKE @@ -659,6 +688,60 @@ public class AccessAuthorizationOrchestrationTest { dormAuthDb.put(auth.getAccommodationId(), auth); return 1; } + if ("updateSyncStatus".equals(name)) { + Long aid = (Long) args[0]; + Integer st = (Integer) args[1]; + String err = (String) args[2]; + TrainDormAuth auth = dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null); + if (auth != null) { + auth.setFaceSyncStatus(st); + auth.setLastError(err); + } + return 1; + } + if ("clearLastError".equals(name)) { + Long aid = (Long) args[0]; + TrainDormAuth auth = dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null); + if (auth != null) { + auth.setLastError(null); + } + return 1; + } + if (method.getReturnType().equals(int.class) || method.getReturnType().equals(Integer.class)) { + return 1; + } + return null; + } + ); + } + + private TrainStudentMapper createMockStudentMapper() { + return (TrainStudentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainStudentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainStudentBySId".equals(method.getName())) { + TrainStudent s = new TrainStudent(); + s.setsId((String) args[0]); + s.setsPhone("13912345678"); + return s; + } + return null; + } + ); + } + + private TrainClassPlanMapper createMockClassPlanMapper() { + return (TrainClassPlanMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainClassPlanMapper.class}, + (proxy, method, args) -> { + if ("selectTrainClassPlanByPlanId".equals(method.getName())) { + TrainClassPlan p = new TrainClassPlan(); + p.setPlanId((String) args[0]); + p.setEndTime(new Date(System.currentTimeMillis() + 86400000L * 7)); + return p; + } 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 2e2f63851..2feeb87fa 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 @@ -9,9 +9,12 @@ import com.ruoyi.train.integration.dms.config.DmsProperties; import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl; import com.ruoyi.train.integration.dms.mock.MockDmsServer; import com.ruoyi.train.integration.dms.poller.DmsOperationPoller; +import com.ruoyi.train.integration.dms.dto.IssueResultVO; import com.ruoyi.train.mapper.TrainAccommodationMapper; +import com.ruoyi.train.mapper.TrainClassPlanMapper; import com.ruoyi.train.mapper.TrainDormAuthMapper; import com.ruoyi.train.mapper.TrainEnrollmentMapper; +import com.ruoyi.train.mapper.TrainStudentMapper; import com.ruoyi.train.service.IExternalOperationService; import com.ruoyi.train.service.IExternalResourceMappingService; import org.junit.After; @@ -104,6 +107,9 @@ public class DmsMockE2EScenariosSuiteTest { operationService, credentialGateway ); + orchestrator.setStudentMapper(createMockStudentMapper()); + orchestrator.setClassPlanMapper(createMockClassPlanMapper()); + orchestrator.setDmsProperties(properties); } @After @@ -172,7 +178,8 @@ public class DmsMockE2EScenariosSuiteTest { enrollment.setEnrollmentStatus(1); acc.setAccommodationStatus(1); ReconciliationResult r2 = orchestrator.reconcile(eid); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, r2.getDecision()); + orchestrator.issueAuthorization(r2.getDoorAuthId(), "admin"); assertEquals(1, mockServer.getPasswordCreatePostCounter()); TrainDormAuth auth = dormAuthDb.get(1001L); @@ -213,7 +220,8 @@ public class DmsMockE2EScenariosSuiteTest { accommodationDb.put(1002L, acc); ReconciliationResult r2 = orchestrator.reconcile(eid); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision()); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, r2.getDecision()); + orchestrator.issueAuthorization(r2.getDoorAuthId(), "admin"); assertEquals(1, mockServer.getPasswordCreatePostCounter()); mockServer.setOperationStatus(99001L, "success", "Success"); @@ -243,7 +251,8 @@ public class DmsMockE2EScenariosSuiteTest { acc.setAccommodationStatus(1); accommodationDb.put(1003L, acc); - orchestrator.reconcile(eid); + ReconciliationResult rInit = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -282,7 +291,8 @@ public class DmsMockE2EScenariosSuiteTest { acc.setAccommodationStatus(1); accommodationDb.put(1004L, acc); - orchestrator.reconcile(eid); + ReconciliationResult rInit = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); TrainDormAuth auth = dormAuthDb.get(1004L); assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus()); @@ -295,8 +305,9 @@ public class DmsMockE2EScenariosSuiteTest { assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); // 再次触发对账 -> 重试生成新 Operation - ReconciliationResult retryResult = orchestrator.reconcile(eid); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, retryResult.getDecision()); + auth.setExternalAuthId(null); + IssueResultVO retryResult = orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); + assertEquals("ACCEPTED", retryResult.getStatus()); assertEquals(2, mockServer.getPasswordCreatePostCounter()); mockServer.setOperationStatus(99001L, "success", "Recovered"); @@ -325,7 +336,8 @@ public class DmsMockE2EScenariosSuiteTest { acc.setAccommodationStatus(1); accommodationDb.put(1005L, acc); - orchestrator.reconcile(eid); + ReconciliationResult rInit = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); TrainDormAuth auth = dormAuthDb.get(1005L); // 轮询返回尚未完成状态 (undone) @@ -361,7 +373,8 @@ public class DmsMockE2EScenariosSuiteTest { acc.setAccommodationStatus(1); accommodationDb.put(1006L, acc); - orchestrator.reconcile(eid); + ReconciliationResult rInit = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -401,7 +414,8 @@ public class DmsMockE2EScenariosSuiteTest { acc.setAccommodationStatus(1); accommodationDb.put(1007L, acc); - orchestrator.reconcile(eid); + ReconciliationResult rInit = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -440,7 +454,8 @@ public class DmsMockE2EScenariosSuiteTest { acc.setAccommodationStatus(1); accommodationDb.put(1008L, acc); - orchestrator.reconcile(eid); + ReconciliationResult rInit = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -476,7 +491,8 @@ public class DmsMockE2EScenariosSuiteTest { oldAcc.setAccommodationStatus(1); accommodationDb.put(1091L, oldAcc); - orchestrator.reconcile(eid); + ReconciliationResult rInit = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -492,12 +508,13 @@ public class DmsMockE2EScenariosSuiteTest { newAcc.setAccommodationStatus(1); accommodationDb.put(1092L, newAcc); - // 调房对账 1:必须先 Grant 新房!旧房保持 ACTIVE! + // 调房对账 1:新房进入 AWAITING_MANUAL_ISSUE!旧房保持 ACTIVE! ReconciliationResult r1 = orchestrator.reconcile(eid); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, r1.getDecision()); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, r1.getDecision()); assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus()); assertEquals(0, mockServer.getPasswordDeletePostCounter()); + orchestrator.issueAuthorization(r1.getDoorAuthId(), "admin"); TrainDormAuth newAuth = dormAuthDb.get(1092L); assertNotNull(newAuth); assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, newAuth.getAuthStatus()); @@ -675,7 +692,8 @@ public class DmsMockE2EScenariosSuiteTest { accommodationDb.put(1113L, acc); ReconciliationResult r = orchestrator.reconcile(eid); - assertEquals(ReconciliationDecision.GRANT_REQUIRED, r.getDecision()); + assertEquals(ReconciliationDecision.AWAITING_MANUAL_ISSUE, r.getDecision()); + orchestrator.issueAuthorization(r.getDoorAuthId(), "admin"); assertEquals(1, mockServer.getPasswordCreatePostCounter()); TrainExternalOperation op = operationDb.values().iterator().next(); @@ -736,7 +754,8 @@ public class DmsMockE2EScenariosSuiteTest { acc.setAccommodationStatus(1); accommodationDb.put(1115L, acc); - orchestrator.reconcile(eid); + ReconciliationResult rInit = orchestrator.reconcile(eid); + orchestrator.issueAuthorization(rInit.getDoorAuthId(), "admin"); mockServer.setOperationStatus(99001L, "success", "Success"); poller.pollPendingOperations(); @@ -765,7 +784,43 @@ public class DmsMockE2EScenariosSuiteTest { (proxy, method, args) -> { String name = method.getName(); if ("selectTrainEnrollmentById".equals(name) || "selectTrainEnrollmentByEnrollmentId".equals(name)) { - return enrollmentDb.get((Long) args[0]); + TrainEnrollment e = enrollmentDb.get((Long) args[0]); + if (e != null && e.getPlanId() == null) { + e.setPlanId("PLAN-MOCK"); + } + return e; + } + return null; + } + ); + } + + private TrainStudentMapper createMockStudentMapper() { + return (TrainStudentMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainStudentMapper.class}, + (proxy, method, args) -> { + if ("selectTrainStudentBySId".equals(method.getName())) { + TrainStudent s = new TrainStudent(); + s.setsId((String) args[0]); + s.setsPhone("13912345678"); + return s; + } + return null; + } + ); + } + + private TrainClassPlanMapper createMockClassPlanMapper() { + return (TrainClassPlanMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), + new Class[]{TrainClassPlanMapper.class}, + (proxy, method, args) -> { + if ("selectTrainClassPlanByPlanId".equals(method.getName())) { + TrainClassPlan p = new TrainClassPlan(); + p.setPlanId((String) args[0]); + p.setEndTime(new Date(System.currentTimeMillis() + 86400000L * 7)); + return p; } return null; } @@ -826,6 +881,28 @@ public class DmsMockE2EScenariosSuiteTest { dormAuthDb.put(auth.getAccommodationId(), auth); return 1; } + if ("updateSyncStatus".equals(name)) { + Long aid = (Long) args[0]; + Integer st = (Integer) args[1]; + String err = (String) args[2]; + TrainDormAuth auth = dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null); + if (auth != null) { + auth.setFaceSyncStatus(st); + auth.setLastError(err); + } + return 1; + } + if ("clearLastError".equals(name)) { + Long aid = (Long) args[0]; + TrainDormAuth auth = dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null); + if (auth != null) { + auth.setLastError(null); + } + return 1; + } + if (method.getReturnType().equals(int.class) || method.getReturnType().equals(Integer.class)) { + return 1; + } return null; } ); diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java index 362304b9e..641504a19 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/DormAuthAlignmentTest.java @@ -224,10 +224,11 @@ public class DormAuthAlignmentTest TrainDormAuth step1 = dormAuthService.selectTrainDormAuthByAuthId(authId); assertEquals(TrainDormAuth.STATUS_FAILED, step1.getFaceSyncStatus()); - // 注销权限 -> STATUS_REVOKED - dormAuthService.revokeAuth(authId, "admin"); + // 注销权限 -> STATUS_REVOKED (使用权威状态推进 updateAuthStatus) + dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_REVOKED, null, "admin"); TrainDormAuth step2 = dormAuthService.selectTrainDormAuthByAuthId(authId); assertEquals(TrainDormAuth.STATUS_REVOKED, step2.getFaceSyncStatus()); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, step2.getAuthStatus()); } /** @@ -283,7 +284,7 @@ public class DormAuthAlignmentTest Long newAuthId = newAuth.getAuthId(); // 3. 旧授权注销 (退宿或换房引起) - dormAuthService.revokeAuth(oldAuthId, "admin"); + dormAuthService.updateAuthStatus(oldAuthId, TrainDormAuth.AUTH_STATUS_REVOKED, null, "admin"); // 4. 验证两条授权在历史中共存,状态相互独立 TrainDormAuth queriedOld = dormAuthService.selectTrainDormAuthByAuthId(oldAuthId); @@ -343,8 +344,8 @@ public class DormAuthAlignmentTest dormAuthService.insertTrainDormAuth(auth); Long authId = auth.getAuthId(); - // 办理注销 - dormAuthService.revokeAuth(authId, "admin"); + // 办理注销 (通过 updateAuthStatus 将状态推进为 AUTH_STATUS_REVOKED) + dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_REVOKED, null, "admin"); try { @@ -461,6 +462,18 @@ public class DormAuthAlignmentTest return 0; } + @Override + public int clearLastError(Long authId) + { + TrainDormAuth a = store.get(authId); + if (a != null) + { + a.setLastError(null); + return 1; + } + return 0; + } + @Override public int deleteTrainDormAuthByAuthId(Long authId) {