新增门禁:实现访问授权编排

This commit is contained in:
wangzhibiao committed 2026-09-16 11:21:21 +08:00
1 parent bb16b0b875
commit 20c5d494cf
22 files changed
+2306 -7

No files matched your search

@@ -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"));
}
}
@@ -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()
@@ -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)
* <p>
* 核心架构与安全准则:
* 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<TrainAccommodation> 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<TrainAccommodation> 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<TrainAccommodation> 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<TrainExternalResourceMapping> lockMappings = resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId);
if (lockMappings == null || lockMappings.isEmpty()) {
return ReconciliationResult.waiting("No door locks mapped for dormId " + dormId);
}
List<TrainExternalResourceMapping> 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<TrainAccommodation> 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]");
}
}
@@ -0,0 +1,26 @@
package com.ruoyi.train.integration.dms.orchestration;
/**
* 门禁权限对账决策枚举 (厂商中立)
*/
public enum ReconciliationDecision {
/**
* 无需操作 (当前状态已与业务事实对齐)
*/
NO_ACTION,
/**
* 需要发放权限 (满足所有前置业务条件,待下发凭据)
*/
GRANT_REQUIRED,
/**
* 需要注销权限 (学员退训/结训或调房退宿,需收回凭据)
*/
REVOKE_REQUIRED,
/**
* 等待前置业务条件 (未报到、未分房、无门锁映射等)
*/
WAITING_FOR_PREREQUISITE
}
@@ -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 +
'}';
}
}
@@ -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<Long> 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;
@@ -0,0 +1,56 @@
package com.ruoyi.train.integration.dms.security;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
/**
* 安全随机门禁凭据密码生成器
* <p>
* 严格安全准则:
* 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);
}
}
@@ -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);
}
@@ -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<EnrollmentItemVO> 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);
@@ -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<EnrollmentItemVO> 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());
}
}
}
}
@@ -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;
}
/**
* 批量删除住宿安排
*
@@ -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);
}
}
@@ -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)
* <p>
* 遵循安全红线:
* 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<Long, TrainEnrollment> enrollmentDb = new ConcurrentHashMap<>();
private final Map<Long, TrainAccommodation> accommodationDb = new ConcurrentHashMap<>();
private final Map<Long, TrainDormAuth> dormAuthDb = new ConcurrentHashMap<>();
private final Map<String, TrainExternalResourceMapping> resourceMappingDb = new ConcurrentHashMap<>();
private final Map<Long, TrainExternalOperation> operationDb = new ConcurrentHashMap<>();
private final Map<String, TrainExternalOperation> 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<TrainAccommodation> 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<TrainExternalResourceMapping> locks = new ArrayList<>();
for (Map.Entry<String, TrainExternalResourceMapping> 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<TrainExternalOperation> 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);
}
}
}
@@ -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<String> 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);
}
}
@@ -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);
@@ -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 映射。
@@ -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`,优先保障可用性。
@@ -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 统一部署。
@@ -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`。
@@ -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`。
@@ -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` 及测试门锁硬件。
@@ -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 等待审查)。