diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java
index 2ac22d9fe..c5009fe34 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/AccessAuthorizationOrchestrator.java
@@ -12,6 +12,7 @@ import com.ruoyi.train.integration.dms.config.DmsProperties;
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
import com.ruoyi.train.integration.dms.dto.IssueResultVO;
+import com.ruoyi.train.integration.dms.exception.DmsBusinessException;
import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
import com.ruoyi.train.integration.dms.security.SecurePasswordGenerator;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
@@ -36,17 +37,18 @@ import java.util.List;
import java.util.stream.Collectors;
/**
- * 宿舍门禁业务授权统一编排器 (Wave 3B / Package A)
+ * 宿舍门禁业务授权统一编排器 (Wave 3B / Package A.1 Hardened)
*
* 核心架构与安全准则:
* 1. 状态驱动对账 (Reconciliation Model):业务事实 → 期望状态,与触发顺序解耦。
* 2. C12 方案 B (AWAITING_MANUAL_ISSUE):自动业务事件(报到、住宿分配、调房)严禁自动生成明文密码并下发 DMS,
* 仅确保本地授权记录 (PENDING_GRANT) 并返回 AWAITING_MANUAL_ISSUE。
* 3. 显式下发端点 (issueAuthorization):仅交互请求下发,一机一密短暂返回一次性明文,严禁落库、严禁日志输出。
- * 4. C10 真实手机号:严禁使用伪造手机号,唯一来源为学员主档 (TrainStudent.sPhone)。手机号缺失严格阻断下发。
- * 5. C09 真实有效期:以开班计划结束时间 (train_class_plan.end_time) 为准,严禁无依据的 +30 天推导。
- * 6. C01/C11 统一注销与批量注销:严禁直接修改状态模拟注销,严禁 delete(0);退训时遍历所有历史授权,独立处理并生成 BatchReconciliationResult。
- * 7. 事务边界 (LONG_DB_TRANSACTION_AROUND_HTTP = NO):数据库事务内仅落库 CREATED 状态,经 afterCommit 钩子派发外部 HTTP。
+ * 4. BLK-01 / BLK-03: 外部 Mutation 区分明确失败与不确定结果 (AMBIGUOUS)。原子 Claim 抢占派发权,杜绝双发与盲重发。
+ * 5. BLK-02: 迟到轮询结果禁止复活授权,状态机单向不可逆保障。
+ * 6. MAJ-02: DMS 禁用态下安全区分草稿注销与已产生外部凭据的队列沉淀 (PENDING_REVOKE)。
+ * 7. MAJ-03 / MAJ-04: 批量对账与调房旧授权统一使用 BatchReconciliationResult,真实反映状态,全失败绝不报成功。
+ * 8. MIN-01: 手机号国际码 (+86) 正规化与日志全流程多格式 PII 脱敏。
*/
@Service
public class AccessAuthorizationOrchestrator {
@@ -164,59 +166,22 @@ public class AccessAuthorizationOrchestrator {
}
/**
- * 学员退训/结训注销处理 (C11: 遍历注销所有历史授权,独立处理,生成 BatchReconciliationResult)
+ * 学员退训/结训注销处理 (C11 / MAJ-04: 统一批量撤权助手)
*/
private ReconciliationResult handleEnrollmentTermination(TrainEnrollment enrollment, List accHistory) {
- BatchReconciliationResult batch = new BatchReconciliationResult(enrollment.getEnrollmentId());
-
- if (accHistory == null || accHistory.isEmpty()) {
- batch.setOutcome(BatchReconciliationResult.OUTCOME_ALL_SETTLED);
- return ReconciliationResult.batch(batch);
- }
-
- // 遍历所有相关住宿记录的授权
- for (TrainAccommodation acc : accHistory) {
- if (acc == null || acc.getAccommodationId() == null) {
- continue;
- }
- Long accId = acc.getAccommodationId();
- TrainDormAuth auth = dormAuthMapper != null ? dormAuthMapper.selectTrainDormAuthByAccommodationId(accId) : null;
-
- if (auth == null) {
- batch.addItem(new ReconciliationItemVO(null, accId, "REVOKE", "NO_ACTION", "No auth record found"));
- batch.setNoAction(batch.getNoAction() + 1);
- continue;
- }
-
- // 幂等:若已为 REVOKED,记为 NO_ACTION,不得重复撤权
- if (TrainDormAuth.AUTH_STATUS_REVOKED.equals(auth.getAuthStatus())) {
- batch.addItem(new ReconciliationItemVO(auth.getAuthId(), accId, "REVOKE", "NO_ACTION", "Already REVOKED"));
- batch.setNoAction(batch.getNoAction() + 1);
- continue;
- }
-
- // 执行单项注销流程,一项异常不得阻止其余项继续处理
- try {
- ReconciliationResult revokeRes = executeRevokeFlow(auth, "EnrollmentTerminated:" + enrollment.getEnrollmentStatus(), "Orchestrator");
- if (revokeRes.isSuccess()) {
- String itemStatus = "DISPATCHED";
- if (ReconciliationDecision.NO_ACTION.equals(revokeRes.getDecision())) {
- itemStatus = "REVOKED";
+ List authList = new ArrayList<>();
+ if (accHistory != null) {
+ for (TrainAccommodation acc : accHistory) {
+ if (acc != null && acc.getAccommodationId() != null) {
+ TrainDormAuth auth = dormAuthMapper != null ? dormAuthMapper.selectTrainDormAuthByAccommodationId(acc.getAccommodationId()) : null;
+ if (auth != null) {
+ authList.add(auth);
}
- batch.addItem(new ReconciliationItemVO(auth.getAuthId(), accId, "REVOKE", itemStatus, revokeRes.getReason()));
- batch.setDispatched(batch.getDispatched() + 1);
- } else {
- batch.addItem(new ReconciliationItemVO(auth.getAuthId(), accId, "REVOKE", "FAILED", revokeRes.getReason()));
- batch.setFailed(batch.getFailed() + 1);
}
- } catch (Exception ex) {
- log.error("Failed to revoke auth {} for enrollment {}: {}", auth.getAuthId(), enrollment.getEnrollmentId(), ex.getMessage());
- batch.addItem(new ReconciliationItemVO(auth.getAuthId(), accId, "REVOKE", "FAILED", sanitize(ex.getMessage())));
- batch.setFailed(batch.getFailed() + 1);
}
}
-
- batch.computeOutcome();
+ BatchReconciliationResult batch = executeBatchRevokeForAuths(enrollment.getEnrollmentId(), authList,
+ "EnrollmentTerminated:" + enrollment.getEnrollmentStatus(), "Orchestrator");
return ReconciliationResult.batch(batch);
}
@@ -233,18 +198,13 @@ public class AccessAuthorizationOrchestrator {
// 如果新住宿的授权已经生效 (ACTIVE)
if (currentAuth != null && TrainDormAuth.AUTH_STATUS_ACTIVE.equals(currentAuth.getAuthStatus())) {
- // 调房场景:新房已 ACTIVE,遍历检查所有历史住宿的残留授权并执行注销 (C11: 遍历全部,不单项早退)
+ // 调房场景:新房已 ACTIVE,统一批量撤权处理所有历史残留授权 (MAJ-04: 杜绝假成功)
List staleAuths = findStaleActiveAuthsInHistory(accommodationId, accHistory);
if (!staleAuths.isEmpty()) {
log.info("New room access ACTIVE. Triggering revokes for {} stale accommodation auths", staleAuths.size());
- for (TrainDormAuth oldAuth : staleAuths) {
- try {
- executeRevokeFlow(oldAuth, "TransferCompleted", "Orchestrator");
- } catch (Exception e) {
- log.error("Failed to revoke stale auth {}: {}", oldAuth.getAuthId(), e.getMessage());
- }
- }
- return ReconciliationResult.revoking("Triggered revokes for " + staleAuths.size() + " stale accommodation auths");
+ BatchReconciliationResult batch = executeBatchRevokeForAuths(enrollment.getEnrollmentId(), staleAuths,
+ "TransferCompleted", "Orchestrator");
+ return ReconciliationResult.batch(batch);
}
return ReconciliationResult.noAction("Door authorization is already ACTIVE for accommodation " + accommodationId);
}
@@ -301,6 +261,63 @@ public class AccessAuthorizationOrchestrator {
return ReconciliationResult.awaitingManualIssue(authId, "Prerequisites met; waiting for administrator manual issue");
}
+ /**
+ * 抽取统一批量撤权处理方法 (MAJ-04: 退训与调房公用,任何单项进入批次统计,全失败绝不报成功)
+ */
+ public BatchReconciliationResult executeBatchRevokeForAuths(Long enrollmentId, List authList, String reason, String operator) {
+ BatchReconciliationResult batch = new BatchReconciliationResult(enrollmentId);
+ if (authList == null || authList.isEmpty()) {
+ batch.setOutcome(BatchReconciliationResult.OUTCOME_ALL_SETTLED);
+ return batch;
+ }
+
+ for (TrainDormAuth auth : authList) {
+ if (auth == null || auth.getAuthId() == null) {
+ continue;
+ }
+ Long authId = auth.getAuthId();
+ Long accId = auth.getAccommodationId();
+
+ if (TrainDormAuth.AUTH_STATUS_REVOKED.equals(auth.getAuthStatus())) {
+ batch.addItem(new ReconciliationItemVO(authId, accId, "REVOKE", "NO_ACTION", "Already REVOKED"));
+ batch.setNoAction(batch.getNoAction() + 1);
+ continue;
+ }
+
+ try {
+ ReconciliationResult res = executeRevokeFlow(auth, reason, operator);
+ if (res.isSuccess()) {
+ if (ReconciliationDecision.NO_ACTION.equals(res.getDecision())) {
+ batch.addItem(new ReconciliationItemVO(authId, accId, "REVOKE", "NO_ACTION", res.getReason()));
+ batch.setNoAction(batch.getNoAction() + 1);
+ } else if (res.getOperationId() != null && !res.isExternalInvoked()) {
+ // DMS 禁用态下的排队创建
+ batch.addItem(new ReconciliationItemVO(authId, accId, "REVOKE", "QUEUED", res.getReason()));
+ batch.setQueued(batch.getQueued() + 1);
+ } else {
+ batch.addItem(new ReconciliationItemVO(authId, accId, "REVOKE", "DISPATCHED", res.getReason()));
+ batch.setDispatched(batch.getDispatched() + 1);
+ }
+ } else {
+ if (res.getReason() != null && res.getReason().contains("AMBIGUOUS_EXTERNAL_CREDENTIAL")) {
+ batch.addItem(new ReconciliationItemVO(authId, accId, "REVOKE", "MANUAL_REQUIRED", res.getReason()));
+ batch.setManualRequired(batch.getManualRequired() + 1);
+ } else {
+ batch.addItem(new ReconciliationItemVO(authId, accId, "REVOKE", "FAILED", res.getReason()));
+ batch.setFailed(batch.getFailed() + 1);
+ }
+ }
+ } catch (Exception ex) {
+ log.error("Batch revoke failed for authId {}: {}", authId, ex.getMessage());
+ batch.addItem(new ReconciliationItemVO(authId, accId, "REVOKE", "FAILED", sanitize(ex.getMessage())));
+ batch.setFailed(batch.getFailed() + 1);
+ }
+ }
+
+ batch.computeOutcome();
+ return batch;
+ }
+
/**
* 管理员显式下发门禁凭据 (交互端点专用,一次性返回明文密码)
*
@@ -327,13 +344,28 @@ public class AccessAuthorizationOrchestrator {
throw new ServiceException("当前授权处于注销流程或已注销,无法重新下发凭据");
}
- // 2. ERROR 重试守卫:检查是否存在可能已创建但未知 externalAuthId 的不确定状态
- if (TrainDormAuth.AUTH_STATUS_ERROR.equals(currentStatus)) {
- if (auth.getExternalAuthId() != null && !auth.getExternalAuthId().trim().isEmpty()) {
- throw new ServiceException("该授权记录已存在外部凭据标识 (" + auth.getExternalAuthId() + "),为防止制造孤儿凭据,禁止盲目重发。需先核实或注销旧凭据。");
+ // 2. BLK-01 / Section 5: 重发守卫 (检查 latest GRANT operation)
+ // 任何处于 DISPATCHING, AMBIGUOUS, ACCEPTED, PENDING 或已存在的外部凭据,严禁盲目重发!
+ TrainExternalOperation latestGrant = operationService != null ? operationService.selectLatestGrantOperationByDoorAuthId(authId) : null;
+ if (latestGrant != null) {
+ String grantStatus = latestGrant.getOperationStatus();
+ if (TrainExternalOperation.STATUS_DISPATCHING.equals(grantStatus)
+ || TrainExternalOperation.STATUS_AMBIGUOUS.equals(grantStatus)
+ || TrainExternalOperation.STATUS_ACCEPTED.equals(grantStatus)
+ || TrainExternalOperation.STATUS_PENDING.equals(grantStatus)
+ || TrainExternalOperation.STATUS_SUCCESS.equals(grantStatus)) {
+ log.warn("Reissue blocked for authId {}: latest grant operation {} is in non-definitive status {}",
+ authId, latestGrant.getOperationId(), grantStatus);
+ return new IssueResultVO(authId, null, null, null,
+ "AMBIGUOUS_EXTERNAL_MUTATION",
+ "AMBIGUOUS_EXTERNAL_MUTATION: 上一次下发结果无法确认,禁止重复下发,请先核对厂商侧凭据。(MANUAL_VENDOR_RECONCILIATION_REQUIRED)");
}
}
+ if (auth.getExternalAuthId() != null && !auth.getExternalAuthId().trim().isEmpty()) {
+ throw new ServiceException("该授权记录已存在外部凭据标识 (" + auth.getExternalAuthId() + "),为防止制造孤儿凭据,禁止盲目重发。需先核实或注销旧凭据。");
+ }
+
// 3. 关联实体获取
TrainAccommodation acc = accommodationMapper != null ? accommodationMapper.selectTrainAccommodationByAccommodationId(auth.getAccommodationId()) : null;
if (acc == null) {
@@ -345,16 +377,17 @@ public class AccessAuthorizationOrchestrator {
throw new ServiceException("关联学员报名档案不存在: " + acc.getEnrollmentId());
}
- // 4. C10 真实手机号核验:严禁伪造手机号,唯一来源为 TrainStudent.sPhone
- String phoneNumber = null;
+ // 4. MIN-01 / C10 手机号核验与正规化:支持 +86、空格、分隔符,严禁伪造
+ String rawPhone = null;
if (studentMapper != null && enrollment.getStudentId() != null) {
TrainStudent student = studentMapper.selectTrainStudentBySId(enrollment.getStudentId());
if (student != null) {
- phoneNumber = student.getsPhone();
+ rawPhone = student.getsPhone();
}
}
- if (phoneNumber == null || !phoneNumber.matches("^1[3-9]\\d{9}$")) {
- log.warn("Cannot issue credential for auth {}: missing or invalid student phone ({})", authId, sanitize(phoneNumber));
+ String normalizedPhone = normalizePhone(rawPhone);
+ if (normalizedPhone == null || !normalizedPhone.matches("^1[3-9]\\d{9}$")) {
+ log.warn("Cannot issue credential for auth {}: missing or invalid student phone ({})", authId, sanitize(rawPhone));
return new IssueResultVO(authId, null, null, null, "WAITING_FOR_PREREQUISITE", "MISSING_STUDENT_PHONE: 学员主档缺失有效手机号,暂无法下发门禁");
}
@@ -392,7 +425,7 @@ public class AccessAuthorizationOrchestrator {
dormAuthService.updateTrainDormAuth(auth);
}
- // 7. DMS disabled 检查 (Req 12)
+ // 7. DMS disabled 检查 (MAJ-05 / Req 12)
if (dmsProperties != null && !dmsProperties.isEnabled()) {
return new IssueResultVO(authId, null, null, null, "WAITING_FOR_PREREQUISITE", "DMS_DISABLED: 共享运行环境门禁对接未启用 (dms.enabled=false),无法向外部厂商下发门禁凭据");
}
@@ -400,7 +433,7 @@ public class AccessAuthorizationOrchestrator {
// 8. 内存生成 6 位强随机数字密码
String plaintextPassword = SecurePasswordGenerator.generateDefaultPassword();
- // 9. 创建外部操作流水 (短本地事务,幂等键保护)
+ // 9. 创建外部操作流水 (短本地事务,幂等键保护,初始为 CREATED)
Integer version = (auth.getOperationVersion() != null ? auth.getOperationVersion() : 1) + 1;
String idempotencyKey = "GRANT:AUTH:" + authId + ":V" + version;
TrainExternalOperation op = new TrainExternalOperation();
@@ -421,7 +454,16 @@ public class AccessAuthorizationOrchestrator {
dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_GRANTING, operationId, operator);
}
- // 11. 调用外部厂商接口
+ // 11. DB级原子 Claim 抢占派发权 (BLK-03)
+ if (operationService != null && operationId != null) {
+ boolean claimed = operationService.claimOperationForDispatch(operationId);
+ if (!claimed) {
+ log.warn("Operation {} was already claimed by concurrent dispatcher", operationId);
+ throw new ServiceException("该下发操作已被抢占,请勿重复触发");
+ }
+ }
+
+ // 12. 调用外部厂商接口 (Definitive vs Ambiguous Failure 保护: BLK-01)
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Long lockId = null;
try {
@@ -432,25 +474,39 @@ public class AccessAuthorizationOrchestrator {
try {
DmsPasswordCreateResponse resp = credentialGateway.issueOnlinePassword(
- lockId, enrollment.getStudentId(), phoneNumber, plaintextPassword,
+ lockId, enrollment.getStudentId(), normalizedPhone, plaintextPassword,
sdf.format(validStart), sdf.format(validUntil));
String extOpIdStr = resp.getOperationId() != null ? String.valueOf(resp.getOperationId()) : null;
String extKeyIdStr = resp.getId() != null ? String.valueOf(resp.getId()) : null;
+ // Section 6: 优先把外部凭据 ID 持久化到 train_external_operation.external_key_id
if (operationService != null && operationId != null) {
operationService.bindExternalReference(operationId, extOpIdStr, resp.getLockHardwareId(), extKeyIdStr, operator);
- operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, operator);
}
- if (extKeyIdStr != null && dormAuthService != null) {
- auth.setExternalAuthId(extKeyIdStr);
- dormAuthService.updateTrainDormAuth(auth);
- }
-
- // 成功后清空历史错误 (Req 16)
- if (dormAuthMapper != null) {
- dormAuthMapper.clearLastError(authId);
+ // 然后推进授权主表
+ try {
+ if (extKeyIdStr != null && dormAuthService != null) {
+ auth.setExternalAuthId(extKeyIdStr);
+ dormAuthService.updateTrainDormAuth(auth);
+ }
+ if (operationService != null && operationId != null) {
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, operator);
+ }
+ if (dormAuthMapper != null) {
+ dormAuthMapper.clearLastError(authId);
+ }
+ } catch (Exception writeBackEx) {
+ log.error("Vendor succeeded but local auth update failed for authId {}: {}", authId, writeBackEx.getMessage(), writeBackEx);
+ if (operationService != null && operationId != null) {
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_AMBIGUOUS,
+ "AMBIGUOUS_EXTERNAL_MUTATION", "Vendor success but local auth update failed: " + sanitize(writeBackEx.getMessage()), operator);
+ }
+ if (dormAuthService != null) {
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, operator);
+ }
+ throw new ServiceException("AMBIGUOUS_EXTERNAL_MUTATION: 凭据已在外部创建 (KeyID: " + extKeyIdStr + ") 但本地授权写回失败,禁止重复下发,请核对厂商凭据");
}
log.info("DMS password create accepted for authId {}: localOpId={}, extOpId={}", authId, operationId, extOpIdStr);
@@ -464,18 +520,31 @@ public class AccessAuthorizationOrchestrator {
"凭据已提交下发,设备确认后生效。"
);
- } catch (Exception e) {
- log.error("DMS password create failed for authId {}: {}", authId, sanitize(e.getMessage()));
+ } catch (DmsBusinessException be) {
+ // 厂商明确返回业务失败 (Definitive Failure: BLK-01)
+ log.error("DMS password create rejected by vendor for authId {}: [{}] {}", authId, be.getErrorCode(), sanitize(be.getMessage()));
if (operationService != null && operationId != null) {
- operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.getMessage()), operator);
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, be.getErrorCode(), sanitize(be.getMessage()), operator);
}
if (dormAuthService != null) {
- dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, operator);
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, "FAILED: " + sanitize(be.getMessage()), operator);
}
- if (dormAuthMapper != null) {
- dormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_FAILED, "GATEWAY_ERROR: " + sanitize(e.getMessage()), operator, new Date());
+ return new IssueResultVO(authId, null, null, null, "FAILED", "门禁凭据下发失败 (厂商拒绝): " + sanitize(be.getMessage()));
+ } catch (ServiceException se) {
+ throw se;
+ } catch (Exception e) {
+ // 网络超时/连接中断/无法证明厂商未执行 (Ambiguous Failure: BLK-01)
+ log.error("DMS password create ambiguous outcome for authId {}: {}", authId, sanitize(e.getMessage()), e);
+ if (operationService != null && operationId != null) {
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_AMBIGUOUS,
+ "AMBIGUOUS_EXTERNAL_MUTATION", sanitize(e.getMessage()), operator);
}
- throw new ServiceException("门禁凭据下发失败: " + sanitize(e.getMessage()));
+ if (dormAuthService != null) {
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId,
+ "AMBIGUOUS_EXTERNAL_MUTATION: " + sanitize(e.getMessage()), operator);
+ }
+ return new IssueResultVO(authId, null, null, null, "AMBIGUOUS_EXTERNAL_MUTATION",
+ "AMBIGUOUS_EXTERNAL_MUTATION: 上一次下发结果无法确认,禁止重复下发,请先核对厂商侧凭据。(" + sanitize(e.getMessage()) + ")");
}
}
@@ -498,25 +567,66 @@ public class AccessAuthorizationOrchestrator {
}
/**
- * 单项授权注销核心流程 (安全守卫 + 事务边界隔离)
+ * 单项授权注销核心流程 (安全守卫 + 事务边界隔离 + MAJ-02 DMS Disabled 状态机)
*/
private ReconciliationResult executeRevokeFlow(TrainDormAuth auth, String reason, String operator) {
Long authId = auth.getAuthId();
- // 1. DMS disabled 守卫 (Req 12): 关闭时不得发送 HTTP
+ // 1. DMS disabled 守卫 (MAJ-02)
if (dmsProperties != null && !dmsProperties.isEnabled()) {
- if (auth.getExternalAuthId() == null || auth.getExternalAuthId().trim().isEmpty()) {
- // 从未下发过外部凭据,本地可直接安全标记为 REVOKED
+ boolean isPendingGrant = TrainDormAuth.AUTH_STATUS_PENDING_GRANT.equals(auth.getAuthStatus());
+ boolean noExternalAuthId = auth.getExternalAuthId() == null || auth.getExternalAuthId().trim().isEmpty();
+
+ // 检查是否存在任何进行中或有副作用的历史 GRANT 操作流水
+ TrainExternalOperation latestGrant = operationService != null ? operationService.selectLatestGrantOperationByDoorAuthId(authId) : null;
+ boolean hasAmbiguousOrActiveOp = latestGrant != null && (
+ TrainExternalOperation.STATUS_DISPATCHING.equals(latestGrant.getOperationStatus())
+ || TrainExternalOperation.STATUS_AMBIGUOUS.equals(latestGrant.getOperationStatus())
+ || TrainExternalOperation.STATUS_ACCEPTED.equals(latestGrant.getOperationStatus())
+ || TrainExternalOperation.STATUS_PENDING.equals(latestGrant.getOperationStatus())
+ || TrainExternalOperation.STATUS_SUCCESS.equals(latestGrant.getOperationStatus())
+ );
+
+ if (isPendingGrant && noExternalAuthId && !hasAmbiguousOrActiveOp) {
+ // 仅当从未下发且无任何外部副作用可能时,允许本地安全标记为 REVOKED
if (dormAuthService != null) {
dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_REVOKED, null, operator);
}
- return ReconciliationResult.noAction("DMS disabled; marked REVOKED locally as no external credential existed");
- } else {
- // 存在历史外部凭据,不可谎报 REVOKED,保持待处理状态
+ return ReconciliationResult.noAction("DMS disabled; marked REVOKED locally as draft auth never had external mutation");
+ } else if (!noExternalAuthId) {
+ // externalAuthId 存在:建立/保留 PENDING_REVOKE,并创建 REVOKE CREATED operation,但不发 HTTP (等待未来 poller 派发)
if (dormAuthService != null) {
dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_PENDING_REVOKE, null, operator);
}
- return ReconciliationResult.waiting("DMS disabled; authorization has externalAuthId " + auth.getExternalAuthId() + " awaiting manual settlement");
+ Integer version = (auth.getOperationVersion() != null ? auth.getOperationVersion() : 1) + 1;
+ 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);
+ op.setNextPollTime(new Date());
+ op.setCreateBy(operator);
+
+ TrainExternalOperation createdOp = operationService != null ? operationService.createOperation(op) : null;
+ Long operationId = createdOp != null ? createdOp.getOperationId() : null;
+ if (dormAuthService != null && operationId != null) {
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_PENDING_REVOKE, operationId, operator);
+ }
+ return ReconciliationResult.revokeRequired(authId, operationId, false, true, "DMS disabled; authorization has externalAuthId " + auth.getExternalAuthId() + ", revoke queued as CREATED");
+ } else {
+ // externalAuthId 缺失但处于非草稿状态或存在外部凭据可能:进入明确 ERROR
+ log.warn("Cannot safely revoke auth {} when DMS is disabled without externalAuthId. Marking ERROR", authId);
+ if (dormAuthService != null) {
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, null, operator);
+ }
+ auth.setLastError("AMBIGUOUS_EXTERNAL_CREDENTIAL: DMS disabled and external credential state cannot be verified");
+ if (dormAuthMapper != null) {
+ dormAuthMapper.updateTrainDormAuth(auth);
+ }
+ return ReconciliationResult.failure("AMBIGUOUS_EXTERNAL_CREDENTIAL: DMS disabled and external credential state cannot be verified for auth " + authId);
}
}
@@ -534,9 +644,9 @@ public class AccessAuthorizationOrchestrator {
if (dormAuthService != null) {
dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, null, operator);
}
+ auth.setLastError("UNRESOLVABLE_EXTERNAL_CREDENTIAL: Missing externalAuthId for auth " + authId + "; manual review required");
if (dormAuthMapper != null) {
- dormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_FAILED,
- "UNRESOLVABLE_EXTERNAL_CREDENTIAL: externalAuthId缺失,禁止调用delete(0),需人工核对厂商侧凭据", operator, new Date());
+ dormAuthMapper.updateTrainDormAuth(auth);
}
return ReconciliationResult.failure("UNRESOLVABLE_EXTERNAL_CREDENTIAL: Missing externalAuthId for auth " + authId + "; manual review required");
}
@@ -549,14 +659,10 @@ public class AccessAuthorizationOrchestrator {
if (dormAuthService != null) {
dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, null, operator);
}
- if (dormAuthMapper != null) {
- dormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_FAILED,
- "UNRESOLVABLE_EXTERNAL_CREDENTIAL: externalAuthId格式非法(" + auth.getExternalAuthId() + "),需人工核对", operator, new Date());
- }
return ReconciliationResult.failure("UNRESOLVABLE_EXTERNAL_CREDENTIAL: Invalid externalAuthId for auth " + authId);
}
- // 3. 事务内:创建外部操作流水 (STATUS_CREATED) 并推进状态为 REVOKING (Req 17)
+ // 3. 事务内:创建外部操作流水 (STATUS_CREATED) 并推进状态为 REVOKING
Integer version = (auth.getOperationVersion() != null ? auth.getOperationVersion() : 1) + 1;
String idempotencyKey = "REVOKE:AUTH:" + authId + ":V" + version;
TrainExternalOperation op = new TrainExternalOperation();
@@ -591,9 +697,18 @@ public class AccessAuthorizationOrchestrator {
}
/**
- * 派发远程撤销 HTTP 请求 (事务外执行)
+ * 派发远程撤销 HTTP 请求 (事务外执行,受 BLK-03 原子 Claim 保护)
*/
public ReconciliationResult dispatchRevokeCall(Long authId, Long operationId, Long externalKeyId, String operator) {
+ // 原子 Claim (BLK-03)
+ if (operationService != null && operationId != null) {
+ boolean claimed = operationService.claimOperationForDispatch(operationId);
+ if (!claimed) {
+ log.info("Revoke operation {} was already claimed, skipping duplicate afterCommit dispatch", operationId);
+ return ReconciliationResult.revokeRequired(authId, operationId, false, true, "Already claimed by concurrent worker");
+ }
+ }
+
try {
DmsPasswordDeleteResponse resp = credentialGateway.revokeOnlinePassword(externalKeyId);
String extOpIdStr = resp.getOperationId() != null ? String.valueOf(resp.getOperationId()) : null;
@@ -606,18 +721,24 @@ public class AccessAuthorizationOrchestrator {
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, sanitize(e.getMessage()));
+ } catch (DmsBusinessException be) {
+ log.error("DMS password delete rejected by vendor for authId {}: [{}] {}", authId, be.getErrorCode(), sanitize(be.getMessage()));
if (operationService != null && operationId != null) {
- operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.getMessage()), operator);
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, be.getErrorCode(), sanitize(be.getMessage()), operator);
}
if (dormAuthService != null) {
dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, operator);
}
- if (dormAuthMapper != null) {
- dormAuthMapper.updateSyncStatus(authId, TrainDormAuth.STATUS_FAILED, "GATEWAY_ERROR: " + sanitize(e.getMessage()), operator, new Date());
+ return ReconciliationResult.revokeRequired(authId, operationId, true, false, "External revoke rejected: " + sanitize(be.getMessage()));
+ } catch (Exception e) {
+ log.error("DMS password delete ambiguous failure for authId {}: {}", authId, sanitize(e.getMessage()));
+ if (operationService != null && operationId != null) {
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_AMBIGUOUS, "AMBIGUOUS_EXTERNAL_MUTATION", sanitize(e.getMessage()), operator);
}
- return ReconciliationResult.revokeRequired(authId, operationId, true, false, "External revoke failed: " + sanitize(e.getMessage()));
+ if (dormAuthService != null) {
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, operator);
+ }
+ return ReconciliationResult.revokeRequired(authId, operationId, true, false, "AMBIGUOUS_EXTERNAL_MUTATION: " + sanitize(e.getMessage()));
}
}
@@ -635,12 +756,24 @@ public class AccessAuthorizationOrchestrator {
return staleList;
}
+ /**
+ * 规范化手机号:去除空白、横杠、+86国家码等
+ */
+ public static String normalizePhone(String phone) {
+ if (phone == null) return null;
+ String clean = phone.trim().replaceAll("[\\s\\-+]", "");
+ if (clean.startsWith("86") && clean.length() == 13) {
+ clean = clean.substring(2);
+ }
+ return clean;
+ }
+
private String sanitize(String str) {
if (str == null) return null;
String sanitized = str.replaceAll("(?i)(password|token|secret|appsecret)=[^&\\s]+", "$1=[PROTECTED]")
.replaceAll("(?i)(\"password\"\\s*:\\s*\")[^\"]+(\")", "$1[PROTECTED]$2");
- // 脱敏 11 位手机号 (Req 8)
- sanitized = sanitized.replaceAll("(?
- * 终态准则:
+ * 终态准则 (MAJ-03):
* 1. ALL_SETTLED: 所有相关授权均已确认进入终态 (REVOKED) 或明确无需任何操作 (NO_ACTION);
- * 2. IN_PROGRESS: 存在已向外部派发但仍在异步处理/轮询中的授权操作;
- * 3. PARTIAL_FAILURE: 部分授权成功/已处理,部分授权出现不可恢复异常;
- * 4. ALL_FAILED: 所有需要操作的授权项均处理失败。
+ * 2. IN_PROGRESS: 存在已向外部真正派发 HTTP 请求且在异步处理中的授权操作;
+ * 3. WAITING_FOR_DMS: DMS 禁用态下已安全持久化 REVOKE CREATED 操作排队等待未来派发;
+ * 4. REQUIRES_MANUAL_SETTLEMENT: 外部凭证不确定或状态异常,阻断自动处理,必须人工对账解除;
+ * 5. PARTIAL_FAILURE: 部分授权成功/已处理,部分授权出现不可恢复异常;
+ * 6. ALL_FAILED: 所有需要操作的授权项均处理失败。
*/
public class BatchReconciliationResult implements Serializable {
private static final long serialVersionUID = 1L;
public static final String OUTCOME_ALL_SETTLED = "ALL_SETTLED";
public static final String OUTCOME_IN_PROGRESS = "IN_PROGRESS";
+ public static final String OUTCOME_WAITING_FOR_DMS = "WAITING_FOR_DMS";
+ public static final String OUTCOME_REQUIRES_MANUAL_SETTLEMENT = "REQUIRES_MANUAL_SETTLEMENT";
public static final String OUTCOME_PARTIAL_FAILURE = "PARTIAL_FAILURE";
public static final String OUTCOME_ALL_FAILED = "ALL_FAILED";
@@ -27,6 +31,8 @@ public class BatchReconciliationResult implements Serializable {
private int dispatched;
private int noAction;
private int failed;
+ private int queued;
+ private int manualRequired;
private String outcome;
public BatchReconciliationResult() {
@@ -51,11 +57,19 @@ public class BatchReconciliationResult implements Serializable {
}
if (this.failed > 0) {
- if (this.failed == this.total) {
+ if (this.failed + this.manualRequired == this.total) {
this.outcome = OUTCOME_ALL_FAILED;
} else {
this.outcome = OUTCOME_PARTIAL_FAILURE;
}
+ } else if (this.manualRequired > 0) {
+ if (this.manualRequired == this.total) {
+ this.outcome = OUTCOME_REQUIRES_MANUAL_SETTLEMENT;
+ } else {
+ this.outcome = OUTCOME_PARTIAL_FAILURE;
+ }
+ } else if (this.queued > 0) {
+ this.outcome = OUTCOME_WAITING_FOR_DMS;
} else if (this.dispatched > 0) {
this.outcome = OUTCOME_IN_PROGRESS;
} else {
@@ -111,6 +125,22 @@ public class BatchReconciliationResult implements Serializable {
this.failed = failed;
}
+ public int getQueued() {
+ return queued;
+ }
+
+ public void setQueued(int queued) {
+ this.queued = queued;
+ }
+
+ public int getManualRequired() {
+ return manualRequired;
+ }
+
+ public void setManualRequired(int manualRequired) {
+ this.manualRequired = manualRequired;
+ }
+
public String getOutcome() {
return outcome;
}
@@ -119,6 +149,14 @@ public class BatchReconciliationResult implements Serializable {
this.outcome = outcome;
}
+ public boolean isSuccess() {
+ return OUTCOME_ALL_SETTLED.equals(outcome) || OUTCOME_IN_PROGRESS.equals(outcome) || OUTCOME_WAITING_FOR_DMS.equals(outcome);
+ }
+
+ public boolean isExternalInvoked() {
+ return dispatched > 0;
+ }
+
@Override
public String toString() {
return "BatchReconciliationResult{" +
@@ -127,6 +165,8 @@ public class BatchReconciliationResult implements Serializable {
", dispatched=" + dispatched +
", noAction=" + noAction +
", failed=" + failed +
+ ", queued=" + queued +
+ ", manualRequired=" + manualRequired +
", outcome='" + outcome + '\'' +
", items=" + items +
'}';
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java
index 7e5cd34a2..a4b8c46e7 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationDecision.java
@@ -27,5 +27,10 @@ public enum ReconciliationDecision {
/**
* 满足发放条件,等待管理员手动触发凭据下发 (Package A / Option B)
*/
- AWAITING_MANUAL_ISSUE
+ AWAITING_MANUAL_ISSUE,
+
+ /**
+ * 明确失败或不可恢复异常 (MIN-06)
+ */
+ FAILED
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java
index 77799f7be..daf8daf2a 100644
--- a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationResult.java
@@ -37,7 +37,7 @@ public class ReconciliationResult implements Serializable {
public static ReconciliationResult failure(String reason) {
ReconciliationResult res = new ReconciliationResult();
- res.setDecision(ReconciliationDecision.WAITING_FOR_PREREQUISITE);
+ res.setDecision(ReconciliationDecision.FAILED);
res.setReason(reason);
res.setSuccess(false);
return res;
@@ -178,21 +178,6 @@ public class ReconciliationResult implements Serializable {
this.externalInvoked = externalInvoked;
}
- /**
- * 临时管理端一次性明文展示凭据 (ADMIN_ONE_TIME_DISPLAY 策略)
- * 声明为 transient,仅在单次创建成功内存调用栈短暂留存,严禁落库、严禁序列化传输至非授权端、严禁日志输出
- */
- private transient String oneTimePlaintextCredential;
-
- @com.fasterxml.jackson.annotation.JsonProperty("oneTimePlaintextCredential")
- public String getOneTimePlaintextCredential() {
- return oneTimePlaintextCredential;
- }
-
- public void setOneTimePlaintextCredential(String oneTimePlaintextCredential) {
- this.oneTimePlaintextCredential = oneTimePlaintextCredential;
- }
-
public boolean isSuccess() {
return success;
}
@@ -211,7 +196,6 @@ public class ReconciliationResult implements Serializable {
", operationId=" + operationId +
", externalInvoked=" + externalInvoked +
", success=" + success +
- ", oneTimeCredential=" + (oneTimePlaintextCredential != null ? "[PROTECTED]" : "null") +
'}';
}
}