测试门禁:覆盖外部不确定性与并发派发场景

This commit is contained in:
wangzhibiao committed 2026-09-21 14:57:53 +08:00
1 parent a12b2e3543
commit 0a637bfbbc
7 files changed
+1134 -84

No files matched your search

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