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") + '}'; } }