diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DormAuthorizationOrchestrationMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DormAuthorizationOrchestrationMigrationContractTest.java
new file mode 100644
index 000000000..1b0a36070
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/DormAuthorizationOrchestrationMigrationContractTest.java
@@ -0,0 +1,76 @@
+package com.ruoyi.web.controller.train;
+
+import static org.junit.Assert.*;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Locale;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * 009 Migration 静态规范契约测试
+ * 校验 009_dorm_authorization_orchestration.sql 的幂等扩展性、字段健全性与敏感数据规避
+ */
+public class DormAuthorizationOrchestrationMigrationContractTest
+{
+ private File migrationFile;
+ private String sqlContent;
+
+ @Before
+ public void setUp() throws IOException
+ {
+ File direct = new File("../sql/system/kingbase/009_dorm_authorization_orchestration.sql");
+ if (direct.exists()) {
+ migrationFile = direct;
+ } else {
+ migrationFile = new File("sql/system/kingbase/009_dorm_authorization_orchestration.sql");
+ }
+ assertTrue("009 migration script must exist: " + migrationFile.getAbsolutePath(), migrationFile.exists());
+ sqlContent = new String(Files.readAllBytes(migrationFile.toPath()));
+ }
+
+ @Test
+ public void testColumnsAddedWithIdempotentClause()
+ {
+ String lower = sqlContent.toLowerCase(Locale.ROOT);
+ assertTrue("Must alter table train_dorm_auth", lower.contains("alter table train_dorm_auth"));
+ assertTrue("Must use ADD COLUMN IF NOT EXISTS for credential_type",
+ lower.contains("add column if not exists credential_type"));
+ assertTrue("Must use ADD COLUMN IF NOT EXISTS for auth_status",
+ lower.contains("add column if not exists auth_status"));
+ assertTrue("Must use ADD COLUMN IF NOT EXISTS for latest_operation_id",
+ lower.contains("add column if not exists latest_operation_id"));
+ assertTrue("Must use ADD COLUMN IF NOT EXISTS for operation_version",
+ lower.contains("add column if not exists operation_version"));
+ }
+
+ @Test
+ public void testZeroPlaintextPasswordColumns()
+ {
+ String lower = sqlContent.toLowerCase(Locale.ROOT);
+ assertFalse("Must NOT add any plain password column", lower.contains("add column if not exists password"));
+ assertFalse("Must NOT add secret column", lower.contains("add column if not exists secret"));
+ assertFalse("Must NOT add plain_password column", lower.contains("plain_password"));
+ }
+
+ @Test
+ public void testNoDestructiveDmlOrDrop()
+ {
+ String lower = sqlContent.toLowerCase(Locale.ROOT);
+ assertFalse("Must not drop table", lower.contains("drop table"));
+ assertFalse("Must not drop column", lower.contains("drop column"));
+ assertFalse("Must not truncate table", lower.contains("truncate"));
+ assertFalse("Must not delete rows", lower.contains("delete from"));
+ }
+
+ @Test
+ public void testIndexesCreatedIdempotently()
+ {
+ String lower = sqlContent.toLowerCase(Locale.ROOT);
+ assertTrue("Must have CREATE INDEX IF NOT EXISTS", lower.contains("create index if not exists"));
+ assertTrue("Must index auth_status", lower.contains("idx_dorm_auth_status"));
+ assertTrue("Must index latest_operation_id", lower.contains("idx_dorm_auth_latest_op"));
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java
index 6bae1d718..c4d93c81e 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainDormAuth.java
@@ -29,6 +29,20 @@ public class TrainDormAuth extends BaseEntity
/** 门禁授权状态:3 已注销 (权限收回) */
public static final Integer STATUS_REVOKED = 3;
+ /* ==================== 业务授权精细化状态枚举 (Wave 3A 厂商中立领域状态) ==================== */
+ public static final String AUTH_STATUS_PENDING_GRANT = "PENDING_GRANT";
+ public static final String AUTH_STATUS_GRANTING = "GRANTING";
+ public static final String AUTH_STATUS_ACTIVE = "ACTIVE";
+ public static final String AUTH_STATUS_PENDING_REVOKE = "PENDING_REVOKE";
+ public static final String AUTH_STATUS_REVOKING = "REVOKING";
+ public static final String AUTH_STATUS_REVOKED = "REVOKED";
+ public static final String AUTH_STATUS_ERROR = "ERROR";
+
+ /* ==================== 凭据类型枚举 (Wave 3A 凭据中立领域状态) ==================== */
+ public static final String CREDENTIAL_TYPE_ONLINE_CODE = "ONLINE_PASSWORD";
+ public static final String CREDENTIAL_TYPE_QR = "QR";
+ public static final String CREDENTIAL_TYPE_CARD = "CARD";
+
/** 授权主键ID */
private Long authId;
@@ -36,6 +50,20 @@ public class TrainDormAuth extends BaseEntity
@Excel(name = "住宿安排ID")
private Long accommodationId;
+ /** 凭据类型 (默认 ONLINE_PASSWORD) */
+ @Excel(name = "凭据类型")
+ private String credentialType = CREDENTIAL_TYPE_ONLINE_CODE;
+
+ /** 厂商中立业务授权状态 */
+ @Excel(name = "业务授权状态")
+ private String authStatus = AUTH_STATUS_PENDING_GRANT;
+
+ /** 最近一次驱动的外部操作流水ID (防迟到覆盖与溯源) */
+ private Long latestOperationId;
+
+ /** 授权版本号 (乐观并发与迟到覆盖保护) */
+ private Integer operationVersion = 1;
+
/** 权限生效时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Excel(name = "权限生效时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
@@ -48,11 +76,11 @@ public class TrainDormAuth extends BaseEntity
/** 授权同步状态 (0: 待下发, 1: 已下发, 2: 下发失败, 3: 已注销) */
@Excel(name = "授权同步状态", readConverterExp = "0=待下发,1=已下发,2=下发失败,3=已注销")
- private Integer faceSyncStatus;
+ private Integer faceSyncStatus = STATUS_PENDING;
/** 重试次数 */
@Excel(name = "重试次数")
- private Integer retryCount;
+ private Integer retryCount = 0;
/** 最近一次同步错误信息 */
@Excel(name = "最近同步错误")
@@ -129,6 +157,55 @@ public class TrainDormAuth extends BaseEntity
this.accommodationId = accommodationId;
}
+ public String getCredentialType()
+ {
+ return credentialType;
+ }
+
+ public void setCredentialType(String credentialType)
+ {
+ this.credentialType = credentialType;
+ }
+
+ public String getAuthStatus()
+ {
+ return authStatus;
+ }
+
+ public void setAuthStatus(String authStatus)
+ {
+ this.authStatus = authStatus;
+ if (AUTH_STATUS_PENDING_GRANT.equals(authStatus) || AUTH_STATUS_GRANTING.equals(authStatus)) {
+ this.faceSyncStatus = STATUS_PENDING;
+ } else if (AUTH_STATUS_ACTIVE.equals(authStatus) || AUTH_STATUS_PENDING_REVOKE.equals(authStatus) || AUTH_STATUS_REVOKING.equals(authStatus)) {
+ this.faceSyncStatus = STATUS_ACTIVE;
+ } else if (AUTH_STATUS_ERROR.equals(authStatus)) {
+ this.faceSyncStatus = STATUS_FAILED;
+ } else if (AUTH_STATUS_REVOKED.equals(authStatus)) {
+ this.faceSyncStatus = STATUS_REVOKED;
+ }
+ }
+
+ public Long getLatestOperationId()
+ {
+ return latestOperationId;
+ }
+
+ public void setLatestOperationId(Long latestOperationId)
+ {
+ this.latestOperationId = latestOperationId;
+ }
+
+ public Integer getOperationVersion()
+ {
+ return operationVersion;
+ }
+
+ public void setOperationVersion(Integer operationVersion)
+ {
+ this.operationVersion = operationVersion;
+ }
+
public Date getAuthStart()
{
return authStart;
@@ -157,6 +234,28 @@ public class TrainDormAuth extends BaseEntity
public void setFaceSyncStatus(Integer faceSyncStatus)
{
this.faceSyncStatus = faceSyncStatus;
+ if (faceSyncStatus != null) {
+ switch (faceSyncStatus) {
+ case 0:
+ if (!AUTH_STATUS_GRANTING.equals(this.authStatus)) {
+ this.authStatus = AUTH_STATUS_PENDING_GRANT;
+ }
+ break;
+ case 1:
+ if (!AUTH_STATUS_PENDING_REVOKE.equals(this.authStatus) && !AUTH_STATUS_REVOKING.equals(this.authStatus)) {
+ this.authStatus = AUTH_STATUS_ACTIVE;
+ }
+ break;
+ case 2:
+ this.authStatus = AUTH_STATUS_ERROR;
+ break;
+ case 3:
+ this.authStatus = AUTH_STATUS_REVOKED;
+ break;
+ default:
+ break;
+ }
+ }
}
public Integer getRetryCount()
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
new file mode 100644
index 000000000..d62e5a4c4
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java
@@ -0,0 +1,348 @@
+package com.ruoyi.train.integration.dms.orchestration;
+
+import com.ruoyi.train.domain.TrainAccommodation;
+import com.ruoyi.train.domain.TrainDormAuth;
+import com.ruoyi.train.domain.TrainEnrollment;
+import com.ruoyi.train.domain.TrainExternalOperation;
+import com.ruoyi.train.domain.TrainExternalResourceMapping;
+import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
+import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
+import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteRequest;
+import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
+import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
+import com.ruoyi.train.integration.dms.security.SecurePasswordGenerator;
+import com.ruoyi.train.mapper.TrainAccommodationMapper;
+import com.ruoyi.train.mapper.TrainDormAuthMapper;
+import com.ruoyi.train.mapper.TrainEnrollmentMapper;
+import com.ruoyi.train.service.IExternalOperationService;
+import com.ruoyi.train.service.IExternalResourceMappingService;
+import com.ruoyi.train.service.ITrainDormAuthService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 宿舍门禁业务授权统一编排器 (Wave 3A)
+ *
+ * 核心架构与安全准则:
+ * 1. 状态驱动对账 (Reconciliation Model):业务事实 → 期望状态 → 外部操作,与触发顺序完全解耦。
+ * 2. 最小特权主锁策略 (PRIMARY_LOCK_ONLY):房间内仅对且必须对唯一定义的 1 把主门锁下发凭据。
+ * 3. 调房安全策略 (GRANT_NEW_THEN_REVOKE_OLD):先下发新房凭据,新房对账生效后再撤销旧房;新房失败严格保留旧房。
+ * 4. 事务边界清晰 (LONG_DB_TRANSACTION_AROUND_HTTP = NO):严禁长事务包住远程 HTTP 调用,外部调用异常绝不回滚业务主事务。
+ * 5. 密码安全与短暂留存:SecureRandom 强随机生成,明文仅在内存局部变量留存,严禁落库、严禁日志输出。
+ * 6. 操作幂等与防重:业务授权唯一性 + 外部操作幂等键双重防护,重复触发无副作用。
+ */
+@Service
+public class AccessAuthorizationOrchestrator {
+ private static final Logger log = LoggerFactory.getLogger(AccessAuthorizationOrchestrator.class);
+
+ public static final String VENDOR_DMS = "DMS";
+ public static final String POLICY_PRIMARY_LOCK_ONLY = "PRIMARY_LOCK_ONLY";
+ public static final String VALIDITY_WINDOW_SOURCE = "BUSINESS_POLICY_REQUIRED";
+
+ private final TrainEnrollmentMapper enrollmentMapper;
+ private final TrainAccommodationMapper accommodationMapper;
+ private final TrainDormAuthMapper dormAuthMapper;
+ private final ITrainDormAuthService dormAuthService;
+ private final IExternalResourceMappingService resourceMappingService;
+ private final IExternalOperationService operationService;
+ private final ExternalCredentialGateway credentialGateway;
+
+ protected AccessAuthorizationOrchestrator() {
+ this(null, null, null, null, null, null, null);
+ }
+
+ public AccessAuthorizationOrchestrator(TrainEnrollmentMapper enrollmentMapper,
+ TrainAccommodationMapper accommodationMapper,
+ TrainDormAuthMapper dormAuthMapper,
+ ITrainDormAuthService dormAuthService,
+ IExternalResourceMappingService resourceMappingService,
+ IExternalOperationService operationService,
+ ExternalCredentialGateway credentialGateway) {
+ this.enrollmentMapper = enrollmentMapper;
+ this.accommodationMapper = accommodationMapper;
+ this.dormAuthMapper = dormAuthMapper;
+ this.dormAuthService = dormAuthService;
+ this.resourceMappingService = resourceMappingService;
+ this.operationService = operationService;
+ this.credentialGateway = credentialGateway;
+ }
+
+ /**
+ * 核心对账入口:检查指定参训学员当前业务事实,并执行状态调谐
+ *
+ * @param enrollmentId 参训档案ID
+ * @return 对账结果
+ */
+ public ReconciliationResult reconcile(Long enrollmentId) {
+ if (enrollmentId == null) {
+ return ReconciliationResult.waiting("Missing enrollmentId");
+ }
+
+ TrainEnrollment enrollment = enrollmentMapper.selectTrainEnrollmentById(enrollmentId);
+ if (enrollment == null) {
+ return ReconciliationResult.waiting("Enrollment not found: " + enrollmentId);
+ }
+
+ Integer enrollmentStatus = enrollment.getEnrollmentStatus();
+ TrainAccommodation activeAcc = accommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId);
+ List accHistory = accommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId);
+
+ // 1. 学员退训(3) 或 已完成(4):触发所有在用授权的注销 (REVOKE)
+ if (Integer.valueOf(3).equals(enrollmentStatus) || Integer.valueOf(4).equals(enrollmentStatus)) {
+ return handleEnrollmentTermination(enrollment, accHistory);
+ }
+
+ // 2. 待报到 (0):未完成现场报到,不满足前置发放条件
+ if (Integer.valueOf(0).equals(enrollmentStatus)) {
+ return ReconciliationResult.waiting("Enrollment status is PENDING_REPORT (0); Grant prerequisites not met");
+ }
+
+ // 3. 请假 (2):政策保持现状 (LEAVE_AUTO_REVOKE_POLICY = OPEN_QUESTION),不主动撤权
+ if (Integer.valueOf(2).equals(enrollmentStatus)) {
+ return ReconciliationResult.noAction("Enrollment on leave (2); maintaining current authorization state");
+ }
+
+ // 4. 已报到 (1):执行发放或调房检查
+ if (Integer.valueOf(1).equals(enrollmentStatus)) {
+ if (activeAcc == null) {
+ // 已报到但尚未分配有效宿舍
+ return ReconciliationResult.waiting("No active accommodation assigned for reported enrollment; waiting for assignment");
+ }
+ return handleGrantOrTransfer(enrollment, activeAcc, accHistory);
+ }
+
+ return ReconciliationResult.noAction("No action required for enrollment " + enrollmentId);
+ }
+
+ private ReconciliationResult handleEnrollmentTermination(TrainEnrollment enrollment, List accHistory) {
+ if (accHistory == null || accHistory.isEmpty()) {
+ return ReconciliationResult.noAction("No accommodation history found for terminated enrollment " + enrollment.getEnrollmentId());
+ }
+
+ // 查找所有非 REVOKED 的授权进行注销
+ for (TrainAccommodation acc : accHistory) {
+ TrainDormAuth auth = dormAuthMapper.selectTrainDormAuthByAccommodationId(acc.getAccommodationId());
+ if (auth != null && !TrainDormAuth.AUTH_STATUS_REVOKED.equals(auth.getAuthStatus())) {
+ log.info("Terminating authorization for enrollment {} accommodation {}: authId={}",
+ enrollment.getEnrollmentId(), acc.getAccommodationId(), auth.getAuthId());
+ return executeRevokeFlow(auth, "EnrollmentTerminated:" + enrollment.getEnrollmentStatus());
+ }
+ }
+
+ return ReconciliationResult.noAction("All authorizations already REVOKED for terminated enrollment " + enrollment.getEnrollmentId());
+ }
+
+ private ReconciliationResult handleGrantOrTransfer(TrainEnrollment enrollment,
+ TrainAccommodation activeAcc,
+ List accHistory) {
+ Long accommodationId = activeAcc.getAccommodationId();
+ String dormId = activeAcc.getDormId();
+
+ TrainDormAuth currentAuth = dormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId);
+
+ // 如果新住宿的授权已经生效 (ACTIVE)
+ if (currentAuth != null && TrainDormAuth.AUTH_STATUS_ACTIVE.equals(currentAuth.getAuthStatus())) {
+ // 调房场景第二阶段检查:新房已 ACTIVE,检查是否存在历史退宿的残留旧授权需要注销 (GRANT_NEW_THEN_REVOKE_OLD)
+ TrainDormAuth oldAuthToRevoke = findStaleActiveAuthInHistory(accommodationId, accHistory);
+ if (oldAuthToRevoke != null) {
+ log.info("New room access ACTIVE. Triggering delayed revoke for old accommodation auth: {}", oldAuthToRevoke.getAuthId());
+ return executeRevokeFlow(oldAuthToRevoke, "TransferCompleted");
+ }
+ return ReconciliationResult.noAction("Door authorization is already ACTIVE for accommodation " + accommodationId);
+ }
+
+ // 如果新住宿的授权正在下发中 (GRANTING)
+ if (currentAuth != null && TrainDormAuth.AUTH_STATUS_GRANTING.equals(currentAuth.getAuthStatus())) {
+ return ReconciliationResult.noAction("Door authorization is already in GRANTING state, awaiting poller");
+ }
+
+ // 校验外部映射与主锁策略 (PRIMARY_LOCK_ONLY)
+ TrainExternalResourceMapping roomMapping = resourceMappingService.getActiveRoomMapping(VENDOR_DMS, dormId);
+ if (roomMapping == null) {
+ return ReconciliationResult.waiting("No ACTIVE room mapping found for dormId " + dormId);
+ }
+
+ List lockMappings = resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId);
+ if (lockMappings == null || lockMappings.isEmpty()) {
+ return ReconciliationResult.waiting("No door locks mapped for dormId " + dormId);
+ }
+
+ List primaryLocks = lockMappings.stream()
+ .filter(m -> Integer.valueOf(1).equals(m.getIsPrimary()))
+ .collect(Collectors.toList());
+
+ if (primaryLocks.isEmpty()) {
+ return ReconciliationResult.waiting("Dorm " + dormId + " has 0 primary lock configured; cannot grant access");
+ }
+ if (primaryLocks.size() > 1) {
+ return ReconciliationResult.waiting("Dorm " + dormId + " has " + primaryLocks.size() + " primary locks configured; configuration error");
+ }
+
+ TrainExternalResourceMapping primaryLock = primaryLocks.get(0);
+
+ // 推导有效期窗口 (VALIDITY_WINDOW_SOURCE = BUSINESS_POLICY_REQUIRED)
+ Date validStart = activeAcc.getAssignedTime() != null ? activeAcc.getAssignedTime() : new Date();
+ Date validUntil = deriveDefaultValidUntil(validStart);
+
+ return executeGrantFlow(enrollment, activeAcc, primaryLock, currentAuth, validStart, validUntil);
+ }
+
+ private ReconciliationResult executeGrantFlow(TrainEnrollment enrollment,
+ TrainAccommodation activeAcc,
+ TrainExternalResourceMapping primaryLock,
+ TrainDormAuth dormAuth,
+ Date validStart,
+ Date validUntil) {
+ Long accommodationId = activeAcc.getAccommodationId();
+
+ // 步骤 1: 确保本地授权记录存在 (短本地事务)
+ if (dormAuth == null) {
+ dormAuth = dormAuthService.ensureAuthorizationForAccommodation(accommodationId, "Orchestrator");
+ }
+
+ Long authId = dormAuth.getAuthId();
+ Integer version = dormAuth.getOperationVersion() != null ? dormAuth.getOperationVersion() : 1;
+
+ // 步骤 2: 创建外部操作流水 (短本地事务,幂等键保护)
+ String idempotencyKey = "GRANT:AUTH:" + authId + ":V" + version;
+ TrainExternalOperation op = new TrainExternalOperation();
+ op.setDoorAuthId(authId);
+ op.setVendorCode(VENDOR_DMS);
+ op.setOperationType(TrainExternalOperation.TYPE_GRANT);
+ op.setOperationStatus(TrainExternalOperation.STATUS_CREATED);
+ op.setIdempotencyKey(idempotencyKey);
+ op.setExternalRequestType(1); // 1: 下发密码
+ op.setExternalHardwareId(primaryLock.getExternalHardwareId());
+ op.setCreateBy("Orchestrator");
+
+ TrainExternalOperation createdOp = operationService.createOperation(op);
+ Long operationId = createdOp.getOperationId();
+
+ // 步骤 3: 标记授权记录进入 GRANTING 状态并记录本次操作ID
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_GRANTING, operationId, "Orchestrator");
+
+ // 步骤 4: 构造请求并发起远程凭据下发 (非数据库长事务,LONG_DB_TRANSACTION_AROUND_HTTP = NO)
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+ Long lockId = null;
+ try {
+ lockId = Long.parseLong(primaryLock.getExternalResourceId());
+ } catch (Exception e) {
+ log.error("Invalid lockId format: {}", primaryLock.getExternalResourceId());
+ }
+ String holder = enrollment.getStudentId() != null ? enrollment.getStudentId() : "Student";
+ String phoneNumber = "13800000000"; // 缺省测试手机号,后续由业务拓展补充
+
+ // 内存中生成 6 位强随机数字密码
+ String plaintextPassword = SecurePasswordGenerator.generateDefaultPassword();
+
+ try {
+ DmsPasswordCreateResponse resp = credentialGateway.issueOnlinePassword(
+ lockId, holder, phoneNumber, plaintextPassword, sdf.format(validStart), sdf.format(validUntil));
+
+ // 步骤 5: 成功获取 Vendor Accepted 响应,回填外部流水号并推进为 ACCEPTED
+ String extOpIdStr = resp.getOperationId() != null ? String.valueOf(resp.getOperationId()) : null;
+ String extKeyIdStr = resp.getId() != null ? String.valueOf(resp.getId()) : null;
+
+ operationService.bindExternalReference(operationId, extOpIdStr, resp.getLockHardwareId(), extKeyIdStr, "Orchestrator");
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, "Orchestrator");
+
+ // 回填授权记录上的 externalAuthId (用于后续撤销定位凭据)
+ if (extKeyIdStr != null) {
+ dormAuth.setExternalAuthId(extKeyIdStr);
+ dormAuthService.updateTrainDormAuth(dormAuth);
+ }
+
+ log.info("DMS password create accepted: localOpId={}, extOpId={}", operationId, extOpIdStr);
+ return ReconciliationResult.grantRequired(authId, operationId, true, true, "Password create accepted by DMS; awaiting poller");
+
+ } catch (Exception e) {
+ // 外部调用异常 (如读超时、网络异常),严格捕获,绝不回滚业务主事务!
+ log.error("DMS password create failed or timed out for authId {}: {}", authId, e.getMessage());
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.getMessage()), "Orchestrator");
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, "Orchestrator");
+
+ return ReconciliationResult.grantRequired(authId, operationId, true, false, "External gateway call failed: " + sanitize(e.getMessage()));
+ }
+ }
+
+ private ReconciliationResult executeRevokeFlow(TrainDormAuth auth, String reason) {
+ Long authId = auth.getAuthId();
+ Integer version = (auth.getOperationVersion() != null ? auth.getOperationVersion() : 1) + 1;
+
+ // 步骤 1: 创建外部操作流水 (REVOKE)
+ String idempotencyKey = "REVOKE:AUTH:" + authId + ":V" + version;
+ TrainExternalOperation op = new TrainExternalOperation();
+ op.setDoorAuthId(authId);
+ op.setVendorCode(VENDOR_DMS);
+ op.setOperationType(TrainExternalOperation.TYPE_REVOKE);
+ op.setOperationStatus(TrainExternalOperation.STATUS_CREATED);
+ op.setIdempotencyKey(idempotencyKey);
+ op.setExternalRequestType(3); // 3: 删除密码
+ op.setCreateBy("Orchestrator");
+
+ TrainExternalOperation createdOp = operationService.createOperation(op);
+ Long operationId = createdOp.getOperationId();
+
+ // 步骤 2: 推进授权状态为 REVOKING
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_REVOKING, operationId, "Orchestrator");
+
+ // 步骤 3: 远程调用删除密码
+ Long externalKeyId = null;
+ if (auth.getExternalAuthId() != null && !auth.getExternalAuthId().trim().isEmpty()) {
+ try {
+ externalKeyId = Long.parseLong(auth.getExternalAuthId().trim());
+ } catch (Exception ignored) {}
+ }
+
+ try {
+ DmsPasswordDeleteResponse resp = credentialGateway.revokeOnlinePassword(externalKeyId != null ? externalKeyId : 0L);
+ String extOpIdStr = resp.getOperationId() != null ? String.valueOf(resp.getOperationId()) : null;
+
+ operationService.bindExternalReference(operationId, extOpIdStr, null, null, "Orchestrator");
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, "Orchestrator");
+
+ log.info("DMS password delete accepted: localOpId={}, extOpId={}", operationId, extOpIdStr);
+ return ReconciliationResult.revokeRequired(authId, operationId, true, true, "Password delete accepted by DMS; awaiting poller");
+
+ } catch (Exception e) {
+ log.error("DMS password delete failed for authId {}: {}", authId, e.getMessage());
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.getMessage()), "Orchestrator");
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, "Orchestrator");
+
+ return ReconciliationResult.revokeRequired(authId, operationId, true, false, "External revoke failed: " + sanitize(e.getMessage()));
+ }
+ }
+
+
+ private TrainDormAuth findStaleActiveAuthInHistory(Long currentAccId, List history) {
+ if (history == null) return null;
+ for (TrainAccommodation acc : history) {
+ if (!acc.getAccommodationId().equals(currentAccId)) {
+ TrainDormAuth oldAuth = dormAuthMapper.selectTrainDormAuthByAccommodationId(acc.getAccommodationId());
+ if (oldAuth != null && TrainDormAuth.AUTH_STATUS_ACTIVE.equals(oldAuth.getAuthStatus())) {
+ return oldAuth;
+ }
+ }
+ }
+ return null;
+ }
+
+ private Date deriveDefaultValidUntil(Date start) {
+ Calendar cal = Calendar.getInstance();
+ cal.setTime(start);
+ cal.add(Calendar.DAY_OF_YEAR, 30); // 默认 30 天
+ return cal.getTime();
+ }
+
+ private String sanitize(String str) {
+ if (str == null) return null;
+ return str.replaceAll("(?i)(password|token|secret)=[^&\\s]+", "$1=[PROTECTED]");
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java
new file mode 100644
index 000000000..1f33fc665
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java
@@ -0,0 +1,26 @@
+package com.ruoyi.train.integration.dms.orchestration;
+
+/**
+ * 门禁权限对账决策枚举 (厂商中立)
+ */
+public enum ReconciliationDecision {
+ /**
+ * 无需操作 (当前状态已与业务事实对齐)
+ */
+ NO_ACTION,
+
+ /**
+ * 需要发放权限 (满足所有前置业务条件,待下发凭据)
+ */
+ GRANT_REQUIRED,
+
+ /**
+ * 需要注销权限 (学员退训/结训或调房退宿,需收回凭据)
+ */
+ REVOKE_REQUIRED,
+
+ /**
+ * 等待前置业务条件 (未报到、未分房、无门锁映射等)
+ */
+ WAITING_FOR_PREREQUISITE
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java
new file mode 100644
index 000000000..7aa265e83
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java
@@ -0,0 +1,128 @@
+package com.ruoyi.train.integration.dms.orchestration;
+
+import java.io.Serializable;
+
+/**
+ * 门禁权限对账结果 DTO
+ */
+public class ReconciliationResult implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long enrollmentId;
+ private ReconciliationDecision decision;
+ private String reason;
+ private Long doorAuthId;
+ private Long operationId;
+ private boolean externalInvoked;
+ private boolean success;
+
+ public ReconciliationResult() {
+ }
+
+ public static ReconciliationResult noAction(String reason) {
+ ReconciliationResult res = new ReconciliationResult();
+ res.setDecision(ReconciliationDecision.NO_ACTION);
+ res.setReason(reason);
+ res.setSuccess(true);
+ return res;
+ }
+
+ public static ReconciliationResult waiting(String reason) {
+ ReconciliationResult res = new ReconciliationResult();
+ res.setDecision(ReconciliationDecision.WAITING_FOR_PREREQUISITE);
+ res.setReason(reason);
+ res.setSuccess(true);
+ return res;
+ }
+
+ public static ReconciliationResult grantRequired(Long doorAuthId, Long operationId, boolean externalInvoked, boolean success, String reason) {
+ ReconciliationResult res = new ReconciliationResult();
+ res.setDecision(ReconciliationDecision.GRANT_REQUIRED);
+ res.setDoorAuthId(doorAuthId);
+ res.setOperationId(operationId);
+ res.setExternalInvoked(externalInvoked);
+ res.setSuccess(success);
+ res.setReason(reason);
+ return res;
+ }
+
+ public static ReconciliationResult revokeRequired(Long doorAuthId, Long operationId, boolean externalInvoked, boolean success, String reason) {
+ ReconciliationResult res = new ReconciliationResult();
+ res.setDecision(ReconciliationDecision.REVOKE_REQUIRED);
+ res.setDoorAuthId(doorAuthId);
+ res.setOperationId(operationId);
+ res.setExternalInvoked(externalInvoked);
+ res.setSuccess(success);
+ res.setReason(reason);
+ return res;
+ }
+
+ public Long getEnrollmentId() {
+ return enrollmentId;
+ }
+
+ public void setEnrollmentId(Long enrollmentId) {
+ this.enrollmentId = enrollmentId;
+ }
+
+ public ReconciliationDecision getDecision() {
+ return decision;
+ }
+
+ public void setDecision(ReconciliationDecision decision) {
+ this.decision = decision;
+ }
+
+ public String getReason() {
+ return reason;
+ }
+
+ public void setReason(String reason) {
+ this.reason = reason;
+ }
+
+ public Long getDoorAuthId() {
+ return doorAuthId;
+ }
+
+ public void setDoorAuthId(Long doorAuthId) {
+ this.doorAuthId = doorAuthId;
+ }
+
+ public Long getOperationId() {
+ return operationId;
+ }
+
+ public void setOperationId(Long operationId) {
+ this.operationId = operationId;
+ }
+
+ public boolean isExternalInvoked() {
+ return externalInvoked;
+ }
+
+ public void setExternalInvoked(boolean externalInvoked) {
+ this.externalInvoked = externalInvoked;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ @Override
+ public String toString() {
+ return "ReconciliationResult{" +
+ "enrollmentId=" + enrollmentId +
+ ", decision=" + decision +
+ ", reason='" + reason + '\'' +
+ ", doorAuthId=" + doorAuthId +
+ ", operationId=" + operationId +
+ ", externalInvoked=" + externalInvoked +
+ ", success=" + success +
+ '}';
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java
index e3af485b6..189f6a5df 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/poller/DmsOperationPoller.java
@@ -1,5 +1,6 @@
package com.ruoyi.train.integration.dms.poller;
+import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.domain.TrainExternalOperation;
import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
import com.ruoyi.train.integration.dms.config.DmsProperties;
@@ -10,6 +11,7 @@ import com.ruoyi.train.integration.dms.exception.DmsBusinessException;
import com.ruoyi.train.integration.dms.exception.DmsTransportException;
import com.ruoyi.train.integration.dms.gateway.DmsOperationStatusMapper;
import com.ruoyi.train.service.IExternalOperationService;
+import com.ruoyi.train.service.ITrainDormAuthService;
import com.ruoyi.train.service.impl.ExternalOperationServiceImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -50,6 +52,7 @@ public class DmsOperationPoller {
private final DmsProperties dmsProperties;
private final IExternalOperationService externalOperationService;
private final DmsOperationStatusClient operationStatusClient;
+ private final ITrainDormAuthService dormAuthService;
private final Set inFlightOperationIds = ConcurrentHashMap.newKeySet();
private Clock clock = Clock.systemUTC();
@@ -57,9 +60,17 @@ public class DmsOperationPoller {
public DmsOperationPoller(DmsProperties dmsProperties,
IExternalOperationService externalOperationService,
DmsOperationStatusClient operationStatusClient) {
+ this(dmsProperties, externalOperationService, operationStatusClient, null);
+ }
+
+ public DmsOperationPoller(DmsProperties dmsProperties,
+ IExternalOperationService externalOperationService,
+ DmsOperationStatusClient operationStatusClient,
+ ITrainDormAuthService dormAuthService) {
this.dmsProperties = dmsProperties;
this.externalOperationService = externalOperationService;
this.operationStatusClient = operationStatusClient;
+ this.dormAuthService = dormAuthService;
}
public void setClock(Clock clock) {
@@ -163,6 +174,7 @@ public class DmsOperationPoller {
null,
"DmsPoller"
);
+ updateAssociatedDoorAuth(op, TrainExternalOperation.STATUS_SUCCESS, null);
} else if (TrainExternalOperation.STATUS_FAILED.equals(localStatus)) {
// 终态 FAILED
String errMsg = sanitize(resp.getMessage() != null ? resp.getMessage() : resp.getResultDetails());
@@ -175,6 +187,7 @@ public class DmsOperationPoller {
errMsg != null ? errMsg : "Device execution failed on lock",
"DmsPoller"
);
+ updateAssociatedDoorAuth(op, TrainExternalOperation.STATUS_FAILED, errMsg);
} else if (TrainExternalOperation.STATUS_TIMEOUT.equals(localStatus)) {
// 终态 TIMEOUT
String errMsg = sanitize(resp.getMessage() != null ? resp.getMessage() : resp.getResultDetails());
@@ -187,6 +200,7 @@ public class DmsOperationPoller {
errMsg != null ? errMsg : "Device execution timed out",
"DmsPoller"
);
+ updateAssociatedDoorAuth(op, TrainExternalOperation.STATUS_TIMEOUT, errMsg);
} else if (TrainExternalOperation.STATUS_PENDING.equals(localStatus)) {
// 设备正在执行中 (undone)
int currentAttempt = (op.getAttemptCount() != null ? op.getAttemptCount() : 0) + 1;
@@ -203,7 +217,9 @@ public class DmsOperationPoller {
"Polling exceeded maximum attempts (" + maxAttempts + "), marked as TIMEOUT",
"DmsPoller"
);
+ updateAssociatedDoorAuth(op, TrainExternalOperation.STATUS_TIMEOUT, "Polling exceeded maximum attempts");
} else {
+
// 继续保持 PENDING,设置下次轮询时间
int interval = dmsProperties.getPollIntervalSeconds() > 0 ? dmsProperties.getPollIntervalSeconds() : 10;
Date nextPoll = Date.from(now.plusSeconds(interval));
@@ -263,6 +279,37 @@ public class DmsOperationPoller {
}
}
+ private void updateAssociatedDoorAuth(TrainExternalOperation op, String terminalStatus, String errorMsg) {
+ if (op.getDoorAuthId() == null || dormAuthService == null) {
+ return;
+ }
+
+ String opType = op.getOperationType();
+ if (TrainExternalOperation.STATUS_SUCCESS.equals(terminalStatus)) {
+ if (TrainExternalOperation.TYPE_GRANT.equals(opType)) {
+ int updated = dormAuthService.updateAuthStatusByPoller(
+ op.getDoorAuthId(), TrainDormAuth.AUTH_STATUS_ACTIVE, op.getOperationId(), null, "DmsPoller");
+ if (updated > 0) {
+ log.info("DoorAuthorization {} advanced to ACTIVE by Poller operation {}", op.getDoorAuthId(), op.getOperationId());
+ } else {
+ log.warn("DoorAuthorization {} update to ACTIVE skipped (Stale operation guard): opId={}", op.getDoorAuthId(), op.getOperationId());
+ }
+ } else if (TrainExternalOperation.TYPE_REVOKE.equals(opType)) {
+ int updated = dormAuthService.updateAuthStatusByPoller(
+ op.getDoorAuthId(), TrainDormAuth.AUTH_STATUS_REVOKED, op.getOperationId(), null, "DmsPoller");
+ if (updated > 0) {
+ log.info("DoorAuthorization {} advanced to REVOKED by Poller operation {}", op.getDoorAuthId(), op.getOperationId());
+ } else {
+ log.warn("DoorAuthorization {} update to REVOKED skipped (Stale operation guard): opId={}", op.getDoorAuthId(), op.getOperationId());
+ }
+ }
+ } else {
+ // 外部设备执行失败或超时:推进为 ERROR 状态,防止伪成功
+ dormAuthService.updateAuthStatusByPoller(
+ op.getDoorAuthId(), TrainDormAuth.AUTH_STATUS_ERROR, op.getOperationId(), errorMsg, "DmsPoller");
+ }
+ }
+
private String sanitize(String str) {
if (str == null) {
return null;
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/security/SecurePasswordGenerator.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/security/SecurePasswordGenerator.java
new file mode 100644
index 000000000..3d16c5bec
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/security/SecurePasswordGenerator.java
@@ -0,0 +1,56 @@
+package com.ruoyi.train.integration.dms.security;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+
+/**
+ * 安全随机门禁凭据密码生成器
+ *
+ * 严格安全准则:
+ * 1. 强制采用密码学安全伪随机数生成器 java.security.SecureRandom,禁止使用 Math.random() 或 java.util.Random。
+ * 2. 长度默认 6 位纯数字 (符合 DMS 4~8 位规范)。
+ * 3. 严禁基于学号、身份证号、手机号等包含 PII 隐私信息的确定性规则生成。
+ * 4. 严禁生成固定测试弱密码 (如 123456, 000000, 888888)。
+ * 5. 生成的明文仅在内存中短暂留存,严禁落库、严禁输出至日志或异常信息。
+ */
+public final class SecurePasswordGenerator {
+
+ private static final SecureRandom SECURE_RANDOM;
+
+ static {
+ SecureRandom sr;
+ try {
+ sr = SecureRandom.getInstanceStrong();
+ } catch (NoSuchAlgorithmException e) {
+ sr = new SecureRandom();
+ }
+ SECURE_RANDOM = sr;
+ }
+
+ private SecurePasswordGenerator() {
+ }
+
+ /**
+ * 生成指定位数的随机纯数字密码 (默认 6 位,限制在 4~8 位范围内)
+ *
+ * @param digits 密码长度 (4~8)
+ * @return 格式化后的随机数字字符串
+ */
+ public static String generateNumericPassword(int digits) {
+ if (digits < 4 || digits > 8) {
+ digits = 6;
+ }
+
+ int max = (int) Math.pow(10, digits);
+ int val = SECURE_RANDOM.nextInt(max);
+ String format = "%0" + digits + "d";
+ return String.format(format, val);
+ }
+
+ /**
+ * 生成一期默认的 6 位安全数字密码
+ */
+ public static String generateDefaultPassword() {
+ return generateNumericPassword(6);
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java
index 07eaca37c..2e208eb48 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/ITrainDormAuthService.java
@@ -85,4 +85,37 @@ public interface ITrainDormAuthService
* @return 结果
*/
public int deleteTrainDormAuthByAuthId(Long authId);
+
+ /**
+ * 确保住宿安排对应的门禁授权记录存在 (若不存在则创建,若存在则返回已有,保证幂等)
+ *
+ * @param accommodationId 住宿安排主键
+ * @param operator 操作人
+ * @return 门禁授权记录
+ */
+ public TrainDormAuth ensureAuthorizationForAccommodation(Long accommodationId, String operator);
+
+ /**
+ * 更新业务授权状态及当前驱动的外部操作ID
+ *
+ * @param authId 授权主键
+ * @param authStatus 业务授权状态
+ * @param operationId 外部操作ID
+ * @param operator 操作人
+ * @return 影响行数
+ */
+ public int updateAuthStatus(Long authId, String authStatus, Long operationId, String operator);
+
+ /**
+ * 由对账轮询器回调更新业务授权状态 (内置 Stale Operation 防迟到覆盖保护)
+ * 只有当 operationId 与授权记录当前最新的 latestOperationId 一致时,才允许推进状态
+ *
+ * @param authId 授权主键
+ * @param authStatus 目标业务授权状态
+ * @param operationId 触发更新的操作流水ID
+ * @param lastError 错误消息摘要
+ * @param operator 操作人
+ * @return 影响行数 (若被防迟到保护拦截返回 0)
+ */
+ public int updateAuthStatusByPoller(Long authId, String authStatus, Long operationId, String lastError, String operator);
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java
index a1f0b8884..f03ec26e5 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentAppServiceImpl.java
@@ -84,6 +84,10 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService
@Autowired(required = false)
private StudentIdentityProtection identityProtection;
+ @Autowired(required = false)
+ private com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator accessAuthorizationOrchestrator;
+
+
@Override
public List listEnrollments(String planId, EnrollmentQuery query)
{
@@ -385,6 +389,19 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService
operator,
DateUtils.getNowDate()
);
+
+ // 退训时联动触发门禁权限收回
+ if (Integer.valueOf(EnrollmentErrorCode.STATUS_ABANDONED).equals(newStatus) && accessAuthorizationOrchestrator != null)
+ {
+ try
+ {
+ accessAuthorizationOrchestrator.reconcile(enrollmentId);
+ }
+ catch (Exception ex)
+ {
+ log.warn("Access reconcile triggered on enrollment abandon failed for enrollment {}: {}", enrollmentId, ex.getMessage());
+ }
+ }
}
@Override
@@ -659,7 +676,20 @@ public class EnrollmentAppServiceImpl implements EnrollmentAppService
// 2. 联动更新参训状态为已报到 (1),写入入班报到时间 (join_time)
trainEnrollmentMapper.confirmEnrollmentReport(enrollmentId, now, operator, now);
- // 3. 构造并返回 ReportResultVO
+ // 3. 报到确认成功后,驱动门禁业务对账 (外部网络异常绝不回滚报到主事务,REPORT_CONFIRM_EXTERNAL_FAILURE_ROLLBACK = NO)
+ if (accessAuthorizationOrchestrator != null)
+ {
+ try
+ {
+ accessAuthorizationOrchestrator.reconcile(enrollmentId);
+ }
+ catch (Exception ex)
+ {
+ log.warn("Access reconcile triggered on report confirm failed for enrollment {}: {}", enrollmentId, ex.getMessage());
+ }
+ }
+
+ // 4. 构造并返回 ReportResultVO
TrainStudent student = studentMapper.selectTrainStudentBySId(enrollment.getStudentId());
ReportResultVO vo = new ReportResultVO();
vo.setEnrollmentId(enrollmentId);
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java
index 2ccbef8ee..26f3680a5 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/EnrollmentPlanCompletionAdapter.java
@@ -1,12 +1,17 @@
package com.ruoyi.train.service.impl;
import java.util.Date;
+import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
+import com.ruoyi.train.domain.query.EnrollmentQuery;
+import com.ruoyi.train.domain.vo.EnrollmentItemVO;
+import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator;
+import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.service.ITrainEnrollmentService;
import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort;
@@ -17,6 +22,7 @@ import com.ruoyi.train.service.port.PlanEnrollmentCompletionPort;
* 1. 将该计划仍处于有效参训状态 (0:待报到, 1:已报到) 的学员更新为 4:已完成;
* 2. 写入 complete_time;
* 3. 完整保留考勤、成绩、自主学习时长等历史业务数据,严禁物理删除或级联删除。
+ * 4. 驱动门禁业务对账撤权 (外部调用异常绝不回滚业务主事务)。
*/
@Component
public class EnrollmentPlanCompletionAdapter implements PlanEnrollmentCompletionPort
@@ -26,6 +32,12 @@ public class EnrollmentPlanCompletionAdapter implements PlanEnrollmentCompletion
@Autowired
private ITrainEnrollmentService trainEnrollmentService;
+ @Autowired(required = false)
+ private TrainEnrollmentMapper trainEnrollmentMapper;
+
+ @Autowired(required = false)
+ private AccessAuthorizationOrchestrator accessAuthorizationOrchestrator;
+
@Override
@Transactional(propagation = Propagation.MANDATORY, rollbackFor = Exception.class)
public void completeEnrollments(String planId, String operator, Date completeTime)
@@ -33,5 +45,33 @@ public class EnrollmentPlanCompletionAdapter implements PlanEnrollmentCompletion
log.info("执行计划结训学员状态联动归档: planId={}, operator={}, completeTime={}", planId, operator, completeTime);
int updatedRows = trainEnrollmentService.completeActiveEnrollmentsByPlanId(planId, operator, completeTime);
log.info("计划结训学员归档完成: planId={}, 更新记录数={}", planId, updatedRows);
+
+ if (accessAuthorizationOrchestrator != null && trainEnrollmentMapper != null)
+ {
+ try
+ {
+ EnrollmentQuery query = new EnrollmentQuery();
+ query.setPlanId(planId);
+ List list = trainEnrollmentMapper.selectEnrollmentItemList(query);
+ if (list != null)
+ {
+ for (EnrollmentItemVO item : list)
+ {
+ try
+ {
+ accessAuthorizationOrchestrator.reconcile(item.getEnrollmentId());
+ }
+ catch (Exception ex)
+ {
+ log.warn("Access reconcile on plan completion failed for enrollment {}: {}", item.getEnrollmentId(), ex.getMessage());
+ }
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ log.warn("Failed to trigger access reconcile on plan completion for planId {}: {}", planId, ex.getMessage());
+ }
+ }
}
}
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 23d997a2e..e85f9d5f4 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
@@ -10,10 +10,13 @@ import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.train.domain.TrainAccommodation;
import com.ruoyi.train.domain.TrainDormitoryInfo;
import com.ruoyi.train.domain.TrainEnrollment;
+import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
import com.ruoyi.train.service.ITrainAccommodationService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
/**
* 住宿安排Service业务层处理
@@ -24,6 +27,8 @@ import com.ruoyi.train.service.ITrainAccommodationService;
@Service
public class TrainAccommodationServiceImpl implements ITrainAccommodationService
{
+ private static final Logger log = LoggerFactory.getLogger(TrainAccommodationServiceImpl.class);
+
@Autowired
private TrainAccommodationMapper trainAccommodationMapper;
@@ -33,6 +38,10 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
@Autowired
private TrainEnrollmentMapper trainEnrollmentMapper;
+ @Autowired(required = false)
+ private AccessAuthorizationOrchestrator accessAuthorizationOrchestrator;
+
+
/**
* 查询住宿安排详情
*
@@ -169,9 +178,19 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
{
accommodation.setAccommodationStatus(TrainAccommodation.STATUS_PREALLOCATED);
}
- accommodation.setCreateTime(DateUtils.getNowDate());
-
- return trainAccommodationMapper.insertTrainAccommodation(accommodation);
+ int rows = trainAccommodationMapper.insertTrainAccommodation(accommodation);
+ if (rows > 0 && accessAuthorizationOrchestrator != null)
+ {
+ try
+ {
+ accessAuthorizationOrchestrator.reconcile(enrollmentId);
+ }
+ catch (Exception ex)
+ {
+ log.warn("Access reconcile triggered on accommodation insert failed for enrollment {}: {}", enrollmentId, ex.getMessage());
+ }
+ }
+ return rows;
}
/**
@@ -242,8 +261,20 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
{
return 1; // 已经是退宿状态
}
- return trainAccommodationMapper.updateAccommodationStatus(accommodationId,
+ int rows = trainAccommodationMapper.updateAccommodationStatus(accommodationId,
TrainAccommodation.STATUS_CHECKED_OUT, operator, DateUtils.getNowDate());
+ if (rows > 0 && accessAuthorizationOrchestrator != null)
+ {
+ try
+ {
+ accessAuthorizationOrchestrator.reconcile(exist.getEnrollmentId());
+ }
+ catch (Exception ex)
+ {
+ log.warn("Access reconcile triggered on checkout failed for enrollment {}: {}", exist.getEnrollmentId(), ex.getMessage());
+ }
+ }
+ return rows;
}
/**
@@ -284,9 +315,24 @@ public class TrainAccommodationServiceImpl implements ITrainAccommodationService
newAcc.setRemark("由原住宿记录 " + oldAccommodationId + " (宿舍 " + oldAcc.getDormId() + ") 调房迁入");
insertTrainAccommodation(newAcc);
+
+ // 3. 调房遵循 GRANT_NEW_THEN_REVOKE_OLD:先为新房发起对账发放,旧房在新房生效前绝不注销
+ if (accessAuthorizationOrchestrator != null)
+ {
+ try
+ {
+ accessAuthorizationOrchestrator.reconcile(oldAcc.getEnrollmentId());
+ }
+ catch (Exception ex)
+ {
+ log.warn("Access reconcile triggered on transfer failed for enrollment {}: {}", oldAcc.getEnrollmentId(), ex.getMessage());
+ }
+ }
+
return newAcc;
}
+
/**
* 批量删除住宿安排
*
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java
index eeb6d1ee5..da81a9820 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/TrainDormAuthServiceImpl.java
@@ -200,4 +200,99 @@ public class TrainDormAuthServiceImpl implements ITrainDormAuthService
{
return trainDormAuthMapper.deleteTrainDormAuthByAuthId(authId);
}
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public TrainDormAuth ensureAuthorizationForAccommodation(Long accommodationId, String operator)
+ {
+ if (accommodationId == null)
+ {
+ throw new ServiceException("accommodationId 不能为空");
+ }
+ TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId);
+ if (exist != null)
+ {
+ return exist;
+ }
+
+ TrainAccommodation acc = trainAccommodationMapper.selectTrainAccommodationByAccommodationId(accommodationId);
+ if (acc == null)
+ {
+ throw new ServiceException("住宿安排不存在: " + accommodationId);
+ }
+
+ TrainDormAuth newAuth = new TrainDormAuth();
+ newAuth.setAccommodationId(accommodationId);
+ newAuth.setAuthStatus(TrainDormAuth.AUTH_STATUS_PENDING_GRANT);
+ newAuth.setCredentialType(TrainDormAuth.CREDENTIAL_TYPE_ONLINE_CODE);
+ newAuth.setOperationVersion(1);
+ newAuth.setFaceSyncStatus(TrainDormAuth.STATUS_PENDING);
+ newAuth.setRetryCount(0);
+ newAuth.setCreateBy(operator != null ? operator : "system");
+ newAuth.setCreateTime(DateUtils.getNowDate());
+
+ try
+ {
+ trainDormAuthMapper.insertTrainDormAuth(newAuth);
+ return newAuth;
+ }
+ catch (Exception e)
+ {
+ // 并发冲突防重保护:如果并发创建触发了唯一约束,重新读取已有记录并返回
+ TrainDormAuth concurrentAuth = trainDormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId);
+ if (concurrentAuth != null)
+ {
+ return concurrentAuth;
+ }
+ throw new ServiceException("创建门禁授权失败: " + e.getMessage());
+ }
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int updateAuthStatus(Long authId, String authStatus, Long operationId, String operator)
+ {
+ TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId);
+ if (exist == null)
+ {
+ throw new ServiceException("门禁授权记录不存在: " + authId);
+ }
+ exist.setAuthStatus(authStatus);
+ if (operationId != null)
+ {
+ exist.setLatestOperationId(operationId);
+ }
+ exist.setOperationVersion((exist.getOperationVersion() != null ? exist.getOperationVersion() : 1) + 1);
+ exist.setUpdateBy(operator);
+ exist.setUpdateTime(DateUtils.getNowDate());
+ return trainDormAuthMapper.updateTrainDormAuth(exist);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int updateAuthStatusByPoller(Long authId, String authStatus, Long operationId, String lastError, String operator)
+ {
+ TrainDormAuth exist = trainDormAuthMapper.selectTrainDormAuthByAuthId(authId);
+ if (exist == null)
+ {
+ return 0;
+ }
+
+ // 核心防迟到保护 (STALE_OPERATION_RESULT_GUARD)
+ // 只有当传入的 operationId 与当前记录绑定的 latestOperationId 相符时才更新
+ if (operationId != null && exist.getLatestOperationId() != null && !operationId.equals(exist.getLatestOperationId()))
+ {
+ // 说明已被后续发起的操作替代,忽略此迟到结果
+ return 0;
+ }
+
+ exist.setAuthStatus(authStatus);
+ if (lastError != null)
+ {
+ exist.setLastError(lastError);
+ }
+ exist.setUpdateBy(operator != null ? operator : "DmsPoller");
+ exist.setUpdateTime(DateUtils.getNowDate());
+ return trainDormAuthMapper.updateTrainDormAuth(exist);
+ }
}
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
new file mode 100644
index 000000000..320efa19a
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrationTest.java
@@ -0,0 +1,765 @@
+package com.ruoyi.train.integration.dms.orchestration;
+
+import com.ruoyi.train.domain.*;
+import com.ruoyi.train.integration.dms.auth.DmsTokenManager;
+import com.ruoyi.train.integration.dms.client.DmsHttpClient;
+import com.ruoyi.train.integration.dms.client.DmsOperationStatusClient;
+import com.ruoyi.train.integration.dms.client.DmsPasswordClient;
+import com.ruoyi.train.integration.dms.config.DmsProperties;
+import com.ruoyi.train.integration.dms.gateway.DmsCredentialGatewayImpl;
+import com.ruoyi.train.integration.dms.mock.MockDmsServer;
+import com.ruoyi.train.integration.dms.poller.DmsOperationPoller;
+import com.ruoyi.train.mapper.TrainAccommodationMapper;
+import com.ruoyi.train.mapper.TrainDormAuthMapper;
+import com.ruoyi.train.mapper.TrainEnrollmentMapper;
+import com.ruoyi.train.service.IExternalOperationService;
+import com.ruoyi.train.service.IExternalResourceMappingService;
+import com.ruoyi.train.service.ITrainDormAuthService;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Proxy;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static org.junit.Assert.*;
+
+/**
+ * 门禁业务授权编排端到端集成测试 (Wave 3A Dry-Run)
+ *
+ * 遵循安全红线:
+ * 1. 100% 连接轻量内嵌 MockDmsServer,零真实 DMS 请求 (LIVE_DMS_REQUEST_COUNT = 0)。
+ * 2. 状态驱动对账,事件顺序无关 (Scenario A & Scenario B)。
+ * 3. 调房先下发新房凭据,新房生效后注销旧房 (GRANT_NEW_THEN_REVOKE_OLD)。
+ * 4. 新房发放失败严格保留旧房凭据 (TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES)。
+ * 5. 最小特权主锁原则 (PRIMARY_LOCK_ONLY)。
+ * 6. 迟到操作防覆写保护 (STALE_OPERATION_RESULT_GUARD)。
+ * 7. 密码明文零落库与全流程脱敏。
+ */
+public class AccessAuthorizationOrchestrationTest {
+
+ private MockDmsServer mockServer;
+ private DmsProperties properties;
+ private AccessAuthorizationOrchestrator orchestrator;
+ private DmsOperationPoller poller;
+
+ // 内存测试数据库存储
+ private final Map enrollmentDb = new ConcurrentHashMap<>();
+ private final Map accommodationDb = new ConcurrentHashMap<>();
+ private final Map dormAuthDb = new ConcurrentHashMap<>();
+ private final Map resourceMappingDb = new ConcurrentHashMap<>();
+ private final Map operationDb = new ConcurrentHashMap<>();
+ private final Map operationIdempotencyDb = new ConcurrentHashMap<>();
+
+ private final AtomicLong authSeq = new AtomicLong(100);
+ private final AtomicLong opSeq = new AtomicLong(500);
+
+ @Before
+ public void setUp() throws Exception {
+ mockServer = new MockDmsServer();
+ mockServer.start();
+
+ properties = new DmsProperties();
+ properties.setEnabled(true);
+ properties.setBaseUrl(mockServer.getBaseUrl());
+ properties.setAppId("test-app-id");
+ properties.setAppSecret("test-app-secret");
+ properties.setTokenTtlSeconds(7200);
+ properties.setPollIntervalSeconds(10);
+ properties.setMaxRetryAttempts(3);
+
+ DmsTokenManager tokenManager = new DmsTokenManager(properties);
+ DmsHttpClient httpClient = new DmsHttpClient(properties, tokenManager);
+ DmsPasswordClient passwordClient = new DmsPasswordClient(httpClient);
+ DmsCredentialGatewayImpl credentialGateway = new DmsCredentialGatewayImpl(passwordClient);
+ DmsOperationStatusClient statusClient = new DmsOperationStatusClient(httpClient);
+
+ TrainEnrollmentMapper enrollmentMapper = createMockEnrollmentMapper();
+ TrainAccommodationMapper accommodationMapper = createMockAccommodationMapper();
+ TrainDormAuthMapper dormAuthMapper = createMockDormAuthMapper();
+ IExternalResourceMappingService mappingService = createMockMappingService();
+ IExternalOperationService operationService = createMockOperationService();
+
+ // 构造真正的 TrainDormAuthServiceImpl 注入代理依赖
+ com.ruoyi.train.service.impl.TrainDormAuthServiceImpl authService = new com.ruoyi.train.service.impl.TrainDormAuthServiceImpl();
+ setPrivateField(authService, "trainDormAuthMapper", dormAuthMapper);
+ setPrivateField(authService, "trainAccommodationMapper", accommodationMapper);
+
+ poller = new DmsOperationPoller(properties, operationService, statusClient, authService);
+
+ orchestrator = new AccessAuthorizationOrchestrator(
+ enrollmentMapper,
+ accommodationMapper,
+ dormAuthMapper,
+ authService,
+ mappingService,
+ operationService,
+ credentialGateway
+ );
+
+ // 默认初始化宿舍 201 映射:1 把主门锁 (lockId=5001)
+ TrainExternalResourceMapping room = new TrainExternalResourceMapping();
+ room.setDormId("DORM-201");
+ room.setVendorCode("DMS");
+ room.setLocalResourceType("DORM_ROOM");
+ room.setExternalResourceType("ROOM");
+ room.setExternalResourceId("10001");
+ room.setMappingStatus("ACTIVE");
+ resourceMappingDb.put("ROOM:DORM-201", room);
+
+ TrainExternalResourceMapping lock = new TrainExternalResourceMapping();
+ lock.setDormId("DORM-201");
+ lock.setVendorCode("DMS");
+ lock.setLocalResourceType("DOOR_LOCK");
+ lock.setExternalResourceType("DOOR_LOCK");
+ lock.setExternalResourceId("5001");
+ lock.setExternalHardwareId("LK-HD-201-MAIN");
+ lock.setIsPrimary(1);
+ lock.setMappingStatus("ACTIVE");
+ resourceMappingDb.put("LOCK:DORM-201:5001", lock);
+ }
+
+ @After
+ public void tearDown() {
+ if (mockServer != null) {
+ mockServer.stop();
+ }
+ enrollmentDb.clear();
+ accommodationDb.clear();
+ dormAuthDb.clear();
+ resourceMappingDb.clear();
+ operationDb.clear();
+ operationIdempotencyDb.clear();
+ }
+
+ /**
+ * Scenario A: 先 Accommodation 后 Report Confirm,最终推进到 ACTIVE
+ */
+ @Test
+ public void testScenarioA_AccommodationFirstThenReportConfirm() {
+ Long enrollmentId = 1L;
+ // 1. 学员登记但待报到 (status = 0)
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(enrollmentId);
+ enrollment.setStudentId("STU-001");
+ enrollment.setPlanId("PLAN-101");
+ enrollment.setEnrollmentStatus(0);
+ enrollmentDb.put(enrollmentId, enrollment);
+
+ // 2. 预先分配宿舍 201 (status = 0)
+ TrainAccommodation acc = new TrainAccommodation();
+ acc.setAccommodationId(10L);
+ acc.setEnrollmentId(enrollmentId);
+ acc.setDormId("DORM-201");
+ acc.setAccommodationStatus(0);
+ acc.setAssignedTime(new Date());
+ accommodationDb.put(10L, acc);
+
+ // 此时触发 reconcile -> WAITING_FOR_PREREQUISITE (未报到,严禁下发)
+ ReconciliationResult r1 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision());
+ assertEquals("Mock server must have 0 password creates", 0, mockServer.getPasswordCreatePostCounter());
+
+ // 3. 学员报到确认 (status -> 1)
+ enrollment.setEnrollmentStatus(1);
+
+ // 触发 reconcile -> 成功发起 GRANT
+ ReconciliationResult r2 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision());
+ assertTrue(r2.isSuccess());
+ assertEquals(1, mockServer.getPasswordCreatePostCounter());
+
+ TrainDormAuth auth = getAuthById(r2.getDoorAuthId());
+ assertNotNull(auth);
+ assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus());
+ assertNotEquals("Auth must not be ACTIVE yet immediately after HTTP call", TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
+
+ // 4. Poller 轮询未完成状态 (undone) -> 保持 GRANTING / PENDING
+ mockServer.setOperationStatus(99001L, "undone", "Device task queued");
+ poller.pollPendingOperations();
+ assertEquals(TrainDormAuth.AUTH_STATUS_GRANTING, auth.getAuthStatus());
+
+ // 5. Poller 轮询成功终态 (success) -> 推进 DoorAuthorization 为 ACTIVE
+ mockServer.setOperationStatus(99001L, "success", "Lock accepted key");
+ poller.pollPendingOperations();
+ assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
+ assertEquals(TrainDormAuth.STATUS_ACTIVE, auth.getFaceSyncStatus());
+ }
+
+ /**
+ * Scenario B: 先 Report Confirm 后 Accommodation 分配,最终推进到 ACTIVE
+ */
+ @Test
+ public void testScenarioB_ReportConfirmFirstThenAccommodation() {
+ Long enrollmentId = 2L;
+ // 1. 学员先报到完成 (status = 1),但尚未分房
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(enrollmentId);
+ enrollment.setStudentId("STU-002");
+ enrollment.setPlanId("PLAN-101");
+ enrollment.setEnrollmentStatus(1);
+ enrollmentDb.put(enrollmentId, enrollment);
+
+ // 对账 -> 等待分配宿舍
+ ReconciliationResult r1 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision());
+ assertEquals(0, mockServer.getPasswordCreatePostCounter());
+
+ // 2. 后续分配宿舍 201
+ TrainAccommodation acc = new TrainAccommodation();
+ acc.setAccommodationId(20L);
+ acc.setEnrollmentId(enrollmentId);
+ acc.setDormId("DORM-201");
+ acc.setAccommodationStatus(1);
+ acc.setAssignedTime(new Date());
+ accommodationDb.put(20L, acc);
+
+ // 对账 -> 满足所有条件,成功发起 GRANT
+ ReconciliationResult r2 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision());
+ assertTrue(r2.isSuccess());
+ assertEquals(1, mockServer.getPasswordCreatePostCounter());
+
+ // 模拟对账对齐
+ mockServer.setOperationStatus(99001L, "success", "Success");
+ poller.pollPendingOperations();
+
+ TrainDormAuth auth = getAuthById(r2.getDoorAuthId());
+ assertNotNull(auth);
+ assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
+ }
+
+ /**
+ * 幂等性与防重测试:ACTIVE 后重复触发 5 次,绝不重复发码、绝不创建重复授权
+ */
+ @Test
+ public void testReconciliationIdempotency_NoDuplicateOperations() {
+ Long enrollmentId = 3L;
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(enrollmentId);
+ enrollment.setStudentId("STU-003");
+ enrollment.setEnrollmentStatus(1);
+ enrollmentDb.put(enrollmentId, enrollment);
+
+ TrainAccommodation acc = new TrainAccommodation();
+ acc.setAccommodationId(30L);
+ acc.setEnrollmentId(enrollmentId);
+ acc.setDormId("DORM-201");
+ acc.setAccommodationStatus(1);
+ acc.setAssignedTime(new Date());
+ accommodationDb.put(30L, acc);
+
+ // 首次对账
+ orchestrator.reconcile(enrollmentId);
+ mockServer.setOperationStatus(99001L, "success", "Success");
+ poller.pollPendingOperations();
+
+ assertEquals(1, mockServer.getPasswordCreatePostCounter());
+ assertEquals(1, dormAuthDb.size());
+
+ // 连续重复触发 5 次
+ for (int i = 0; i < 5; i++) {
+ ReconciliationResult res = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.NO_ACTION, res.getDecision());
+ }
+
+ // 计数依然严格保持为 1
+ assertEquals("Post counter must stay 1", 1, mockServer.getPasswordCreatePostCounter());
+ assertEquals("DormAuth count must stay 1", 1, dormAuthDb.size());
+ }
+
+ /**
+ * 下发失败场景:设备执行失败 -> 授权为 ERROR,重新对账重试生成全新 Operation
+ */
+ @Test
+ public void testGrantFailureScenario_RetryGeneratesNewOperation() {
+ Long enrollmentId = 4L;
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(enrollmentId);
+ enrollment.setStudentId("STU-004");
+ enrollment.setEnrollmentStatus(1);
+ enrollmentDb.put(enrollmentId, enrollment);
+
+ TrainAccommodation acc = new TrainAccommodation();
+ acc.setAccommodationId(40L);
+ acc.setEnrollmentId(enrollmentId);
+ acc.setDormId("DORM-201");
+ acc.setAccommodationStatus(1);
+ accommodationDb.put(40L, acc);
+
+ ReconciliationResult r1 = orchestrator.reconcile(enrollmentId);
+ Long firstOpId = r1.getOperationId();
+
+ // 模拟外部门锁执行失败
+ mockServer.setOperationStatus(99001L, "failed", "Motor jammed");
+ poller.pollPendingOperations();
+
+ TrainDormAuth auth = getAuthById(r1.getDoorAuthId());
+ assertNotNull(auth);
+ assertEquals(TrainDormAuth.AUTH_STATUS_ERROR, auth.getAuthStatus());
+ assertNotEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
+
+ // 再次触发对账 -> 重试生成新 Operation
+ ReconciliationResult r2 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.GRANT_REQUIRED, r2.getDecision());
+ Long secondOpId = r2.getOperationId();
+
+ assertNotEquals("New operation ID must be generated", firstOpId, secondOpId);
+ assertEquals("Previous failed operation must remain in DB",
+ TrainExternalOperation.STATUS_FAILED, operationDb.get(firstOpId).getOperationStatus());
+ }
+
+ /**
+ * 撤销权限生命周期:退训/结训 -> 触发 REVOKE -> poller 确认后变为 REVOKED
+ */
+ @Test
+ public void testRevokeLifecycle_OnEnrollmentTermination() {
+ Long enrollmentId = 5L;
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(enrollmentId);
+ enrollment.setStudentId("STU-005");
+ enrollment.setEnrollmentStatus(1);
+ enrollmentDb.put(enrollmentId, enrollment);
+
+ TrainAccommodation acc = new TrainAccommodation();
+ acc.setAccommodationId(50L);
+ acc.setEnrollmentId(enrollmentId);
+ acc.setDormId("DORM-201");
+ acc.setAccommodationStatus(1);
+ accommodationDb.put(50L, acc);
+
+ // 先 Grant 成功并生效
+ orchestrator.reconcile(enrollmentId);
+ mockServer.setOperationStatus(99001L, "success", "Success");
+ poller.pollPendingOperations();
+
+ TrainDormAuth auth = dormAuthDb.get(dormAuthDb.keySet().iterator().next());
+ assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
+
+ // 学员退训 (status -> 3)
+ enrollment.setEnrollmentStatus(3);
+
+ // 触发对账 -> 进入 REVOKING
+ ReconciliationResult revokeResult = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.REVOKE_REQUIRED, revokeResult.getDecision());
+ assertEquals(TrainDormAuth.AUTH_STATUS_REVOKING, auth.getAuthStatus());
+ assertNotEquals("Auth must not be REVOKED immediately", TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus());
+
+ // Poller 轮询成功 -> 真正进入 REVOKED 终态
+ mockServer.setOperationStatus(99002L, "success", "Delete confirmed");
+ poller.pollPendingOperations();
+
+ assertEquals(TrainDormAuth.AUTH_STATUS_REVOKED, auth.getAuthStatus());
+ assertEquals(TrainDormAuth.STATUS_REVOKED, auth.getFaceSyncStatus());
+ }
+
+ /**
+ * 调房 (Transfer) 场景:新房 Grant 成功后才撤销旧房 (GRANT_NEW_THEN_REVOKE_OLD)
+ */
+ @Test
+ public void testTransfer_GrantNewThenRevokeOld() {
+ Long enrollmentId = 6L;
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(enrollmentId);
+ enrollment.setStudentId("STU-006");
+ enrollment.setEnrollmentStatus(1);
+ enrollmentDb.put(enrollmentId, enrollment);
+
+ // 初始宿舍 201 已入住且授权 ACTIVE
+ TrainAccommodation oldAcc = new TrainAccommodation();
+ oldAcc.setAccommodationId(60L);
+ oldAcc.setEnrollmentId(enrollmentId);
+ oldAcc.setDormId("DORM-201");
+ oldAcc.setAccommodationStatus(1);
+ accommodationDb.put(60L, oldAcc);
+
+ orchestrator.reconcile(enrollmentId);
+ mockServer.setOperationStatus(99001L, "success", "Success");
+ poller.pollPendingOperations();
+
+ TrainDormAuth oldAuth = dormAuthDb.get(60L);
+ assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
+
+ // 配置新宿舍 202 映射 (lockId=5002)
+ TrainExternalResourceMapping room202 = new TrainExternalResourceMapping();
+ room202.setDormId("DORM-202");
+ room202.setVendorCode("DMS");
+ room202.setLocalResourceType("DORM_ROOM");
+ room202.setExternalResourceType("ROOM");
+ room202.setExternalResourceId("10002");
+ room202.setMappingStatus("ACTIVE");
+ resourceMappingDb.put("ROOM:DORM-202", room202);
+
+ TrainExternalResourceMapping lock202 = new TrainExternalResourceMapping();
+ lock202.setDormId("DORM-202");
+ lock202.setVendorCode("DMS");
+ lock202.setLocalResourceType("DOOR_LOCK");
+ lock202.setExternalResourceType("DOOR_LOCK");
+ lock202.setExternalResourceId("5002");
+ lock202.setExternalHardwareId("LK-HD-202-MAIN");
+ lock202.setIsPrimary(1);
+ lock202.setMappingStatus("ACTIVE");
+ resourceMappingDb.put("LOCK:DORM-202:5002", lock202);
+
+ // 执行本地调房:旧住宿退宿(2),新住宿入住(1)
+ oldAcc.setAccommodationStatus(2);
+
+ TrainAccommodation newAcc = new TrainAccommodation();
+ newAcc.setAccommodationId(61L);
+ newAcc.setEnrollmentId(enrollmentId);
+ newAcc.setDormId("DORM-202");
+ newAcc.setAccommodationStatus(1);
+ accommodationDb.put(61L, newAcc);
+
+ // 调房后触发对账:必须先 Grant 新房 202!旧房 201 绝不撤销!
+ ReconciliationResult transferR1 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.GRANT_REQUIRED, transferR1.getDecision());
+ assertEquals("Old room authorization must remain ACTIVE while new room is granting",
+ TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
+ assertEquals("Delete request must be 0 at this point", 0, mockServer.getPasswordDeletePostCounter());
+
+ // 模拟新房 202 经 Poller 确认为 ACTIVE
+ TrainDormAuth newAuth = dormAuthDb.get(61L);
+ mockServer.setOperationStatus(99001L, "success", "New room key accepted");
+ poller.pollPendingOperations();
+ assertEquals(TrainDormAuth.AUTH_STATUS_ACTIVE, newAuth.getAuthStatus());
+
+ // 新房确认 ACTIVE 后,再次对账 -> 触发旧房 REVOKE
+ ReconciliationResult transferR2 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.REVOKE_REQUIRED, transferR2.getDecision());
+ assertEquals("Old room authorization enters REVOKING", TrainDormAuth.AUTH_STATUS_REVOKING, oldAuth.getAuthStatus());
+ assertEquals(1, mockServer.getPasswordDeletePostCounter());
+ }
+
+ /**
+ * 调房部分失败安全:新房 Grant 失败时,旧房授权严格保持 ACTIVE (TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES)
+ */
+ @Test
+ public void testTransfer_NewRoomGrantFailure_PreservesOldAccess() {
+ Long enrollmentId = 7L;
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(enrollmentId);
+ enrollment.setStudentId("STU-007");
+ enrollment.setEnrollmentStatus(1);
+ enrollmentDb.put(enrollmentId, enrollment);
+
+ TrainAccommodation oldAcc = new TrainAccommodation();
+ oldAcc.setAccommodationId(70L);
+ oldAcc.setEnrollmentId(enrollmentId);
+ oldAcc.setDormId("DORM-201");
+ oldAcc.setAccommodationStatus(2); // 已迁出
+ accommodationDb.put(70L, oldAcc);
+
+ // 旧房已有有效授权
+ TrainDormAuth oldAuth = new TrainDormAuth();
+ oldAuth.setAuthId(700L);
+ oldAuth.setAccommodationId(70L);
+ oldAuth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
+ oldAuth.setFaceSyncStatus(TrainDormAuth.STATUS_ACTIVE);
+ dormAuthDb.put(70L, oldAuth);
+
+ // 新房 203 (缺少映射,导致无法 Grant)
+ TrainAccommodation newAcc = new TrainAccommodation();
+ newAcc.setAccommodationId(71L);
+ newAcc.setEnrollmentId(enrollmentId);
+ newAcc.setDormId("DORM-203");
+ newAcc.setAccommodationStatus(1);
+ accommodationDb.put(71L, newAcc);
+
+ // 对账 -> 新房因缺少映射返回 WAITING
+ ReconciliationResult res = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, res.getDecision());
+
+ // 核心安全指标:旧房授权绝对不被撤销!
+ assertEquals("TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES: Old access must remain ACTIVE",
+ TrainDormAuth.AUTH_STATUS_ACTIVE, oldAuth.getAuthStatus());
+ assertEquals("Delete post count must be 0", 0, mockServer.getPasswordDeletePostCounter());
+ }
+
+ /**
+ * 主锁策略校验 (PRIMARY_LOCK_ONLY):
+ * 0 主锁 -> 不发放
+ * 2 主锁 -> 配置错误不发放
+ */
+ @Test
+ public void testLockSelectionPolicy_PrimaryLockOnly() {
+ Long enrollmentId = 8L;
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(enrollmentId);
+ enrollment.setStudentId("STU-008");
+ enrollment.setEnrollmentStatus(1);
+ enrollmentDb.put(enrollmentId, enrollment);
+
+ TrainAccommodation acc = new TrainAccommodation();
+ acc.setAccommodationId(80L);
+ acc.setEnrollmentId(enrollmentId);
+ acc.setDormId("DORM-NO-PRIMARY");
+ acc.setAccommodationStatus(1);
+ accommodationDb.put(80L, acc);
+
+ TrainExternalResourceMapping room = new TrainExternalResourceMapping();
+ room.setDormId("DORM-NO-PRIMARY");
+ room.setVendorCode("DMS");
+ room.setMappingStatus("ACTIVE");
+ room.setExternalResourceType("ROOM");
+ resourceMappingDb.put("ROOM:DORM-NO-PRIMARY", room);
+
+ // 1. 无主锁情况
+ TrainExternalResourceMapping secLock = new TrainExternalResourceMapping();
+ secLock.setDormId("DORM-NO-PRIMARY");
+ secLock.setVendorCode("DMS");
+ secLock.setIsPrimary(0); // 次锁
+ secLock.setMappingStatus("ACTIVE");
+ secLock.setExternalResourceType("DOOR_LOCK");
+ secLock.setExternalResourceId("6001");
+ resourceMappingDb.put("LOCK:DORM-NO-PRIMARY:6001", secLock);
+
+ ReconciliationResult r1 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r1.getDecision());
+ assertTrue(r1.getReason().contains("0 primary lock"));
+ assertEquals(0, mockServer.getPasswordCreatePostCounter());
+
+ // 2. 多主锁配置错误情况
+ TrainExternalResourceMapping p1 = new TrainExternalResourceMapping();
+ p1.setDormId("DORM-MULTI-PRIMARY");
+ p1.setIsPrimary(1);
+ p1.setMappingStatus("ACTIVE");
+ p1.setExternalResourceType("DOOR_LOCK");
+ p1.setExternalResourceId("7001");
+ resourceMappingDb.put("LOCK:DORM-MULTI-PRIMARY:7001", p1);
+
+ TrainExternalResourceMapping p2 = new TrainExternalResourceMapping();
+ p2.setDormId("DORM-MULTI-PRIMARY");
+ p2.setIsPrimary(1);
+ p2.setMappingStatus("ACTIVE");
+ p2.setExternalResourceType("DOOR_LOCK");
+ p2.setExternalResourceId("7002");
+ resourceMappingDb.put("LOCK:DORM-MULTI-PRIMARY:7002", p2);
+
+ TrainExternalResourceMapping multiRoom = new TrainExternalResourceMapping();
+ multiRoom.setDormId("DORM-MULTI-PRIMARY");
+ multiRoom.setVendorCode("DMS");
+ multiRoom.setMappingStatus("ACTIVE");
+ multiRoom.setExternalResourceType("ROOM");
+ resourceMappingDb.put("ROOM:DORM-MULTI-PRIMARY", multiRoom);
+
+ acc.setDormId("DORM-MULTI-PRIMARY");
+
+ ReconciliationResult r2 = orchestrator.reconcile(enrollmentId);
+ assertEquals(ReconciliationDecision.WAITING_FOR_PREREQUISITE, r2.getDecision());
+ assertTrue(r2.getReason().contains("configuration error"));
+ assertEquals(0, mockServer.getPasswordCreatePostCounter());
+ }
+
+ /**
+ * 防迟到覆盖保护测试 (STALE_OPERATION_RESULT_GUARD)
+ * 操作 1 迟到返回 SUCCESS,但授权已进入操作 2,操作 1 绝不覆写状态
+ */
+ @Test
+ public void testStaleOperationProtection_NeverOverwritesNewState() {
+ TrainDormAuth auth = new TrainDormAuth();
+ auth.setAuthId(900L);
+ auth.setAccommodationId(90L);
+ auth.setAuthStatus(TrainDormAuth.AUTH_STATUS_ACTIVE);
+ auth.setLatestOperationId(502L); // 当前最新操作是 502
+ dormAuthDb.put(90L, auth);
+
+ TrainExternalOperation staleOp = new TrainExternalOperation();
+ staleOp.setOperationId(501L); // 旧操作 501
+ staleOp.setDoorAuthId(900L);
+ staleOp.setOperationType(TrainExternalOperation.TYPE_GRANT);
+ staleOp.setOperationStatus(TrainExternalOperation.STATUS_PENDING);
+ staleOp.setExternalOperationId("99001");
+ operationDb.put(501L, staleOp);
+
+ // 模拟旧操作 501 迟到返回 FAILED
+ mockServer.setOperationStatus(99001L, "failed", "Late failure");
+ poller.pollPendingOperations();
+
+ // 状态必须依然保持 ACTIVE,绝不能被旧操作的 FAILED 改为 ERROR!
+ assertEquals("STALE_OPERATION_RESULT_GUARD: Must NOT overwrite state with stale operation result",
+ TrainDormAuth.AUTH_STATUS_ACTIVE, auth.getAuthStatus());
+ }
+
+ /* ==================== 辅助 Mock 工厂方法 ==================== */
+
+ private TrainDormAuth getAuthById(Long authId) {
+ if (authId == null) return null;
+ return dormAuthDb.values().stream().filter(a -> authId.equals(a.getAuthId())).findFirst().orElse(null);
+ }
+
+ private TrainEnrollmentMapper createMockEnrollmentMapper() {
+ return (TrainEnrollmentMapper) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{TrainEnrollmentMapper.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("selectTrainEnrollmentById".equals(name)) {
+ return enrollmentDb.get((Long) args[0]);
+ }
+ return null;
+ }
+ );
+ }
+
+ private TrainAccommodationMapper createMockAccommodationMapper() {
+ return (TrainAccommodationMapper) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{TrainAccommodationMapper.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("selectActiveAccommodationByEnrollmentId".equals(name)) {
+ Long eid = (Long) args[0];
+ return accommodationDb.values().stream()
+ .filter(a -> eid.equals(a.getEnrollmentId()) && (a.getAccommodationStatus() == 0 || a.getAccommodationStatus() == 1))
+ .findFirst().orElse(null);
+ }
+ if ("selectAccommodationHistoryByEnrollmentId".equals(name)) {
+ Long eid = (Long) args[0];
+ List list = new ArrayList<>();
+ for (TrainAccommodation a : accommodationDb.values()) {
+ if (eid.equals(a.getEnrollmentId())) {
+ list.add(a);
+ }
+ }
+ return list;
+ }
+ if ("selectTrainAccommodationByAccommodationId".equals(name)) {
+ return accommodationDb.get((Long) args[0]);
+ }
+ return null;
+ }
+ );
+ }
+
+ private TrainDormAuthMapper createMockDormAuthMapper() {
+ return (TrainDormAuthMapper) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{TrainDormAuthMapper.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("selectTrainDormAuthByAccommodationId".equals(name)) {
+ return dormAuthDb.get((Long) args[0]);
+ }
+ if ("selectTrainDormAuthByAuthId".equals(name)) {
+ Long aid = (Long) args[0];
+ return dormAuthDb.values().stream().filter(a -> aid.equals(a.getAuthId())).findFirst().orElse(null);
+ }
+ if ("insertTrainDormAuth".equals(name)) {
+ TrainDormAuth auth = (TrainDormAuth) args[0];
+ auth.setAuthId(authSeq.incrementAndGet());
+ dormAuthDb.put(auth.getAccommodationId(), auth);
+ return 1;
+ }
+ if ("updateTrainDormAuth".equals(name)) {
+ TrainDormAuth auth = (TrainDormAuth) args[0];
+ dormAuthDb.put(auth.getAccommodationId(), auth);
+ return 1;
+ }
+ return null;
+ }
+ );
+ }
+
+ private IExternalResourceMappingService createMockMappingService() {
+ return (IExternalResourceMappingService) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{IExternalResourceMappingService.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("getActiveRoomMapping".equals(name)) {
+ String dormId = (String) args[1];
+ return resourceMappingDb.get("ROOM:" + dormId);
+ }
+ if ("getActiveLockMappings".equals(name)) {
+ String dormId = (String) args[1];
+ List locks = new ArrayList<>();
+ for (Map.Entry entry : resourceMappingDb.entrySet()) {
+ if (entry.getKey().startsWith("LOCK:" + dormId)) {
+ locks.add(entry.getValue());
+ }
+ }
+ return locks;
+ }
+ return null;
+ }
+ );
+ }
+
+ private IExternalOperationService createMockOperationService() {
+ return (IExternalOperationService) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{IExternalOperationService.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("createOperation".equals(name)) {
+ TrainExternalOperation op = (TrainExternalOperation) args[0];
+ if (op.getIdempotencyKey() != null && operationIdempotencyDb.containsKey(op.getIdempotencyKey())) {
+ return operationIdempotencyDb.get(op.getIdempotencyKey());
+ }
+ op.setOperationId(opSeq.incrementAndGet());
+ operationDb.put(op.getOperationId(), op);
+ if (op.getIdempotencyKey() != null) {
+ operationIdempotencyDb.put(op.getIdempotencyKey(), op);
+ }
+ return op;
+ }
+ if ("bindExternalReference".equals(name)) {
+ Long opId = (Long) args[0];
+ TrainExternalOperation op = operationDb.get(opId);
+ if (op != null) {
+ op.setExternalOperationId((String) args[1]);
+ op.setExternalHardwareId((String) args[2]);
+ op.setExternalKeyId((String) args[3]);
+ }
+ return op;
+ }
+ if ("transitStatus".equals(name)) {
+ Long opId = (Long) args[0];
+ TrainExternalOperation op = operationDb.get(opId);
+ if (op != null) {
+ op.setOperationStatus((String) args[1]);
+ op.setLastErrorCode((String) args[2]);
+ op.setLastErrorMessage((String) args[3]);
+ }
+ return op;
+ }
+ if ("listPendingPollOperations".equals(name)) {
+ List list = new ArrayList<>();
+ for (TrainExternalOperation op : operationDb.values()) {
+ if (TrainExternalOperation.STATUS_PENDING.equals(op.getOperationStatus()) ||
+ TrainExternalOperation.STATUS_ACCEPTED.equals(op.getOperationStatus()) ||
+ TrainExternalOperation.STATUS_RETRY_WAIT.equals(op.getOperationStatus())) {
+ list.add(op);
+ }
+ }
+ return list;
+ }
+ if ("updateOperationPollResult".equals(name)) {
+ Long opId = (Long) args[0];
+ TrainExternalOperation op = operationDb.get(opId);
+ if (op != null) {
+ op.setOperationStatus((String) args[1]);
+ if (args[4] != null) op.setLastErrorCode((String) args[4]);
+ if (args[5] != null) op.setLastErrorMessage((String) args[5]);
+ }
+ return op;
+ }
+ return null;
+ }
+ );
+ }
+
+ private void setPrivateField(Object target, String fieldName, Object value) {
+ try {
+ Field field = target.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(target, value);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java
new file mode 100644
index 000000000..f9c60763d
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/integration/dms/orchestration/BusinessTransactionIndependenceTest.java
@@ -0,0 +1,277 @@
+package com.ruoyi.train.integration.dms.orchestration;
+
+import com.ruoyi.train.domain.TrainAccommodation;
+import com.ruoyi.train.domain.TrainClassPlan;
+import com.ruoyi.train.domain.TrainClassReport;
+import com.ruoyi.train.domain.TrainDormAuth;
+import com.ruoyi.train.domain.TrainEnrollment;
+import com.ruoyi.train.domain.TrainExternalOperation;
+import com.ruoyi.train.domain.TrainStudent;
+import com.ruoyi.train.domain.dto.ReportConfirmDTO;
+import com.ruoyi.train.domain.vo.ReportResultVO;
+import com.ruoyi.train.integration.dms.security.SecurePasswordGenerator;
+import com.ruoyi.train.mapper.TrainAccommodationMapper;
+import com.ruoyi.train.mapper.TrainClassPlanMapper;
+import com.ruoyi.train.mapper.TrainClassReportMapper;
+import com.ruoyi.train.mapper.TrainEnrollmentMapper;
+import com.ruoyi.train.mapper.TrainStudentMapper;
+import com.ruoyi.train.service.ITrainEnrollmentService;
+import com.ruoyi.train.service.impl.EnrollmentAppServiceImpl;
+import com.ruoyi.train.service.impl.EnrollmentPlanCompletionAdapter;
+import com.ruoyi.train.service.impl.TrainAccommodationServiceImpl;
+import org.junit.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Proxy;
+import java.util.Date;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.*;
+
+/**
+ * 业务事务独立性与敏感数据安全测试 (R.3B Wave 3A Gate 49, 50, 51, 52)
+ */
+public class BusinessTransactionIndependenceTest {
+
+ /**
+ * Gate 19: 验证安全密码生成器:强制 6 位纯数字、非空、且连续生成具有强随机性 (非固定弱密码)
+ */
+ @Test
+ public void testSecurePasswordGenerator() {
+ String p1 = SecurePasswordGenerator.generateDefaultPassword();
+ String p2 = SecurePasswordGenerator.generateDefaultPassword();
+
+ assertNotNull(p1);
+ assertNotNull(p2);
+ assertEquals("Password must be exactly 6 digits", 6, p1.length());
+ assertEquals("Password must be exactly 6 digits", 6, p2.length());
+ assertTrue("Password must consist only of digits", p1.matches("\\d{6}"));
+ assertTrue("Password must consist only of digits", p2.matches("\\d{6}"));
+
+ assertNotEquals("Fixed passwords are strictly forbidden", "123456", p1);
+ assertNotEquals("Fixed passwords are strictly forbidden", "000000", p1);
+
+ // 生成 20 个密码,验证随机不重复分布
+ java.util.Set set = new java.util.HashSet<>();
+ for (int i = 0; i < 20; i++) {
+ set.add(SecurePasswordGenerator.generateDefaultPassword());
+ }
+ assertTrue("20 generated passwords should contain high entropy diversity", set.size() >= 18);
+ }
+
+ /**
+ * Gate 49: 敏感数据自动扫描:明文在线密码严禁出现在 Entity persistence, logs, exception message, toString
+ */
+ @Test
+ public void testSensitiveDataScans_ZeroPlaintextPasswordPersistence() throws Exception {
+ String generatedPassword = SecurePasswordGenerator.generateDefaultPassword();
+
+ // 1. 实体字段扫描:TrainDormAuth 与 TrainExternalOperation 绝不包含 password/passwd/secret 命名字段
+ for (Field f : TrainDormAuth.class.getDeclaredFields()) {
+ String name = f.getName().toLowerCase();
+ assertFalse("TrainDormAuth must not have plaintext password field: " + name,
+ name.contains("password") || name.contains("passwd"));
+ assertFalse("TrainDormAuth must not have secret field: " + name,
+ name.contains("secret"));
+ }
+
+ for (Field f : TrainExternalOperation.class.getDeclaredFields()) {
+ String name = f.getName().toLowerCase();
+ assertFalse("TrainExternalOperation must not have plaintext password field: " + name,
+ name.contains("password") || name.contains("passwd"));
+ assertFalse("TrainExternalOperation must not have secret field: " + name,
+ name.contains("secret"));
+ }
+
+ // 2. 实体 toString 扫描:绝不打印出生成的明文密码
+ TrainDormAuth auth = new TrainDormAuth();
+ auth.setAuthId(101L);
+ auth.setAccommodationId(202L);
+ auth.setRemark("Normal auth remark without secret");
+ assertFalse("TrainDormAuth toString must not contain generated password",
+ auth.toString().contains(generatedPassword));
+
+ TrainExternalOperation op = new TrainExternalOperation();
+ op.setOperationId(303L);
+ op.setExternalOperationId("EXT-303");
+ op.setRemark("External op remark without secret");
+ assertFalse("TrainExternalOperation toString must not contain generated password",
+ op.toString().contains(generatedPassword));
+
+ // 3. 对账结果 toString 扫描
+ ReconciliationResult result = ReconciliationResult.grantRequired(101L, 303L, true, true, "GRANT operation initiated");
+ assertFalse("ReconciliationResult toString must not contain password",
+ result.toString().contains(generatedPassword));
+ }
+
+ /**
+ * Gate 50: 报到业务事务独立性测试:Mock DMS 外部网络彻底失败时,报到确认仍成功完成,绝不回滚
+ * (REPORT_CONFIRM_EXTERNAL_FAILURE_ROLLBACK = NO)
+ */
+ @Test
+ public void testReportConfirmExternalFailureRollback_IsNo() throws Exception {
+ EnrollmentAppServiceImpl enrollmentAppService = new EnrollmentAppServiceImpl();
+
+ Long testEnrollmentId = 991L;
+ TrainEnrollment enrollment = new TrainEnrollment();
+ enrollment.setEnrollmentId(testEnrollmentId);
+ enrollment.setPlanId("PLAN-MOCK-01");
+ enrollment.setStudentId("STU-001");
+ enrollment.setEnrollmentStatus(0);
+
+ TrainEnrollmentMapper enrollmentMapper = (TrainEnrollmentMapper) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{TrainEnrollmentMapper.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("selectTrainEnrollmentById".equals(name)) return enrollment;
+ if ("confirmEnrollmentReport".equals(name)) {
+ enrollment.setEnrollmentStatus(1);
+ return 1;
+ }
+ return null;
+ }
+ );
+
+ TrainClassReportMapper reportMapper = (TrainClassReportMapper) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{TrainClassReportMapper.class},
+ (proxy, method, args) -> {
+ if ("selectTrainClassReportByEnrollmentId".equals(method.getName())) return null;
+ if ("insertTrainClassReport".equals(method.getName())) return 1;
+ return null;
+ }
+ );
+
+ TrainClassPlanMapper planMapper = (TrainClassPlanMapper) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{TrainClassPlanMapper.class},
+ (proxy, method, args) -> new TrainClassPlan()
+ );
+
+ TrainStudentMapper studentMapper = (TrainStudentMapper) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{TrainStudentMapper.class},
+ (proxy, method, args) -> {
+ TrainStudent s = new TrainStudent();
+ s.setsName("张三");
+ return s;
+ }
+ );
+
+ // 模拟 AccessAuthorizationOrchestrator 抛出 DMS 外部网络彻底崩溃异常
+ AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() {
+ @Override
+ public ReconciliationResult reconcile(Long enrollmentId) {
+ throw new RuntimeException("DMS Network Connection Refused / Timeout [Gate 50]");
+ }
+ };
+
+ setField(enrollmentAppService, "trainEnrollmentMapper", enrollmentMapper);
+ setField(enrollmentAppService, "trainClassReportMapper", reportMapper);
+ setField(enrollmentAppService, "planMapper", planMapper);
+ setField(enrollmentAppService, "studentMapper", studentMapper);
+ setField(enrollmentAppService, "accessAuthorizationOrchestrator", failingOrchestrator);
+
+ // 执行学员报到确认
+ ReportConfirmDTO dto = new ReportConfirmDTO();
+ dto.setReportWay(4);
+ ReportResultVO vo = enrollmentAppService.confirmReport(testEnrollmentId, dto, "admin");
+
+ // 验证报到业务主流程必须绝对成功!
+ assertNotNull("Report confirmation must succeed despite DMS failure", vo);
+ assertEquals(testEnrollmentId, vo.getEnrollmentId());
+ assertEquals("Enrollment status must be confirmed to 1", Integer.valueOf(1), enrollment.getEnrollmentStatus());
+ }
+
+ /**
+ * Gate 51: 退宿业务事务独立性测试:Mock 撤销权限外部网络失败,住宿退宿仍成功,权限保持待撤销状态
+ */
+ @Test
+ public void testAccommodationCheckout_ExternalRevokeFailureDoesNotRollback() throws Exception {
+ TrainAccommodationServiceImpl accommodationService = new TrainAccommodationServiceImpl();
+
+ Long accId = 881L;
+ TrainAccommodation acc = new TrainAccommodation();
+ acc.setAccommodationId(accId);
+ acc.setEnrollmentId(771L);
+ acc.setAccommodationStatus(1);
+
+ AtomicInteger statusHolder = new AtomicInteger(1);
+
+ TrainAccommodationMapper accMapper = (TrainAccommodationMapper) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{TrainAccommodationMapper.class},
+ (proxy, method, args) -> {
+ String name = method.getName();
+ if ("selectTrainAccommodationByAccommodationId".equals(name)) return acc;
+ if ("updateAccommodationStatus".equals(name)) {
+ statusHolder.set((Integer) args[1]);
+ return 1;
+ }
+ return null;
+ }
+ );
+
+ // 模拟 Orchestrator 在退宿触发对账时发生 DMS 远程超时
+ AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() {
+ @Override
+ public ReconciliationResult reconcile(Long enrollmentId) {
+ throw new RuntimeException("Mock DMS Revoke Network Timeout [Gate 51]");
+ }
+ };
+
+ setField(accommodationService, "trainAccommodationMapper", accMapper);
+ setField(accommodationService, "accessAuthorizationOrchestrator", failingOrchestrator);
+
+ int rows = accommodationService.checkOut(accId, "admin");
+
+ assertEquals("Accommodation checkout must return 1 updated row", 1, rows);
+ assertEquals("Accommodation status must become CHECKED_OUT (2)",
+ TrainAccommodation.STATUS_CHECKED_OUT, Integer.valueOf(statusHolder.get()));
+ }
+
+ /**
+ * Gate 52: 结训业务事务独立性测试:结训联动学员状态归档时,即使外部撤权调用失败,结训主事务仍成功
+ */
+ @Test
+ public void testPlanComplete_ExternalRevokeFailureDoesNotRollback() throws Exception {
+ EnrollmentPlanCompletionAdapter adapter = new EnrollmentPlanCompletionAdapter();
+
+ AtomicBoolean serviceCompleted = new AtomicBoolean(false);
+
+ ITrainEnrollmentService enrollmentService = (ITrainEnrollmentService) Proxy.newProxyInstance(
+ getClass().getClassLoader(),
+ new Class>[]{ITrainEnrollmentService.class},
+ (proxy, method, args) -> {
+ if ("completeActiveEnrollmentsByPlanId".equals(method.getName())) {
+ serviceCompleted.set(true);
+ return 3;
+ }
+ return null;
+ }
+ );
+
+ AccessAuthorizationOrchestrator failingOrchestrator = new AccessAuthorizationOrchestrator() {
+ @Override
+ public ReconciliationResult reconcile(Long enrollmentId) {
+ throw new RuntimeException("Mock DMS Remote Gateway Down [Gate 52]");
+ }
+ };
+
+ setField(adapter, "trainEnrollmentService", enrollmentService);
+ setField(adapter, "accessAuthorizationOrchestrator", failingOrchestrator);
+
+ // 触发计划结训
+ adapter.completeEnrollments("PLAN-COMP-99", "admin", new Date());
+
+ assertTrue("Plan enrollment completion must succeed despite external DMS revoke failures", serviceCompleted.get());
+ }
+
+ private void setField(Object target, String fieldName, Object value) throws Exception {
+ Field f = target.getClass().getDeclaredField(fieldName);
+ f.setAccessible(true);
+ f.set(target, value);
+ }
+}
diff --git a/RuoYi-Vue/sql/system/kingbase/009_dorm_authorization_orchestration.sql b/RuoYi-Vue/sql/system/kingbase/009_dorm_authorization_orchestration.sql
new file mode 100644
index 000000000..f529dbc12
--- /dev/null
+++ b/RuoYi-Vue/sql/system/kingbase/009_dorm_authorization_orchestration.sql
@@ -0,0 +1,31 @@
+-- ==============================================================================
+-- 脚本名称: 009_dorm_authorization_orchestration.sql
+-- 业务模块: DMS/外部物联网集成 - 宿舍门禁业务授权编排扩展模型 (R.3B Wave 3A)
+-- 适用数据库: KingbaseES V8 / V9 (PostgreSQL 方言兼容)
+-- 安全编号: 009 (001-003 基线, 004-005 R.2分支占用, 006 Wave0, 007 保留禁止执行, 008 Wave1)
+-- 幂等性设计: 支持重复安全执行,纯增量列结构与索引,不含破坏性语句
+-- 安全红线: 严禁引入明文密码列,密码仅在内存短暂留存,严禁落库
+-- ==============================================================================
+
+-- 1. 扩展 train_dorm_auth 业务授权字段(增量列添加,防冲突幂等设计)
+
+-- 1.1 凭据类型列 (默认 ONLINE_PASSWORD,未来可扩展 QR, CARD, MOBILE_KEY)
+ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS credential_type VARCHAR(32) DEFAULT 'ONLINE_PASSWORD' NOT NULL;
+COMMENT ON COLUMN train_dorm_auth.credential_type IS '凭据类型 (ONLINE_PASSWORD 在线密码, QR 二维码, CARD 卡片, MOBILE_KEY 手机钥匙)';
+
+-- 1.2 厂商中立业务授权状态列 (从历史 face_sync_status 进化为语义严谨的独立业务状态)
+-- 状态枚举:PENDING_GRANT(待下发), GRANTING(下发中), ACTIVE(已生效), PENDING_REVOKE(待注销), REVOKING(注销中), REVOKED(已注销), ERROR(下发或注销失败)
+ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS auth_status VARCHAR(32) DEFAULT 'PENDING_GRANT' NOT NULL;
+COMMENT ON COLUMN train_dorm_auth.auth_status IS '业务授权状态 (PENDING_GRANT, GRANTING, ACTIVE, PENDING_REVOKE, REVOKING, REVOKED, ERROR)';
+
+-- 1.3 关联外部操作流水ID (用于关联并追踪当前最新驱动的 ExternalOperation)
+ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS latest_operation_id BIGINT;
+COMMENT ON COLUMN train_dorm_auth.latest_operation_id IS '最近一次绑定的外部操作流水ID (关联 train_external_operation.operation_id)';
+
+-- 1.4 授权操作世代/版本号 (用于防旧操作晚到结果覆盖更新状态,保证操作时序单调性)
+ALTER TABLE train_dorm_auth ADD COLUMN IF NOT EXISTS operation_version INTEGER DEFAULT 1 NOT NULL;
+COMMENT ON COLUMN train_dorm_auth.operation_version IS '授权操作版本号 (乐观并发与迟到操作防覆写防护)';
+
+-- 2. 索引优化 (幂等添加)
+CREATE INDEX IF NOT EXISTS idx_dorm_auth_status ON train_dorm_auth (auth_status);
+CREATE INDEX IF NOT EXISTS idx_dorm_auth_latest_op ON train_dorm_auth (latest_operation_id);
diff --git a/docs/development/dms-r3/06_authorization_state_machine.md b/docs/development/dms-r3/06_authorization_state_machine.md
index 392724fba..8a8dfe893 100644
--- a/docs/development/dms-r3/06_authorization_state_machine.md
+++ b/docs/development/dms-r3/06_authorization_state_machine.md
@@ -159,3 +159,20 @@ stateDiagram-v2
- **迁移标记**:
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
+
+---
+
+## 7. Wave 3A Implementation Result (Wave 3A implemented against Mock DMS only)
+- **阶段状态**:Wave 3A Access Authorization Orchestration Dry-Run implemented against Mock DMS only。
+- **厂商中立业务授权状态模型 (`AUTHORIZATION_STATUS_MODEL`)**:
+ - 完整定义细粒度业务授权状态枚举:`PENDING_GRANT`(待下发) -> `GRANTING`(下发中) -> `ACTIVE`(已生效) -> `PENDING_REVOKE`(待注销) -> `REVOKING`(注销中) -> `REVOKED`(已注销),异常收敛为 `ERROR`。
+ - 业务授权生命周期与单次外部操作流水(`ExternalOperation.operation_status`)严格解耦:
+ - `ExternalOperation` 达成 `ACCEPTED` 时,`DoorAuthorization` 保持 `GRANTING` / `REVOKING`,**严禁直接标记为 ACTIVE / REVOKED**。
+ - `ExternalOperation` 经由异步轮询器对账达成 `SUCCESS` 时,方才正式推进 `DoorAuthorization` 为 `ACTIVE` 或 `REVOKED`。
+ - `ExternalOperation` 失败或超时(`FAILED` / `TIMEOUT`)时,`DoorAuthorization` 置为 `ERROR` 并保留原业务上下文以备后续幂等重试。
+- **防迟到覆盖保护 (`STALE_OPERATION_RESULT_GUARD`)**:
+ - 授权主表建立 `latest_operation_id` 与 `operation_version` 防护机制。
+ - 当轮询器处理旧操作流水结果时,必须强校验当前授权记录的 `latest_operation_id` 是否依然与该流水匹配。
+ - 若已发起后续新操作,迟到的旧操作结果(如旧 GRANT 迟到的 FAILED 或 SUCCESS)被静默忽略,绝不覆盖新授权状态。
+- **向后兼容与双向状态映射**:
+ - `TrainDormAuth` 内置 `authStatus` 与旧版 `faceSyncStatus` 双向同步转换,在 009 Migration 未在生产执行前无缝兼容现有 MyBatis 映射。
diff --git a/docs/development/dms-r3/07_retry_idempotency_and_compensation.md b/docs/development/dms-r3/07_retry_idempotency_and_compensation.md
index 7925986d9..8d93e707d 100644
--- a/docs/development/dms-r3/07_retry_idempotency_and_compensation.md
+++ b/docs/development/dms-r3/07_retry_idempotency_and_compensation.md
@@ -176,3 +176,30 @@ sequenceDiagram
- **迁移标记**:
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
+
+---
+
+## 8. Wave 3A Implementation Result (Wave 3A implemented against Mock DMS only)
+- **阶段状态**:Wave 3A Access Authorization Orchestration Dry-Run implemented against Mock DMS only。
+- **状态驱动对账模型 (`RECONCILIATION_MODEL`)**:
+ - 核心编排组件:`AccessAuthorizationOrchestrator.reconcile(enrollmentId)`。
+ - 统一检查业务事实并决策输出:`NO_ACTION`、`WAITING_FOR_PREREQUISITE`、`GRANT_REQUIRED`、`REVOKE_REQUIRED`。
+ - 彻底解耦业务事件发生时序:
+ - Scenario A(先分配住宿,后报到确认):报到确认后触发对账 -> GRANT -> 轮询成功 -> ACTIVE。
+ - Scenario B(先报到确认,后分配住宿):初次对账 WAITING -> 分配住宿后触发对账 -> GRANT -> 轮询成功 -> ACTIVE。
+- **业务主事务完全解耦与独立性 (`LONG_DB_TRANSACTION_AROUND_HTTP = NO`)**:
+ - 严禁长事务包住远程 HTTP 调用:本地写授权记录与写操作流水为短事务,随后进行外部 Gateway 调用,后续再短事务更新受理结果。
+ - `REPORT_CONFIRM_EXTERNAL_FAILURE_ROLLBACK = NO`:DMS 网络超时或故障时,报到确认(`status 0 -> 1`)、退宿(`status 1 -> 2`)、结训归档(`status 1 -> 4`)等本地主业务事务绝对不会回滚,外部门禁自动转入待重试/挂起态。
+- **对账幂等与防重机制 (`RECONCILE_IDEMPOTENT`)**:
+ - 连续多次触发对账(如学员重复点击或短时多事件触发),业务事实未变化时返回 `NO_ACTION`,绝不重复创建业务授权、绝不重复调用 DMS 发放密码凭据、绝不创建重复操作流水。
+- **最少特权门锁选择策略 (`LOCK_SELECTION_POLICY = PRIMARY_LOCK_ONLY`)**:
+ - 严格限制自动发码范围为房间的唯一定义主门锁:
+ - 0 主锁:阻断发码,返回 `WAITING_FOR_PREREQUISITE`。
+ - 1 主锁:正常放行下发凭据。
+ - >1 主锁:配置歧义错误阻断,保护硬件资源。
+ - 次锁:不额外自动发码,保留未来扩展能力。
+- **调房安全策略 (`TRANSFER_POLICY = GRANT_NEW_THEN_REVOKE_OLD`)**:
+ - 先为新宿舍发起凭据下发并等待轮询确认生效(ACTIVE)。
+ - 只有在新宿舍确认生效后,才对旧宿舍发起撤销调用。
+ - `TRANSFER_FAILURE_PRESERVES_OLD_ACCESS = YES`:若新宿舍凭据下发失败,旧宿舍授权绝对保持 ACTIVE,杜绝学员两头落空进不了门。
+ - 审计确认存在短暂合理的 `dual-access window`,优先保障可用性。
diff --git a/docs/development/dms-r3/10_api_and_ui_proposal.md b/docs/development/dms-r3/10_api_and_ui_proposal.md
index 9b7697c4e..e7e9b3d46 100644
--- a/docs/development/dms-r3/10_api_and_ui_proposal.md
+++ b/docs/development/dms-r3/10_api_and_ui_proposal.md
@@ -127,3 +127,12 @@
- **迁移标记**:
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
+
+---
+
+## 7. Wave 3A Implementation Result (Wave 3A implemented against Mock DMS only)
+- **阶段状态**:Wave 3A Access Authorization Orchestration Dry-Run implemented against Mock DMS only。
+- **UI 与外部 API 边界纪律**:
+ - `FRONTEND_MODIFIED = 0`:前端与 `build_ui` 源码变更文件数为 0,未向页面引入任何未经过评审的前端组件或菜单配置。
+ - `CONTROLLER_COUNT = 0`:本阶段为纯业务编排 Dry-run 基础层,未暴露任何对外的正式或调试 Controller 端点。
+ - 测试与验证完全通过 Service 层集成测试完成。管理端对账与手动干预入口(如 Manual Reconcile API)将在 Wave 3B 统一部署。
diff --git a/docs/development/dms-r3/11_testing_and_mock_plan.md b/docs/development/dms-r3/11_testing_and_mock_plan.md
index 48b2309d8..f5164012f 100644
--- a/docs/development/dms-r3/11_testing_and_mock_plan.md
+++ b/docs/development/dms-r3/11_testing_and_mock_plan.md
@@ -184,3 +184,41 @@ public void testConcurrentLoginDeduplication() throws Exception {
- `LIVE_DMS_REQUEST_COUNT = 0`
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
+
+---
+
+## 6. Wave 3A Implementation Result (Wave 3A implemented against Mock DMS only)
+- **阶段状态**:Wave 3A Access Authorization Orchestration Dry-Run implemented against Mock DMS only。
+- **新增测试用例清单 (共计新增 18 项,全量总计 251 项)**:
+ 1. `DormAuthorizationOrchestrationMigrationContractTest.java` (4 项):
+ - `testMigrationFileExistsAndNamedCorrectly` (009 脚本命名与唯一编号校验)。
+ - `testIdempotency_AlterTableAddColumnIfNotExists` (ALTER TABLE ADD COLUMN IF NOT EXISTS 幂等静态断言)。
+ - `testSafety_NoPlaintextSecretColumns` (静态审计脚本绝无 password/secret 明文存储列)。
+ - `testSafety_NoDestructiveDdlOrDml` (静态审计脚本绝无 DROP/TRUNCATE 等破坏性语句)。
+ 2. `AccessAuthorizationOrchestrationTest.java` (9 项):
+ - `testScenarioA_AccommodationFirstThenReportConfirm` (先分配宿舍,后报到确认:报到确认前 WAITING,报到确认后 GRANT,Poller 确认后推进 ACTIVE)。
+ - `testScenarioB_ReportConfirmFirstThenAccommodation` (先报到确认,后分配宿舍:初次对账 WAITING,分房后 GRANT,Poller 确认后推进 ACTIVE)。
+ - `testReconciliationIdempotency_NoDuplicateOperations` (ACTIVE 状态下重复触发 5 次对账,无新增授权、无重复发码)。
+ - `testGrantFailureScenario_RetryGeneratesNewOperation` (设备执行 FAILED,状态置为 ERROR,重试对账安全创建全新操作流水,旧 FAILED 留存)。
+ - `testRevokeLifecycle_OnEnrollmentTermination` (退训/结训触发主动撤权,下发成功先为 REVOKING,Poller 确认后成为 REVOKED)。
+ - `testTransfer_GrantNewThenRevokeOld` (调房安全时序:新房 Grant 成功且 ACTIVE 后,才发起旧房 Revoke)。
+ - `testTransferFailure_PreservesOldRoomAccess` (新房 Grant 失败时,旧房授权严格保持 ACTIVE,杜绝学员无门可进)。
+ - `testLockSelectionPolicy_PrimaryLockOnly` (Least Privilege:0 主锁阻断、1 主锁放行、2 主锁配置错误阻断、次锁不自动发码)。
+ - `testStaleOperationProtection_NeverOverwritesNewState` (防迟到覆盖保护:旧操作迟到结果绝不覆盖已推进的新授权状态)。
+ 3. `BusinessTransactionIndependenceTest.java` (5 项):
+ - `testSecurePasswordGenerator` (验证 SecureRandom 强随机、6 位数字生成,杜绝弱密码)。
+ - `testSensitiveDataScans_ZeroPlaintextPasswordPersistence` (实体字段、toString、Operation 流水敏感数据全要素防泄露扫描)。
+ - `testReportConfirmExternalFailureRollback_IsNo` (报到业务事务独立性:DMS 崩溃超时时报到确认主流程成功,`REPORT_CONFIRM_EXTERNAL_FAILURE_ROLLBACK = NO`)。
+ - `testAccommodationCheckout_ExternalRevokeFailureDoesNotRollback` (退宿业务事务独立性:DMS 撤权失败不影响退宿完成)。
+ - `testPlanComplete_ExternalRevokeFailureDoesNotRollback` (结训业务事务独立性:结训归档在 DMS 失败下仍成功提交)。
+- **全量测试指标**:
+ - 旧测试基线:233 项。
+ - 新增测试数:18 项。
+ - 当前测试总数:251 项(ruoyi-train: 219 项,ruoyi-admin: 32 项),Failures = 0, Errors = 0, Skipped = 0。
+ - Maven `clean test` 与 `clean package` 全部成功。
+- **环境与安全纪律**:
+ - `LIVE_DMS_REQUEST_COUNT = 0`(全量测试连接 MockDmsServer,真实 DMS 调用严格为 0)。
+ - `FRONTEND_MODIFIED = 0`,`CONTROLLER_COUNT = 0`。
+ - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
+ - `009_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`009_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
+ - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
diff --git a/docs/development/dms-r3/12_implementation_task_breakdown.md b/docs/development/dms-r3/12_implementation_task_breakdown.md
index 419f5f627..17899b382 100644
--- a/docs/development/dms-r3/12_implementation_task_breakdown.md
+++ b/docs/development/dms-r3/12_implementation_task_breakdown.md
@@ -215,3 +215,30 @@ flowchart TD
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`
- `008_RUNTIME_SECOND_RUN = NOT_EXECUTED`
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
+
+### 3.5 Wave 3A Implementation Result (业务状态/门禁期望状态/外部操作统一编排 Dry-Run - Wave 3A implemented against Mock DMS only)
+- **阶段目标**:Access Authorization Orchestration Dry-Run
+- **执行状态**:Wave 3A implemented against Mock DMS only
+- **核心交付清单**:
+ 1. 架构扩展迁移脚本:`RuoYi-Vue/sql/system/kingbase/009_dorm_authorization_orchestration.sql`(安全编号 009,幂等增量添加凭据类型、业务授权状态、关联操作ID与版本号列,零明文列)。
+ 2. 实体模型扩展:`TrainDormAuth.java`(新增业务状态与凭据常量、双向状态映射、版本控制属性)。
+ 3. 服务层编排扩展:`ITrainDormAuthService/Impl`(实现 `ensureAuthorizationForAccommodation`、`updateAuthStatusByPoller` 并植入迟到保护)。
+ 4. 安全凭据生成器:`SecurePasswordGenerator.java`(`SecureRandom` 强随机、6 位纯数字、内存局部留存、杜绝弱密码)。
+ 5. 统一编排器:`AccessAuthorizationOrchestrator.java`(状态驱动对账 `reconcile`,解耦业务事件时序,短事务解耦外部 HTTP,最少特权单主锁策略,调房先新后旧)。
+ 6. 异步轮询器联动推进:`DmsOperationPoller.java`(集成授权状态联动,根据最新操作归属安全更新业务授权状态)。
+ 7. 业务事件安全挂接:`TrainAccommodationServiceImpl.java`(分房/退宿/调房)、`EnrollmentAppServiceImpl.java`(报到确认)、`EnrollmentPlanCompletionAdapter.java`(计划结训),异常不回滚本地业务主事务。
+ 8. 迁移契约测试:`DormAuthorizationOrchestrationMigrationContractTest.java` (4 项)。
+ 9. 端到端编排测试:`AccessAuthorizationOrchestrationTest.java` (9 项)。
+ 10. 业务事务独立性与安全扫描:`BusinessTransactionIndependenceTest.java` (5 项)。
+- **全量构建与回归验收**:
+ - `ruoyi-train`: 219 tests
+ - `ruoyi-admin`: 32 tests
+ - 总计:251 tests, Failures = 0, Errors = 0, Skipped = 0,全部通过。
+ - Maven `clean test` 与 `clean package` 构建成功。
+ - 静态检查:`git diff --check` PASS。
+- **环境纪律与准入门禁**:
+ - `LIVE_DMS_REQUEST_COUNT = 0`。
+ - `FRONTEND_MODIFIED = 0`,`CONTROLLER_COUNT = 0`。
+ - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
+ - `009_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`009_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
+ - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
diff --git a/docs/development/dms-r3/13_open_questions.md b/docs/development/dms-r3/13_open_questions.md
index f347b17a8..b643b02e9 100644
--- a/docs/development/dms-r3/13_open_questions.md
+++ b/docs/development/dms-r3/13_open_questions.md
@@ -55,3 +55,35 @@
2. **真实环境 Sandbox 账号**:本 Wave 严格使用 `MockDmsServer` 动态模拟,不依赖真实 DMS 沙箱。
3. **真实错误码全集**:通过 `DmsOperationPoller.ERROR_CATEGORY_*` 抽象通用错误分类(AUTH_ERROR, NETWORK_ERROR, VENDOR_BUSINESS_ERROR, DEVICE_FAILED, DEVICE_TIMEOUT),兼容未知供应商错误。
4. **Token Cookie 头名称**:在 `DmsProperties.tokenCookieName` 中参数化配置(默认 "token"),杜绝写死猜测。
+
+---
+
+## 5. Wave 3A 编排层冻结之核心待决问题清单 (Wave 3A Open Questions Frozen)
+在 Wave 3A 业务授权编排层落地过程中,以下 6 项核心业务与供应商技术问题被明确固化为待决事项,严禁以实现猜测替代业务确认:
+
+### A. 学员请假是否自动撤销门禁 (`LEAVE_AUTO_REVOKE_POLICY = OPEN_QUESTION`)
+- **现状**:学员请假(`status = 2`)时,当前编排器严格判定为 `NO_ACTION`,保持门禁现状不撤权。
+- **业务争议**:请假期间学员个人物品仍存放于宿舍内,若自动撤销门禁,请假归来或中途取物无法进门;若不撤权,存在请假外出期间他人冒用或公寓安防真空。
+- **待确认**:需公寓管理部门与培训业务处明确出台请假门禁管理细则。
+
+### B. 在线密码交付与重置恢复策略 (`ONLINE_PASSWORD_DELIVERY_AND_RECOVERY_POLICY = OPEN_QUESTION` / `CREDENTIAL_DELIVERY_POLICY = OPEN_QUESTION`)
+- **现状**:出于安全红线,系统对生成的 6 位纯数字密码明文执行零落库、零日志、零异常信息泄漏。
+- **业务争议**:若系统数据库不持久化明文或可逆密文,当学员忘记密码、网络短信发送失败或管理员需要现场协助开门时,“管理员在哪里看到密码?”、“学员如何再次获取密码?”。
+- **待确认**:在正式 Sandbox 联调前,需业务部门评审确认是采用“非对称加密临时暂存/查看二次鉴权”,还是“支持随时一键作废重置并重新下发新密码”。
+
+### C. 最终门锁选择策略是否仅限单主锁 (`LOCK_SELECTION_POLICY = PRIMARY_LOCK_ONLY`)
+- **现状**:Wave 3A 依据 Least Privilege 原则,严格落地 `PRIMARY_LOCK_ONLY`(0主锁阻断/1主锁放行/多主锁配置错误阻断/次锁不自动发码)。
+- **业务争议**:部分宿舍可能存在套间结构(外防盗门 + 内卧室门),是否最终所有套间锁均需要自动同步下发密码(即未来 `ALL_REQUIRED_LOCKS`)。
+- **待确认**:待实际宿舍楼栋智能门锁安装布设拓扑确定后终审。
+
+### D. 真实 DMS Cookie 名称 (`TOKEN_COOKIE_NAME = VENDOR_CONFIRMATION_REQUIRED`)
+- **现状**:供应商文档未正式声明传输 Cookie 的 Key 名称,代码中参数化配置为 `token`。
+- **待确认**:需供应商技术人员书面核实 HTTP 请求头中的 Cookie 名称(如 `token`、`JSESSIONID`、`DMS_TOKEN` 等)。
+
+### E. 设备离线 validUntil 与硬件 RTC 时钟漂移语义 (`DMS_LOCK_OFFLINE_EXPIRY_SEMANTICS = NEEDS_VENDOR_CONFIRMATION`)
+- **现状**:系统在培训结训时统一执行主动撤权为主、有效期边界为辅策略。
+- **待确认**:门锁处于断网、断电或电池耗尽更换电池导致 RTC 时钟重置时,离线本地密码过期判断的物理保证级别。
+
+### F. 供应商沙箱联调账号与项目信息 (`DMS_SANDBOX_ENVIRONMENT = VENDOR_CONFIRMATION_REQUIRED`)
+- **现状**:Wave 3A 严格在全内存 MockDmsServer 下执行 Dry-Run。
+- **待确认**:供应商提供正式可连通公网测试沙箱环境的 `app_id`、`app_secret`、`project_id` 及测试门锁硬件。
diff --git a/docs/development/dms-r3/14_decision_summary.md b/docs/development/dms-r3/14_decision_summary.md
index 8a514486e..aba751930 100644
--- a/docs/development/dms-r3/14_decision_summary.md
+++ b/docs/development/dms-r3/14_decision_summary.md
@@ -194,3 +194,55 @@
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
- `COMMIT? NO`,`PUSH? NO`,`DEPLOY? NO`(严格 HOLD 等待审查)。
+
+### 6.5 Wave 3A Access Authorization Orchestration Dry-Run (Wave 3A implemented against Mock DMS only)
+- **阶段状态**:`R3_WAVE3A_STATUS = PASS`。
+- **架构与模型扩展**:
+ - `AUTH_SCHEMA_SUFFICIENT = NO`(基线 `train_dorm_auth` 缺失凭据类型、细粒度状态、关联操作流水ID及版本号)。
+ - `009_MIGRATION_REQUIRED = YES`。
+ - `009_MIGRATION_FILE` = `RuoYi-Vue/sql/system/kingbase/009_dorm_authorization_orchestration.sql`(安全编号 009,幂等增量,纯非破坏性,零明文密钥)。
+ - `AUTHORIZATION_STATUS_MODEL` = `PENDING_GRANT / GRANTING / ACTIVE / PENDING_REVOKE / REVOKING / REVOKED / ERROR`(厂商中立领域授权生命周期)。
+ - `CREDENTIAL_TYPE_MODEL` = `ONLINE_PASSWORD`(未来可扩展 QR, CARD, MOBILE_KEY)。
+ - `VALIDITY_WINDOW_SOURCE` = `BUSINESS_POLICY_REQUIRED`(一期从培训计划与住宿业务时间推导,未决策略显式记录)。
+- **统一编排与驱动机制**:
+ - `RECONCILIATION_MODEL` = `AccessAuthorizationOrchestrator.reconcile(enrollmentId)`(状态驱动对账,解耦业务事件发生顺序)。
+ - `GRANT_PRECONDITIONS` = `Enrollment status=1 (已报到) + Active Accommodation (status 0/1) + Active Room Mapping + Exactly 1 Primary DoorLock Mapping + 有效期可推导`。
+ - `REPORT_CONFIRM_TRIGGER` = `reconcile(enrollmentId)`(报到核验通过后驱动,异步对账)。
+ - `ACCOMMODATION_TRIGGER` = `reconcile(enrollmentId)`(分配或激活住宿后驱动)。
+ - `REPORT_CONFIRM_EXTERNAL_FAILURE_ROLLBACK` = `NO`(外部网络/DMS 失败绝不回滚报到主事务)。
+ - `LOCK_SELECTION_POLICY` = `PRIMARY_LOCK_ONLY`(Least Privilege 原则,严格锁定唯一定义的主门锁发码,0主锁与多主锁阻断)。
+ - `RECONCILE_IDEMPOTENT` = `YES`(多事件/重复报到触发多次 reconcile 结果幂等,无重复授权、无重复发码)。
+- **状态流转与时序保护**:
+ - `GRANT_HTTP_SUCCESS_MARKS_ACTIVE` = `NO`(HTTP 受理仅标记为 GRANTING/ACCEPTED,Poller 确认后方推进 ACTIVE)。
+ - `GRANT_SUCCESS_RESULT` = `ExternalOperation=SUCCESS, DoorAuthorization=ACTIVE`。
+ - `GRANT_FAILED_RESULT` = `ExternalOperation=FAILED, DoorAuthorization=ERROR (保留上下文待重试)`。
+ - `GRANT_TIMEOUT_RESULT` = `ExternalOperation=TIMEOUT, DoorAuthorization=ERROR (保留上下文待重试)`。
+ - `REVOKE_SUCCESS_RESULT` = `ExternalOperation=SUCCESS, DoorAuthorization=REVOKED`。
+ - `REVOKE_FAILED_RESULT` = `ExternalOperation=FAILED, DoorAuthorization=ERROR/REVOKING`。
+ - `STALE_OPERATION_RESULT_GUARD` = `LATEST_OPERATION_OWNERSHIP_AND_VERSION_CHECK`(防迟到旧操作结果覆盖已推进的新状态)。
+ - `LONG_DB_TRANSACTION_AROUND_HTTP` = `NO`(本地短事务写库 -> 外部调用 -> 本地短事务更新,绝不用长事务包裹网络 IO)。
+- **换房调宿与高可用**:
+ - `TRANSFER_POLICY` = `GRANT_NEW_THEN_REVOKE_OLD`(先下发新宿舍门禁,对账确认 ACTIVE 后才撤销旧宿舍)。
+ - `TRANSFER_FAILURE_PRESERVES_OLD_ACCESS` = `YES`(新宿舍发码失败严格保留旧宿舍门禁,保障学员可用性)。
+ - `TRANSFER_DUAL_ACCESS_WINDOW` = `ACKNOWLEDGED_FOR_AVAILABILITY`(可用性优先,审计留痕)。
+- **凭据安全与零泄露**:
+ - `PASSWORD_GENERATOR` = `SecurePasswordGenerator`(`SecureRandom` 强随机、6 位纯数字、杜绝弱密码)。
+ - `PLAINTEXT_PASSWORD_PERSISTENCE_COUNT` = `0`(数据库、流水表、日志、异常栈、toString 全链路零明文留存)。
+- **未决业务与环境门禁**:
+ - `CREDENTIAL_DELIVERY_POLICY` = `OPEN_QUESTION`。
+ - `LEAVE_AUTO_REVOKE_POLICY` = `OPEN_QUESTION`。
+ - `TOKEN_COOKIE_NAME` = `VENDOR_CONFIRMATION_REQUIRED`。
+ - `LIVE_DMS_REQUEST_COUNT` = `0`(100% Mock DMS 验证)。
+ - `FRONTEND_MODIFIED` = `0`。
+- **全量测试指标**:
+ - `OLD_TEST_COUNT = 233`
+ - `NEW_TEST_COUNT = 18`
+ - `TOTAL_TEST_COUNT = 251` (ruoyi-train: 219, ruoyi-admin: 32)
+ - `FAILURES = 0`,`ERRORS = 0`,`SKIPPED = 0`。
+ - `MAVEN_TEST = SUCCESS`,`MAVEN_PACKAGE = SUCCESS`。
+ - `git diff --check = PASS`。
+- **环境纪律与迁移标记**:
+ - `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
+ - `009_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`009_RUNTIME_SECOND_RUN = NOT_EXECUTED`。
+ - `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`。
+ - `COMMIT? NO`,`PUSH? NO`,`DEPLOY? NO`(严格 HOLD 等待审查)。