修复门禁:退宿时正确进入授权撤销流程

退宿后 enrollment 仍为已报到但已无有效住宿,reconcile(enrollmentId) 只会返回
WAITING_FOR_PREREQUISITE,被退宿住宿对应的 train_dorm_auth 因此永远无法进入撤权状态。

- 编排器新增住宿关系终止撤权路径 revokeAuthorizationsForAccommodation,
  只针对被终止的那一条住宿记录,不改动 reconcile 整体对账语义
- 撤权全程复用既有安全机制:DMS 禁用态语义、CAS 原子 Claim、AMBIGUOUS 判定、
  禁止盲重发,绝不直接 UPDATE auth_status='REVOKED' 绕过状态机
- 已处于 REVOKED / REVOKING / PENDING_REVOKE 的授权直接跳过,避免重复排队
- 退宿改走该路径,撤权推进失败不回滚退宿事实
- 调房不受影响,仍保持新房授权生效后再撤旧房 (GRANT_NEW_THEN_REVOKE_OLD)
- 补充住宿终止撤权与住宿状态守卫单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wangzhibiaoandCursor committed 2026-09-22 17:03:55 +08:00
1 parent aeb76ce35a
commit dc3990b29a
4 files changed
+415 -2

No files matched your search

@@ -261,6 +261,58 @@ public class AccessAuthorizationOrchestrator {
return ReconciliationResult.awaitingManualIssue(authId, "Prerequisites met; waiting for administrator manual issue");
}
/**
* 住宿关系终止撤权路径 (FIX-01)
* <p>
* 冻结业务语义:退宿 = 该住宿关系终止,该住宿产生的门禁授权必须进入撤销流程。
* <p>
* 设计约束:
* 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: 退训与调房公用,任何单项进入批次统计,全失败绝不报成功)
*/
@@ -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;
@@ -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) {
@@ -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 的 <if test="accommodationStatus != null"> 因此永不命中,
// 通用更新路径在任何情况下都不会写 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<Long, TrainAccommodation> store = new HashMap<>();