diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java index c5009fe34..ac846fba5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java @@ -261,6 +261,58 @@ public class AccessAuthorizationOrchestrator { return ReconciliationResult.awaitingManualIssue(authId, "Prerequisites met; waiting for administrator manual issue"); } + /** + * 住宿关系终止撤权路径 (FIX-01) + *

+ * 冻结业务语义:退宿 = 该住宿关系终止,该住宿产生的门禁授权必须进入撤销流程。 + *

+ * 设计约束: + * 1. 本方法只针对"被终止的那一条住宿记录",不改动 reconcile() 的整体对账语义, + * 因此调房 (GRANT_NEW_THEN_REVOKE_OLD) 的"先新房 ACTIVE 再撤旧房"顺序不受影响。 + * 2. 全程复用 executeBatchRevokeForAuths → executeRevokeFlow,继承既有安全机制: + * DMS 禁用态语义、CAS 原子 Claim、AMBIGUOUS 判定、禁止盲重发。 + * 3. 绝不直接 UPDATE auth_status='REVOKED' 绕过状态机。 + * 4. 已处于撤销流程中或已撤销的授权直接跳过,避免重复排队与重复调用厂商 mutation。 + * + * @param accommodationId 被终止的住宿安排ID + * @param reason 终止原因标识 + * @param operator 操作人 + * @return 批量撤权结果 + */ + public BatchReconciliationResult revokeAuthorizationsForAccommodation(Long accommodationId, String reason, String operator) { + TrainAccommodation acc = accommodationId != null && accommodationMapper != null + ? accommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId) : null; + Long enrollmentId = acc != null ? acc.getEnrollmentId() : null; + + if (accommodationId == null) { + BatchReconciliationResult empty = new BatchReconciliationResult(null); + empty.setOutcome(BatchReconciliationResult.OUTCOME_ALL_SETTLED); + return empty; + } + + TrainDormAuth auth = dormAuthMapper != null ? dormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId) : null; + if (auth == null || auth.getAuthId() == null) { + BatchReconciliationResult empty = new BatchReconciliationResult(enrollmentId); + empty.setOutcome(BatchReconciliationResult.OUTCOME_ALL_SETTLED); + return empty; + } + + // 已在撤销流程中或已撤销:不重复推进,避免重复排队与盲重发 + String status = auth.getAuthStatus(); + if (TrainDormAuth.AUTH_STATUS_REVOKED.equals(status) + || TrainDormAuth.AUTH_STATUS_REVOKING.equals(status) + || TrainDormAuth.AUTH_STATUS_PENDING_REVOKE.equals(status)) { + BatchReconciliationResult settled = new BatchReconciliationResult(enrollmentId); + settled.addItem(new ReconciliationItemVO(auth.getAuthId(), accommodationId, "REVOKE", "NO_ACTION", + "Authorization already in revoke lifecycle: " + status)); + settled.setNoAction(settled.getNoAction() + 1); + settled.computeOutcome(); + return settled; + } + + return executeBatchRevokeForAuths(enrollmentId, Collections.singletonList(auth), reason, operator); + } + /** * 抽取统一批量撤权处理方法 (MAJ-04: 退训与调房公用,任何单项进入批次统计,全失败绝不报成功) */ diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java index 6c87518a1..7044520c5 100644 --- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainAccommodationServiceImpl.java @@ -298,13 +298,19 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate()); if (rows > 0 && accessAuthorizationOrchestrator != null) { + // FIX-01: 退宿即住宿关系终止,必须让该住宿产生的门禁授权进入撤销流程。 + // 此处不能再走 reconcile(enrollmentId):退宿后已无有效住宿,reconcile 会直接返回 + // WAITING_FOR_PREREQUISITE,旧授权将永远停留在原状态而不被撤销。 + // 撤权全程复用编排器既有安全机制 (DMS 禁用态语义 / CAS Claim / AMBIGUOUS / 禁止盲重发)。 try { - accessAuthorizationOrchestrator.reconcile(exist.getEnrollmentId()); + accessAuthorizationOrchestrator.revokeAuthorizationsForAccommodation( + accommodationId, "AccommodationCheckedOut", operator); } catch (Exception ex) { - log.warn("Access reconcile triggered on checkout failed for enrollment {}: {}", exist.getEnrollmentId(), ex.getMessage()); + // 撤权推进失败不回滚退宿事实,授权侧会留下 ERROR/待处理状态供人工核查 + log.error("Access revoke triggered on checkout failed for accommodation {}: {}", accommodationId, ex.getMessage()); } } return rows; 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 2a4a8f12e..079df9905 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 @@ -613,6 +613,144 @@ public class AccessAuthorizationOrchestrationTest { TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); } + /** + * FIX-01: 退宿撤权路径 — 授权已 ACTIVE 时,住宿终止必须推进到 REVOKING 并真实发起外部撤权。 + * 同时锁定反向事实:退宿后 reconcile(enrollmentId) 只会返回 WAITING_FOR_PREREQUISITE, + * 因此撤权绝不能依赖 reconcile。 + */ + @Test + public void testFix01_AccommodationTerminationRevoke_ActiveAuthEntersRevokeFlow() { + Long enrollmentId = 20L; + TrainEnrollment enrollment = new TrainEnrollment(); + enrollment.setEnrollmentId(enrollmentId); + enrollment.setStudentId("STU-020"); + enrollment.setPlanId("PLAN-101"); + enrollment.setEnrollmentStatus(1); + enrollmentDb.put(enrollmentId, enrollment); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(200L); + acc.setEnrollmentId(enrollmentId); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(1); + accommodationDb.put(200L, acc); + + ReconciliationResult grant = orchestrator.reconcile(enrollmentId); + orchestrator.issueAuthorization(grant.getDoorAuthId(), "admin"); + mockServer.setOperationStatus(99001L, "success", "Success"); + poller.pollPendingOperations(); + + TrainDormAuth auth = dormAuthDb.get(200L); + assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 办理退宿:住宿状态 1 -> 2 + acc.setAccommodationStatus(2); + + // 反向事实:此时 reconcile 无法推进撤权,只会等待前置条件 + ReconciliationResult stale = orchestrator.reconcile(enrollmentId); + assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, stale.getDecision()); + assertEquals("reconcile must not revoke on checkout", TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus()); + + // 退宿撤权路径:授权必须进入撤销流程 + BatchReconciliationResult batch = orchestrator.revokeAuthorizationsForAccommodation(200L, "AccommodationCheckedOut", "admin"); + assertEquals(1, batch.getDispatched()); + assertEquals(0, batch.getFailed()); + assertEquals("Auth must enter REVOKING, never be force-set to REVOKED", + TrainDormAuth.AUTH_STATUS_REVOKING, auth.getAuthStatus()); + assertEquals(1, mockServer.getPasswordDeletePostCounter()); + + // Poller 确认后才进入终态 + mockServer.setOperationStatus(99002L, "success", "Delete confirmed"); + poller.pollPendingOperations(); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus()); + } + + /** + * FIX-01 + DMS disabled: 存在外部凭据时必须沉淀为 PENDING_REVOKE 并排队, + * 绝不虚假标记 REVOKED,且零真实 DMS mutation。 + */ + @Test + public void testFix01_AccommodationTerminationRevoke_DmsDisabledWithExternalCredential() { + properties.setEnabled(false); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(210L); + acc.setEnrollmentId(21L); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(2); + accommodationDb.put(210L, acc); + + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(2100L); + auth.setAccommodationId(210L); + auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE); + auth.setExternalAuthId("778899"); + dormAuthDb.put(210L, auth); + + int opCountBefore = operationDb.size(); + BatchReconciliationResult batch = orchestrator.revokeAuthorizationsForAccommodation(210L, "AccommodationCheckedOut", "admin"); + + assertEquals("DMS disabled must queue instead of dispatch", 1, batch.getQueued()); + assertEquals("Must not fake REVOKED while external credential still exists", + TrainDormAuth.AUTH_STATUS_PENDING_REVOKE, auth.getAuthStatus()); + assertEquals("A REVOKE operation must be queued", opCountBefore + 1, operationDb.size()); + assertEquals("REAL_DMS_MUTATION_COUNT must stay 0", 0, mockServer.getPasswordDeletePostCounter()); + + // 幂等:重复推进不得产生第二条排队操作,也不得盲重发 + int opCountAfterFirst = operationDb.size(); + BatchReconciliationResult second = orchestrator.revokeAuthorizationsForAccommodation(210L, "AccommodationCheckedOut", "admin"); + assertEquals(1, second.getNoAction()); + assertEquals(opCountAfterFirst, operationDb.size()); + assertEquals(0, mockServer.getPasswordDeletePostCounter()); + } + + /** + * FIX-01 + DMS disabled: 从未下发过的草稿授权 (PENDING_GRANT 且无外部凭据) 允许本地安全终止。 + */ + @Test + public void testFix01_AccommodationTerminationRevoke_DmsDisabledDraftAuthLocallyRevoked() { + properties.setEnabled(false); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(220L); + acc.setEnrollmentId(22L); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(2); + accommodationDb.put(220L, acc); + + TrainDormAuth auth = new TrainDormAuth(); + auth.setAuthId(2200L); + auth.setAccommodationId(220L); + auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT); + dormAuthDb.put(220L, auth); + + BatchReconciliationResult batch = orchestrator.revokeAuthorizationsForAccommodation(220L, "AccommodationCheckedOut", "admin"); + + assertEquals(1, batch.getNoAction()); + assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus()); + assertEquals("REAL_DMS_MUTATION_COUNT must stay 0", 0, mockServer.getPasswordDeletePostCounter()); + } + + /** + * FIX-01: 无授权记录的住宿终止安全返回,不制造噪音操作。 + */ + @Test + public void testFix01_AccommodationTerminationRevoke_NoAuthIsSettled() { + TrainAccommodation acc = new TrainAccommodation(); + acc.setAccommodationId(230L); + acc.setEnrollmentId(23L); + acc.setDormId("DORM-201"); + acc.setAccommodationStatus(2); + accommodationDb.put(230L, acc); + + int opCountBefore = operationDb.size(); + BatchReconciliationResult batch = orchestrator.revokeAuthorizationsForAccommodation(230L, "AccommodationCheckedOut", "admin"); + + assertEquals(BatchReconciliationResult.OUTCOME_ALL_SETTLED, batch.getOutcome()); + assertEquals(opCountBefore, operationDb.size()); + assertEquals(0, mockServer.getPasswordDeletePostCounter()); + } + /* ==================== 辅助 Mock 工厂方法 ==================== */ private TrainDormAuth getAuthById(Long authId) { diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java index f8d0b206c..01f6d23e9 100644 --- a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/AccommodationServiceTest.java @@ -541,8 +541,225 @@ public class AccommodationServiceTest assertNotNull(accommodationService.selectTrainAccommodationByAccommodationId(id)); } + /** + * FIX-01: 退宿必须进入住宿关系终止撤权路径,而不是走 reconcile(enrollmentId)。 + * 退宿后已无有效住宿,reconcile 只会返回 WAITING_FOR_PREREQUISITE,旧授权将永远不被撤销。 + */ + @Test + public void testFix01_CheckOutTriggersAccommodationScopedRevoke() throws Exception + { + RecordingOrchestrator recorder = new RecordingOrchestrator(); + setField(accommodationService, "accessAuthorizationOrchestrator", recorder); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + Long accId = acc.getAccommodationId(); + accommodationService.checkIn(accId, "operator1"); + + recorder.reset(); + accommodationService.checkOut(accId, "operator2"); + + assertEquals("退宿必须触发住宿关系终止撤权", 1, recorder.revokeCallCount); + assertEquals(accId, recorder.lastRevokedAccommodationId); + assertEquals("退宿不得再依赖 reconcile 推进撤权", 0, recorder.reconcileCallCount); + } + + /** + * FIX-01: 撤权推进失败不得回滚退宿事实,退宿本身必须成功。 + */ + @Test + public void testFix01_CheckOutSucceedsEvenWhenRevokeFails() throws Exception + { + RecordingOrchestrator recorder = new RecordingOrchestrator(); + recorder.throwOnRevoke = true; + setField(accommodationService, "accessAuthorizationOrchestrator", recorder); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + Long accId = acc.getAccommodationId(); + + accommodationService.checkOut(accId, "operator2"); + assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, + accommodationService.selectTrainAccommodationByAccommodationId(accId).getAccommodationStatus()); + } + + /** + * FIX-01: 调房不得被退宿撤权路径影响。transfer 内部不走 checkOut, + * 旧住宿授权仍由新房授权生效后的对账负责撤销 (GRANT_NEW_THEN_REVOKE_OLD)。 + */ + @Test + public void testFix01_TransferDoesNotUseCheckoutRevokePath() throws Exception + { + RecordingOrchestrator recorder = new RecordingOrchestrator(); + setField(accommodationService, "accessAuthorizationOrchestrator", recorder); + + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + accommodationService.checkIn(acc.getAccommodationId(), "operator1"); + + recorder.reset(); + TrainAccommodation newAcc = accommodationService.transfer(acc.getAccommodationId(), "DORM_101", "2号床", "admin"); + + assertNotNull(newAcc.getAccommodationId()); + assertEquals("调房绝不得触发退宿撤权路径", 0, recorder.revokeCallCount); + assertEquals("调房仍由新住宿对账驱动", 1, recorder.reconcileCallCount); + assertEquals(TrainAccommodation.STATUS_CHECKED_OUT, + accommodationService.selectTrainAccommodationByAccommodationId(acc.getAccommodationId()).getAccommodationStatus()); + assertEquals(TrainAccommodation.STATUS_CHECKED_IN, newAcc.getAccommodationStatus()); + } + + /** + * FIX-04: 通用 update 不得修改住宿生命周期状态。 + */ + @Test + public void testFix04_GenericUpdateCannotChangeAccommodationStatus() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + Long accId = acc.getAccommodationId(); + + TrainAccommodation update = new TrainAccommodation(); + update.setAccommodationId(accId); + update.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT); + try + { + accommodationService.updateTrainAccommodation(update); + fail("通用 update 修改住宿状态未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("专属业务操作")); + } + assertEquals(TrainAccommodation.STATUS_PREALLOCATED, + accommodationService.selectTrainAccommodationByAccommodationId(accId).getAccommodationStatus()); + } + + /** + * FIX-04: 通用 update 传入与现状相同的状态时放行,但绝不下发状态列。 + */ + @Test + public void testFix04_GenericUpdateWithUnchangedStatusDoesNotWriteStatusColumn() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + accommodationService.insertTrainAccommodation(acc); + Long accId = acc.getAccommodationId(); + + TrainAccommodation update = new TrainAccommodation(); + update.setAccommodationId(accId); + update.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED); + update.setRemark("仅修改备注"); + accommodationService.updateTrainAccommodation(update); + + // 状态字段在下发 Mapper 前被清空,SQL 的 因此永不命中, + // 通用更新路径在任何情况下都不会写 accommodation_status 列 + assertNull("通用更新路径不得携带状态列下发", update.getAccommodationStatus()); + } + + /** + * FIX-04: 住宿状态白名单守卫,"3 已调房"等不存在的状态与任意非法整数一律拒绝入库。 + */ + @Test + public void testFix04_IllegalAccommodationStatusIsRejectedOnInsert() + { + for (Integer illegal : new Integer[]{3, 4, 99, -1}) + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + acc.setAccommodationStatus(illegal); + try + { + accommodationService.insertTrainAccommodation(acc); + fail("非法住宿状态 " + illegal + " 未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("住宿状态不合法")); + } + } + } + + /** + * FIX-04: 新增住宿不得直接置为已退宿。 + */ + @Test + public void testFix04_InsertCannotDirectlyCreateCheckedOutRecord() + { + TrainAccommodation acc = new TrainAccommodation(); + acc.setEnrollmentId(1001L); + acc.setDormId("DORM_101"); + acc.setBedNo("1号床"); + acc.setAccommodationStatus(TrainAccommodation.STATUS_CHECKED_OUT); + try + { + accommodationService.insertTrainAccommodation(acc); + fail("新增即退宿未被拦截"); + } + catch (ServiceException e) + { + assertTrue(e.getMessage().contains("不能直接置为已退宿")); + } + } + // ================================= 内部 Mock 类 ================================= + /** + * 记录型编排器:只统计调用,不产生任何外部副作用 (REAL_DMS_MUTATION_COUNT = 0)。 + */ + private static class RecordingOrchestrator extends com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator + { + private int revokeCallCount = 0; + private int reconcileCallCount = 0; + private Long lastRevokedAccommodationId = null; + private boolean throwOnRevoke = false; + + private void reset() + { + revokeCallCount = 0; + reconcileCallCount = 0; + lastRevokedAccommodationId = null; + } + + @Override + public com.ruoyi.train.integration.dms.orchestration.BatchReconciliationResult revokeAuthorizationsForAccommodation( + Long accommodationId, String reason, String operator) + { + revokeCallCount++; + lastRevokedAccommodationId = accommodationId; + if (throwOnRevoke) + { + throw new IllegalStateException("模拟撤权推进失败"); + } + com.ruoyi.train.integration.dms.orchestration.BatchReconciliationResult batch = + new com.ruoyi.train.integration.dms.orchestration.BatchReconciliationResult(null); + batch.setOutcome(com.ruoyi.train.integration.dms.orchestration.BatchReconciliationResult.OUTCOME_ALL_SETTLED); + return batch; + } + + @Override + public com.ruoyi.train.integration.dms.orchestration.ReconciliationResult reconcile(Long enrollmentId) + { + reconcileCallCount++; + return com.ruoyi.train.integration.dms.orchestration.ReconciliationResult.noAction("test stub"); + } + } + private static class MockAccommodationMapper implements TrainAccommodationMapper { private final Map store = new HashMap<>();