新增门禁:实现访问授权编排
This commit is contained in:
1 parent
bb16b0b875
commit
20c5d494cf
22 files changed
+2306
-7
No files matched your search
+76
@@ -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()
|
||||
|
||||
+348
@@ -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]");
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.ruoyi.train.integration.dms.orchestration;
|
||||
|
||||
/**
|
||||
* 门禁权限对账决策枚举 (厂商中立)
|
||||
*/
|
||||
public enum ReconciliationDecision {
|
||||
/**
|
||||
* 无需操作 (当前状态已与业务事实对齐)
|
||||
*/
|
||||
NO_ACTION,
|
||||
|
||||
/**
|
||||
* 需要发放权限 (满足所有前置业务条件,待下发凭据)
|
||||
*/
|
||||
GRANT_REQUIRED,
|
||||
|
||||
/**
|
||||
* 需要注销权限 (学员退训/结训或调房退宿,需收回凭据)
|
||||
*/
|
||||
REVOKE_REQUIRED,
|
||||
|
||||
/**
|
||||
* 等待前置业务条件 (未报到、未分房、无门锁映射等)
|
||||
*/
|
||||
WAITING_FOR_PREREQUISITE
|
||||
}
|
||||
+128
@@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
+31
-1
@@ -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);
|
||||
|
||||
+40
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-4
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 批量删除住宿安排
|
||||
*
|
||||
|
||||
+95
@@ -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);
|
||||
}
|
||||
}
|
||||
+765
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+277
@@ -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 等待审查)。
|
||||
Reference in new issue
Block a user