修复门禁:校准撤权批量结果与禁用态语义

This commit is contained in:
wangzhibiao committed 2026-09-21 14:57:30 +08:00
1 parent 3349675183
commit b8f18e6421
4 files changed
+300 -138

No files matched your search

@@ -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)
* <p>
* 核心架构与安全准则:
* 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<TrainAccommodation> 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<TrainDormAuth> 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<TrainDormAuth> 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<TrainDormAuth> 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("(?<!\\d)(1[3-9]\\d)\\d{4}(\\d{4})(?!\\d)", "$1****$2");
// 脱敏手机号 (支持 +86, 国际码, 空格, 分隔符: MIN-01)
sanitized = sanitized.replaceAll("(?i)(\\+?86\\s*[-\\s]*)?(1[3-9]\\d)[\\s-]*(\\d{4})[\\s-]*(\\d{4})", "$1$2****$4");
return sanitized;
}
}
@@ -5,19 +5,23 @@ import java.util.ArrayList;
import java.util.List;
/**
* 批量门禁对账汇总结果 (Wave 3B / Package A)
* 批量门禁对账汇总结果 (Wave 3B / Package A.1)
* <p>
* 终态准则:
* 终态准则 (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 +
'}';
@@ -27,5 +27,10 @@ public enum ReconciliationDecision {
/**
* 满足发放条件,等待管理员手动触发凭据下发 (Package A / Option B)
*/
AWAITING_MANUAL_ISSUE
AWAITING_MANUAL_ISSUE,
/**
* 明确失败或不可恢复异常 (MIN-06)
*/
FAILED
}
@@ -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") +
'}';
}
}