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 等待审查)。