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 的