diff --git a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccessManagementController.java b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccessManagementController.java
index b4533247c..312cfcb38 100644
--- a/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccessManagementController.java
+++ b/RuoYi-Vue/ruoyi-admin/src/main/java/com/ruoyi/web/controller/train/TrainAccessManagementController.java
@@ -14,13 +14,16 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
+import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.domain.TrainExternalOperation;
import com.ruoyi.train.domain.TrainExternalResourceMapping;
+import com.ruoyi.train.integration.dms.dto.IssueResultVO;
import com.ruoyi.train.integration.dms.orchestration.AccessAuthorizationOrchestrator;
import com.ruoyi.train.integration.dms.orchestration.ReconciliationResult;
import com.ruoyi.train.service.IExternalOperationService;
@@ -221,6 +224,30 @@ public class TrainAccessManagementController extends BaseController
return success(result);
}
+ /**
+ * 管理员显式触发下发门禁凭据 (交互端点,单次返回明文密码,严禁入库与记入日志)
+ */
+ @PreAuthorize("@ss.hasPermi('train:access:manage')")
+ @Log(title = "门禁授权", businessType = BusinessType.UPDATE, isSaveResponseData = false)
+ @PostMapping("/auth/{authId}/issue")
+ public AjaxResult issueCredential(@PathVariable("authId") Long authId)
+ {
+ IssueResultVO result = accessAuthorizationOrchestrator.issueAuthorization(authId, getUsernameSafe());
+ return success(result);
+ }
+
+ /**
+ * 管理员显式触发注销门禁授权 (统一通过编排器执行)
+ */
+ @PreAuthorize("@ss.hasPermi('train:access:manage')")
+ @Log(title = "门禁授权", businessType = BusinessType.UPDATE)
+ @PostMapping("/auth/{authId}/revoke")
+ public AjaxResult revokeAuth(@PathVariable("authId") Long authId)
+ {
+ ReconciliationResult result = accessAuthorizationOrchestrator.revokeAuthorization(authId, getUsernameSafe());
+ return success(result);
+ }
+
private String sanitizeSecret(String input)
{
if (input == null)
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/IssueResultVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/IssueResultVO.java
new file mode 100644
index 000000000..292b4c999
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/dto/IssueResultVO.java
@@ -0,0 +1,94 @@
+package com.ruoyi.train.integration.dms.dto;
+
+import java.io.Serializable;
+
+/**
+ * 门禁凭据显式下发响应对象 (Wave 3B / Package A)
+ *
+ * 安全红线:
+ * 1. oneTimePlaintextCredential 为一次性交互展示明文,仅本端点返回一次,严禁存库、严禁记入日志、严禁记入 sys_oper_log;
+ * 2. 交互请求必须配置 @Log(isSaveResponseData = false);
+ * 3. 弹窗关闭后前端内存清除,后续无法再次获取旧明文。
+ */
+public class IssueResultVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long authId;
+ private String oneTimePlaintextCredential;
+ private String validStart;
+ private String validUntil;
+ private String status;
+ private String message;
+
+ public IssueResultVO() {
+ }
+
+ public IssueResultVO(Long authId, String oneTimePlaintextCredential, String validStart, String validUntil, String status, String message) {
+ this.authId = authId;
+ this.oneTimePlaintextCredential = oneTimePlaintextCredential;
+ this.validStart = validStart;
+ this.validUntil = validUntil;
+ this.status = status;
+ this.message = message;
+ }
+
+ public Long getAuthId() {
+ return authId;
+ }
+
+ public void setAuthId(Long authId) {
+ this.authId = authId;
+ }
+
+ public String getOneTimePlaintextCredential() {
+ return oneTimePlaintextCredential;
+ }
+
+ public void setOneTimePlaintextCredential(String oneTimePlaintextCredential) {
+ this.oneTimePlaintextCredential = oneTimePlaintextCredential;
+ }
+
+ public String getValidStart() {
+ return validStart;
+ }
+
+ public void setValidStart(String validStart) {
+ this.validStart = validStart;
+ }
+
+ public String getValidUntil() {
+ return validUntil;
+ }
+
+ public void setValidUntil(String validUntil) {
+ this.validUntil = validUntil;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public String toString() {
+ return "IssueResultVO{" +
+ "authId=" + authId +
+ ", oneTimePlaintextCredential='[PROTECTED]'" +
+ ", validStart='" + validStart + '\'' +
+ ", validUntil='" + validUntil + '\'' +
+ ", status='" + status + '\'' +
+ ", message='" + message + '\'' +
+ '}';
+ }
+}
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 7ec1f5846..2ac22d9fe 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
@@ -1,42 +1,52 @@
package com.ruoyi.train.integration.dms.orchestration;
+import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.train.domain.TrainAccommodation;
+import com.ruoyi.train.domain.TrainClassPlan;
import com.ruoyi.train.domain.TrainDormAuth;
import com.ruoyi.train.domain.TrainEnrollment;
import com.ruoyi.train.domain.TrainExternalOperation;
import com.ruoyi.train.domain.TrainExternalResourceMapping;
-import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateRequest;
+import com.ruoyi.train.domain.TrainStudent;
+import com.ruoyi.train.integration.dms.config.DmsProperties;
import com.ruoyi.train.integration.dms.dto.DmsPasswordCreateResponse;
-import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteRequest;
import com.ruoyi.train.integration.dms.dto.DmsPasswordDeleteResponse;
+import com.ruoyi.train.integration.dms.dto.IssueResultVO;
import com.ruoyi.train.integration.dms.gateway.ExternalCredentialGateway;
import com.ruoyi.train.integration.dms.security.SecurePasswordGenerator;
import com.ruoyi.train.mapper.TrainAccommodationMapper;
+import com.ruoyi.train.mapper.TrainClassPlanMapper;
import com.ruoyi.train.mapper.TrainDormAuthMapper;
import com.ruoyi.train.mapper.TrainEnrollmentMapper;
+import com.ruoyi.train.mapper.TrainStudentMapper;
import com.ruoyi.train.service.IExternalOperationService;
import com.ruoyi.train.service.IExternalResourceMappingService;
import com.ruoyi.train.service.ITrainDormAuthService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionSynchronizationAdapter;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.text.SimpleDateFormat;
-import java.util.Calendar;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
- * 宿舍门禁业务授权统一编排器 (Wave 3A)
+ * 宿舍门禁业务授权统一编排器 (Wave 3B / Package A)
*
* 核心架构与安全准则:
- * 1. 状态驱动对账 (Reconciliation Model):业务事实 → 期望状态 → 外部操作,与触发顺序完全解耦。
- * 2. 最小特权主锁策略 (PRIMARY_LOCK_ONLY):房间内仅对且必须对唯一定义的 1 把主门锁下发凭据。
- * 3. 调房安全策略 (GRANT_NEW_THEN_REVOKE_OLD):先下发新房凭据,新房对账生效后再撤销旧房;新房失败严格保留旧房。
- * 4. 事务边界清晰 (LONG_DB_TRANSACTION_AROUND_HTTP = NO):严禁长事务包住远程 HTTP 调用,外部调用异常绝不回滚业务主事务。
- * 5. 密码安全与短暂留存:SecureRandom 强随机生成,明文仅在内存局部变量留存,严禁落库、严禁日志输出。
- * 6. 操作幂等与防重:业务授权唯一性 + 外部操作幂等键双重防护,重复触发无副作用。
+ * 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。
*/
@Service
public class AccessAuthorizationOrchestrator {
@@ -44,7 +54,7 @@ public class AccessAuthorizationOrchestrator {
public static final String VENDOR_DMS = "DMS";
public static final String POLICY_PRIMARY_LOCK_ONLY = "PRIMARY_LOCK_ONLY";
- public static final String VALIDITY_WINDOW_SOURCE = "BUSINESS_POLICY_REQUIRED";
+ public static final String VALIDITY_WINDOW_SOURCE = "PLAN_END_TIME";
private final TrainEnrollmentMapper enrollmentMapper;
private final TrainAccommodationMapper accommodationMapper;
@@ -53,9 +63,23 @@ public class AccessAuthorizationOrchestrator {
private final IExternalResourceMappingService resourceMappingService;
private final IExternalOperationService operationService;
private final ExternalCredentialGateway credentialGateway;
+ private TrainStudentMapper studentMapper;
+ private TrainClassPlanMapper classPlanMapper;
+ private DmsProperties dmsProperties;
protected AccessAuthorizationOrchestrator() {
- this(null, null, null, null, null, null, null);
+ this(null, null, null, null, null, null, null, null, null, null);
+ }
+
+ public AccessAuthorizationOrchestrator(TrainEnrollmentMapper enrollmentMapper,
+ TrainAccommodationMapper accommodationMapper,
+ TrainDormAuthMapper dormAuthMapper,
+ ITrainDormAuthService dormAuthService,
+ IExternalResourceMappingService resourceMappingService,
+ IExternalOperationService operationService,
+ ExternalCredentialGateway credentialGateway) {
+ this(enrollmentMapper, accommodationMapper, dormAuthMapper, dormAuthService,
+ resourceMappingService, operationService, credentialGateway, null, null, null);
}
@org.springframework.beans.factory.annotation.Autowired
@@ -65,7 +89,10 @@ public class AccessAuthorizationOrchestrator {
ITrainDormAuthService dormAuthService,
IExternalResourceMappingService resourceMappingService,
IExternalOperationService operationService,
- ExternalCredentialGateway credentialGateway) {
+ ExternalCredentialGateway credentialGateway,
+ @org.springframework.beans.factory.annotation.Autowired(required = false) TrainStudentMapper studentMapper,
+ @org.springframework.beans.factory.annotation.Autowired(required = false) TrainClassPlanMapper classPlanMapper,
+ @org.springframework.beans.factory.annotation.Autowired(required = false) DmsProperties dmsProperties) {
this.enrollmentMapper = enrollmentMapper;
this.accommodationMapper = accommodationMapper;
this.dormAuthMapper = dormAuthMapper;
@@ -73,6 +100,21 @@ public class AccessAuthorizationOrchestrator {
this.resourceMappingService = resourceMappingService;
this.operationService = operationService;
this.credentialGateway = credentialGateway;
+ this.studentMapper = studentMapper;
+ this.classPlanMapper = classPlanMapper;
+ this.dmsProperties = dmsProperties != null ? dmsProperties : new DmsProperties();
+ }
+
+ public void setStudentMapper(TrainStudentMapper studentMapper) {
+ this.studentMapper = studentMapper;
+ }
+
+ public void setClassPlanMapper(TrainClassPlanMapper classPlanMapper) {
+ this.classPlanMapper = classPlanMapper;
+ }
+
+ public void setDmsProperties(DmsProperties dmsProperties) {
+ this.dmsProperties = dmsProperties;
}
/**
@@ -86,16 +128,16 @@ public class AccessAuthorizationOrchestrator {
return ReconciliationResult.waiting("Missing enrollmentId");
}
- TrainEnrollment enrollment = enrollmentMapper.selectTrainEnrollmentById(enrollmentId);
+ TrainEnrollment enrollment = enrollmentMapper != null ? enrollmentMapper.selectTrainEnrollmentById(enrollmentId) : null;
if (enrollment == null) {
return ReconciliationResult.waiting("Enrollment not found: " + enrollmentId);
}
Integer enrollmentStatus = enrollment.getEnrollmentStatus();
- TrainAccommodation activeAcc = accommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId);
- List accHistory = accommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId);
+ TrainAccommodation activeAcc = accommodationMapper != null ? accommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId) : null;
+ List accHistory = accommodationMapper != null ? accommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId) : Collections.emptyList();
- // 1. 学员退训(3) 或 已完成(4):触发所有在用授权的注销 (REVOKE)
+ // 1. 学员退训(3) 或 已完成(4):触发所有历史/在用授权的注销 (REVOKE)
if (Integer.valueOf(3).equals(enrollmentStatus) || Integer.valueOf(4).equals(enrollmentStatus)) {
return handleEnrollmentTermination(enrollment, accHistory);
}
@@ -105,15 +147,14 @@ public class AccessAuthorizationOrchestrator {
return ReconciliationResult.waiting("Enrollment status is PENDING_REPORT (0); Grant prerequisites not met");
}
- // 3. 请假 (2):政策保持现状 (LEAVE_AUTO_REVOKE_POLICY = OPEN_QUESTION),不主动撤权
+ // 3. 请假 (2):政策保持现状,不主动撤权
if (Integer.valueOf(2).equals(enrollmentStatus)) {
return ReconciliationResult.noAction("Enrollment on leave (2); maintaining current authorization state");
}
- // 4. 已报到 (1):执行发放或调房检查
+ // 4. 已报到 (1):执行发放前置核对或调房后注销检查
if (Integer.valueOf(1).equals(enrollmentStatus)) {
if (activeAcc == null) {
- // 已报到但尚未分配有效宿舍
return ReconciliationResult.waiting("No active accommodation assigned for reported enrollment; waiting for assignment");
}
return handleGrantOrTransfer(enrollment, activeAcc, accHistory);
@@ -122,39 +163,88 @@ public class AccessAuthorizationOrchestrator {
return ReconciliationResult.noAction("No action required for enrollment " + enrollmentId);
}
+ /**
+ * 学员退训/结训注销处理 (C11: 遍历注销所有历史授权,独立处理,生成 BatchReconciliationResult)
+ */
private ReconciliationResult handleEnrollmentTermination(TrainEnrollment enrollment, List accHistory) {
+ BatchReconciliationResult batch = new BatchReconciliationResult(enrollment.getEnrollmentId());
+
if (accHistory == null || accHistory.isEmpty()) {
- return ReconciliationResult.noAction("No accommodation history found for terminated enrollment " + enrollment.getEnrollmentId());
+ batch.setOutcome(BatchReconciliationResult.OUTCOME_ALL_SETTLED);
+ return ReconciliationResult.batch(batch);
}
- // 查找所有非 REVOKED 的授权进行注销
+ // 遍历所有相关住宿记录的授权
for (TrainAccommodation acc : accHistory) {
- TrainDormAuth auth = dormAuthMapper.selectTrainDormAuthByAccommodationId(acc.getAccommodationId());
- if (auth != null && !TrainDormAuth.AUTH_STATUS_REVOKED.equals(auth.getAuthStatus())) {
- log.info("Terminating authorization for enrollment {} accommodation {}: authId={}",
- enrollment.getEnrollmentId(), acc.getAccommodationId(), auth.getAuthId());
- return executeRevokeFlow(auth, "EnrollmentTerminated:" + enrollment.getEnrollmentStatus());
+ 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";
+ }
+ 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);
}
}
- return ReconciliationResult.noAction("All authorizations already REVOKED for terminated enrollment " + enrollment.getEnrollmentId());
+ batch.computeOutcome();
+ return ReconciliationResult.batch(batch);
}
+ /**
+ * 住宿分配/报到后授权处理 (C12 方案 B: 自动事件只创建本地记录为 PENDING_GRANT,返回 AWAITING_MANUAL_ISSUE)
+ */
private ReconciliationResult handleGrantOrTransfer(TrainEnrollment enrollment,
TrainAccommodation activeAcc,
List accHistory) {
Long accommodationId = activeAcc.getAccommodationId();
String dormId = activeAcc.getDormId();
- TrainDormAuth currentAuth = dormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId);
+ TrainDormAuth currentAuth = dormAuthMapper != null ? dormAuthMapper.selectTrainDormAuthByAccommodationId(accommodationId) : null;
// 如果新住宿的授权已经生效 (ACTIVE)
if (currentAuth != null && TrainDormAuth.AUTH_STATUS_ACTIVE.equals(currentAuth.getAuthStatus())) {
- // 调房场景第二阶段检查:新房已 ACTIVE,检查是否存在历史退宿的残留旧授权需要注销 (GRANT_NEW_THEN_REVOKE_OLD)
- TrainDormAuth oldAuthToRevoke = findStaleActiveAuthInHistory(accommodationId, accHistory);
- if (oldAuthToRevoke != null) {
- log.info("New room access ACTIVE. Triggering delayed revoke for old accommodation auth: {}", oldAuthToRevoke.getAuthId());
- return executeRevokeFlow(oldAuthToRevoke, "TransferCompleted");
+ // 调房场景:新房已 ACTIVE,遍历检查所有历史住宿的残留授权并执行注销 (C11: 遍历全部,不单项早退)
+ 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");
}
return ReconciliationResult.noAction("Door authorization is already ACTIVE for accommodation " + accommodationId);
}
@@ -165,12 +255,12 @@ public class AccessAuthorizationOrchestrator {
}
// 校验外部映射与主锁策略 (PRIMARY_LOCK_ONLY)
- TrainExternalResourceMapping roomMapping = resourceMappingService.getActiveRoomMapping(VENDOR_DMS, dormId);
+ TrainExternalResourceMapping roomMapping = resourceMappingService != null ? resourceMappingService.getActiveRoomMapping(VENDOR_DMS, dormId) : null;
if (roomMapping == null) {
return ReconciliationResult.waiting("No ACTIVE room mapping found for dormId " + dormId);
}
- List lockMappings = resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId);
+ List lockMappings = resourceMappingService != null ? resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId) : null;
if (lockMappings == null || lockMappings.isEmpty()) {
return ReconciliationResult.waiting("No door locks mapped for dormId " + dormId);
}
@@ -186,32 +276,132 @@ public class AccessAuthorizationOrchestrator {
return ReconciliationResult.waiting("Dorm " + dormId + " has " + primaryLocks.size() + " primary locks configured; configuration error");
}
- TrainExternalResourceMapping primaryLock = primaryLocks.get(0);
-
- // 推导有效期窗口 (VALIDITY_WINDOW_SOURCE = BUSINESS_POLICY_REQUIRED)
- Date validStart = activeAcc.getAssignedTime() != null ? activeAcc.getAssignedTime() : new Date();
- Date validUntil = deriveDefaultValidUntil(validStart);
-
- return executeGrantFlow(enrollment, activeAcc, primaryLock, currentAuth, validStart, validUntil);
- }
-
- private ReconciliationResult executeGrantFlow(TrainEnrollment enrollment,
- TrainAccommodation activeAcc,
- TrainExternalResourceMapping primaryLock,
- TrainDormAuth dormAuth,
- Date validStart,
- Date validUntil) {
- Long accommodationId = activeAcc.getAccommodationId();
-
- // 步骤 1: 确保本地授权记录存在 (短本地事务)
- if (dormAuth == null) {
- dormAuth = dormAuthService.ensureAuthorizationForAccommodation(accommodationId, "Orchestrator");
+ // 确保本地授权记录存在 (短本地事务,初始为 PENDING_GRANT)
+ if (currentAuth == null && dormAuthService != null) {
+ currentAuth = dormAuthService.ensureAuthorizationForAccommodation(accommodationId, "Orchestrator");
}
- Long authId = dormAuth.getAuthId();
- Integer version = dormAuth.getOperationVersion() != null ? dormAuth.getOperationVersion() : 1;
+ // 推导真实有效期并回填本地授权审计 (C09: 真实查询开班计划 end_time,禁止 +30 天)
+ if (currentAuth != null && classPlanMapper != null && enrollment.getPlanId() != null) {
+ TrainClassPlan plan = classPlanMapper.selectTrainClassPlanByPlanId(enrollment.getPlanId());
+ Date now = new Date();
+ Date validStart = activeAcc.getAssignedTime() != null && activeAcc.getAssignedTime().after(now) ? activeAcc.getAssignedTime() : now;
+ Date validUntil = plan != null ? plan.getEndTime() : null;
- // 步骤 2: 创建外部操作流水 (短本地事务,幂等键保护)
+ if (validUntil != null && validUntil.after(validStart)) {
+ currentAuth.setAuthStart(validStart);
+ currentAuth.setAuthEnd(validUntil);
+ dormAuthService.updateTrainDormAuth(currentAuth);
+ }
+ }
+
+ // C12 方案 B: 自动流程绝不向 DMS 下发密码,授权保持 PENDING_GRANT,返回 AWAITING_MANUAL_ISSUE
+ Long authId = currentAuth != null ? currentAuth.getAuthId() : null;
+ log.info("Prerequisites verified for accommodation {} (authId={}). Returning AWAITING_MANUAL_ISSUE", accommodationId, authId);
+ return ReconciliationResult.awaitingManualIssue(authId, "Prerequisites met; waiting for administrator manual issue");
+ }
+
+ /**
+ * 管理员显式下发门禁凭据 (交互端点专用,一次性返回明文密码)
+ *
+ * @param authId 门禁授权ID
+ * @param operator 操作人
+ * @return 下发结果 VO (包含一次性明文密码)
+ */
+ public IssueResultVO issueAuthorization(Long authId, String operator) {
+ if (authId == null) {
+ throw new ServiceException("authId 不能为空");
+ }
+
+ TrainDormAuth auth = dormAuthMapper != null ? dormAuthMapper.selectTrainDormAuthByAuthId(authId) : null;
+ if (auth == null) {
+ throw new ServiceException("门禁授权记录不存在: " + authId);
+ }
+
+ // 1. Issue 幂等防重守卫 (Req 7): 必须阻止 GRANTING 和 ACTIVE 再次执行 issue
+ String currentStatus = auth.getAuthStatus();
+ if (TrainDormAuth.AUTH_STATUS_ACTIVE.equals(currentStatus) || TrainDormAuth.AUTH_STATUS_GRANTING.equals(currentStatus)) {
+ throw new ServiceException("当前授权处于 " + currentStatus + " 状态,禁止重复下发凭据 (Idempotent Guard)");
+ }
+ if (TrainDormAuth.AUTH_STATUS_REVOKED.equals(currentStatus) || TrainDormAuth.AUTH_STATUS_REVOKING.equals(currentStatus)) {
+ 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() + "),为防止制造孤儿凭据,禁止盲目重发。需先核实或注销旧凭据。");
+ }
+ }
+
+ // 3. 关联实体获取
+ TrainAccommodation acc = accommodationMapper != null ? accommodationMapper.selectTrainAccommodationByAccommodationId(auth.getAccommodationId()) : null;
+ if (acc == null) {
+ throw new ServiceException("关联住宿安排不存在: " + auth.getAccommodationId());
+ }
+
+ TrainEnrollment enrollment = enrollmentMapper != null ? enrollmentMapper.selectTrainEnrollmentById(acc.getEnrollmentId()) : null;
+ if (enrollment == null) {
+ throw new ServiceException("关联学员报名档案不存在: " + acc.getEnrollmentId());
+ }
+
+ // 4. C10 真实手机号核验:严禁伪造手机号,唯一来源为 TrainStudent.sPhone
+ String phoneNumber = null;
+ if (studentMapper != null && enrollment.getStudentId() != null) {
+ TrainStudent student = studentMapper.selectTrainStudentBySId(enrollment.getStudentId());
+ if (student != null) {
+ phoneNumber = 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));
+ return new IssueResultVO(authId, null, null, null, "WAITING_FOR_PREREQUISITE", "MISSING_STUDENT_PHONE: 学员主档缺失有效手机号,暂无法下发门禁");
+ }
+
+ // 5. 门锁配置与主锁策略检查 (PRIMARY_LOCK_ONLY)
+ String dormId = acc.getDormId();
+ TrainExternalResourceMapping roomMapping = resourceMappingService != null ? resourceMappingService.getActiveRoomMapping(VENDOR_DMS, dormId) : null;
+ if (roomMapping == null) {
+ return new IssueResultVO(authId, null, null, null, "WAITING_FOR_PREREQUISITE", "未找到宿舍 " + dormId + " 的有效房间映射");
+ }
+
+ List lockMappings = resourceMappingService != null ? resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId) : null;
+ List primaryLocks = lockMappings != null ? lockMappings.stream()
+ .filter(m -> Integer.valueOf(1).equals(m.getIsPrimary()))
+ .collect(Collectors.toList()) : Collections.emptyList();
+ if (primaryLocks.size() != 1) {
+ return new IssueResultVO(authId, null, null, null, "WAITING_FOR_PREREQUISITE", "宿舍主门锁未配置或存在多把主锁 (Primary lock count = " + primaryLocks.size() + ")");
+ }
+ TrainExternalResourceMapping primaryLock = primaryLocks.get(0);
+
+ // 6. C09 有效期窗口推导:以培训计划 end_time 为准,严禁 +30 天
+ TrainClassPlan plan = classPlanMapper != null && enrollment.getPlanId() != null ? classPlanMapper.selectTrainClassPlanByPlanId(enrollment.getPlanId()) : null;
+ Date now = new Date();
+ Date validStart = acc.getAssignedTime() != null && acc.getAssignedTime().after(now) ? acc.getAssignedTime() : now;
+ Date validUntil = plan != null ? plan.getEndTime() : null;
+
+ if (validUntil == null || !validUntil.after(validStart)) {
+ log.warn("Cannot issue credential for auth {}: invalid validity window (validStart={}, validUntil={})", authId, validStart, validUntil);
+ return new IssueResultVO(authId, null, null, null, "WAITING_FOR_PREREQUISITE", "INVALID_VALIDITY_WINDOW: 培训计划结束时间缺失或早于生效开始时间,无法确定凭据有效期窗口");
+ }
+
+ // 回填本地审计时间
+ auth.setAuthStart(validStart);
+ auth.setAuthEnd(validUntil);
+ if (dormAuthService != null) {
+ dormAuthService.updateTrainDormAuth(auth);
+ }
+
+ // 7. DMS disabled 检查 (Req 12)
+ if (dmsProperties != null && !dmsProperties.isEnabled()) {
+ return new IssueResultVO(authId, null, null, null, "WAITING_FOR_PREREQUISITE", "DMS_DISABLED: 共享运行环境门禁对接未启用 (dms.enabled=false),无法向外部厂商下发门禁凭据");
+ }
+
+ // 8. 内存生成 6 位强随机数字密码
+ String plaintextPassword = SecurePasswordGenerator.generateDefaultPassword();
+
+ // 9. 创建外部操作流水 (短本地事务,幂等键保护)
+ Integer version = (auth.getOperationVersion() != null ? auth.getOperationVersion() : 1) + 1;
String idempotencyKey = "GRANT:AUTH:" + authId + ":V" + version;
TrainExternalOperation op = new TrainExternalOperation();
op.setDoorAuthId(authId);
@@ -221,15 +411,17 @@ public class AccessAuthorizationOrchestrator {
op.setIdempotencyKey(idempotencyKey);
op.setExternalRequestType(1); // 1: 下发密码
op.setExternalHardwareId(primaryLock.getExternalHardwareId());
- op.setCreateBy("Orchestrator");
+ op.setCreateBy(operator != null ? operator : "admin");
- TrainExternalOperation createdOp = operationService.createOperation(op);
- Long operationId = createdOp.getOperationId();
+ TrainExternalOperation createdOp = operationService != null ? operationService.createOperation(op) : null;
+ Long operationId = createdOp != null ? createdOp.getOperationId() : null;
- // 步骤 3: 标记授权记录进入 GRANTING 状态并记录本次操作ID
- dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_GRANTING, operationId, "Orchestrator");
+ // 10. 标记授权记录进入 GRANTING
+ if (dormAuthService != null) {
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_GRANTING, operationId, operator);
+ }
- // 步骤 4: 构造请求并发起远程凭据下发 (非数据库长事务,LONG_DB_TRANSACTION_AROUND_HTTP = NO)
+ // 11. 调用外部厂商接口
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
Long lockId = null;
try {
@@ -237,49 +429,135 @@ public class AccessAuthorizationOrchestrator {
} catch (Exception e) {
log.error("Invalid lockId format: {}", primaryLock.getExternalResourceId());
}
- String holder = enrollment.getStudentId() != null ? enrollment.getStudentId() : "Student";
- String phoneNumber = "13800000000"; // 缺省测试手机号,后续由业务拓展补充
-
- // 内存中生成 6 位强随机数字密码
- String plaintextPassword = SecurePasswordGenerator.generateDefaultPassword();
try {
DmsPasswordCreateResponse resp = credentialGateway.issueOnlinePassword(
- lockId, holder, phoneNumber, plaintextPassword, sdf.format(validStart), sdf.format(validUntil));
+ lockId, enrollment.getStudentId(), phoneNumber, plaintextPassword,
+ sdf.format(validStart), sdf.format(validUntil));
- // 步骤 5: 成功获取 Vendor Accepted 响应,回填外部流水号并推进为 ACCEPTED
String extOpIdStr = resp.getOperationId() != null ? String.valueOf(resp.getOperationId()) : null;
String extKeyIdStr = resp.getId() != null ? String.valueOf(resp.getId()) : null;
- operationService.bindExternalReference(operationId, extOpIdStr, resp.getLockHardwareId(), extKeyIdStr, "Orchestrator");
- operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, "Orchestrator");
-
- // 回填授权记录上的 externalAuthId (用于后续撤销定位凭据)
- if (extKeyIdStr != null) {
- dormAuth.setExternalAuthId(extKeyIdStr);
- dormAuthService.updateTrainDormAuth(dormAuth);
+ if (operationService != null && operationId != null) {
+ operationService.bindExternalReference(operationId, extOpIdStr, resp.getLockHardwareId(), extKeyIdStr, operator);
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, operator);
}
- log.info("DMS password create accepted: localOpId={}, extOpId={}", operationId, extOpIdStr);
- ReconciliationResult grantResult = ReconciliationResult.grantRequired(authId, operationId, true, true, "Password create accepted by DMS; awaiting poller");
- grantResult.setOneTimePlaintextCredential(plaintextPassword);
- return grantResult;
+ if (extKeyIdStr != null && dormAuthService != null) {
+ auth.setExternalAuthId(extKeyIdStr);
+ dormAuthService.updateTrainDormAuth(auth);
+ }
+
+ // 成功后清空历史错误 (Req 16)
+ if (dormAuthMapper != null) {
+ dormAuthMapper.clearLastError(authId);
+ }
+
+ log.info("DMS password create accepted for authId {}: localOpId={}, extOpId={}", authId, operationId, extOpIdStr);
+
+ return new IssueResultVO(
+ authId,
+ plaintextPassword,
+ sdf.format(validStart),
+ sdf.format(validUntil),
+ "ACCEPTED",
+ "凭据已提交下发,设备确认后生效。"
+ );
} catch (Exception e) {
- // 外部调用异常 (如读超时、网络异常),严格捕获,绝不回滚业务主事务!
- log.error("DMS password create failed or timed out for authId {}: {}", authId, e.getMessage());
- operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.getMessage()), "Orchestrator");
- dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, "Orchestrator");
-
- return ReconciliationResult.grantRequired(authId, operationId, true, false, "External gateway call failed: " + sanitize(e.getMessage()));
+ log.error("DMS password create failed for authId {}: {}", authId, sanitize(e.getMessage()));
+ if (operationService != null && operationId != null) {
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.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());
+ }
+ throw new ServiceException("门禁凭据下发失败: " + sanitize(e.getMessage()));
}
}
- private ReconciliationResult executeRevokeFlow(TrainDormAuth auth, String reason) {
- Long authId = auth.getAuthId();
- Integer version = (auth.getOperationVersion() != null ? auth.getOperationVersion() : 1) + 1;
+ /**
+ * 管理员显式触发注销门禁授权
+ *
+ * @param authId 授权ID
+ * @param operator 操作人
+ * @return 注销执行结果
+ */
+ public ReconciliationResult revokeAuthorization(Long authId, String operator) {
+ if (authId == null) {
+ throw new ServiceException("authId 不能为空");
+ }
+ TrainDormAuth auth = dormAuthMapper != null ? dormAuthMapper.selectTrainDormAuthByAuthId(authId) : null;
+ if (auth == null) {
+ throw new ServiceException("门禁授权记录不存在: " + authId);
+ }
+ return executeRevokeFlow(auth, "ManualRevoke", operator != null ? operator : "admin");
+ }
- // 步骤 1: 创建外部操作流水 (REVOKE)
+ /**
+ * 单项授权注销核心流程 (安全守卫 + 事务边界隔离)
+ */
+ private ReconciliationResult executeRevokeFlow(TrainDormAuth auth, String reason, String operator) {
+ Long authId = auth.getAuthId();
+
+ // 1. DMS disabled 守卫 (Req 12): 关闭时不得发送 HTTP
+ if (dmsProperties != null && !dmsProperties.isEnabled()) {
+ if (auth.getExternalAuthId() == null || auth.getExternalAuthId().trim().isEmpty()) {
+ // 从未下发过外部凭据,本地可直接安全标记为 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,保持待处理状态
+ 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");
+ }
+ }
+
+ // 2. Revoke 安全守卫 (Req 11): 严禁 externalAuthId == null -> revoke keyId 0
+ if (auth.getExternalAuthId() == null || auth.getExternalAuthId().trim().isEmpty()) {
+ if (TrainDormAuth.AUTH_STATUS_PENDING_GRANT.equals(auth.getAuthStatus())) {
+ // 本地待授权草稿,未曾向厂商下发过
+ if (dormAuthService != null) {
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_REVOKED, null, operator);
+ }
+ return ReconciliationResult.noAction("Draft auth never issued to external system; marked REVOKED locally");
+ }
+ // 处于可能已下发的状态但缺失 externalAuthId:严禁发 delete(0),进入明确 ERROR
+ log.warn("Cannot revoke auth {}: externalAuthId is null/empty. Blocking revoke(0)", authId);
+ 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缺失,禁止调用delete(0),需人工核对厂商侧凭据", operator, new Date());
+ }
+ return ReconciliationResult.failure("UNRESOLVABLE_EXTERNAL_CREDENTIAL: Missing externalAuthId for auth " + authId + "; manual review required");
+ }
+
+ Long externalKeyId;
+ try {
+ externalKeyId = Long.parseLong(auth.getExternalAuthId().trim());
+ } catch (Exception e) {
+ log.error("Invalid externalAuthId format on auth {}: {}", authId, auth.getExternalAuthId());
+ 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)
+ Integer version = (auth.getOperationVersion() != null ? auth.getOperationVersion() : 1) + 1;
String idempotencyKey = "REVOKE:AUTH:" + authId + ":V" + version;
TrainExternalOperation op = new TrainExternalOperation();
op.setDoorAuthId(authId);
@@ -288,64 +566,81 @@ public class AccessAuthorizationOrchestrator {
op.setOperationStatus(TrainExternalOperation.STATUS_CREATED);
op.setIdempotencyKey(idempotencyKey);
op.setExternalRequestType(3); // 3: 删除密码
- op.setCreateBy("Orchestrator");
+ op.setNextPollTime(new Date());
+ op.setCreateBy(operator);
- TrainExternalOperation createdOp = operationService.createOperation(op);
- Long operationId = createdOp.getOperationId();
+ TrainExternalOperation createdOp = operationService != null ? operationService.createOperation(op) : null;
+ Long operationId = createdOp != null ? createdOp.getOperationId() : null;
- // 步骤 2: 推进授权状态为 REVOKING
- dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_REVOKING, operationId, "Orchestrator");
-
- // 步骤 3: 远程调用删除密码
- Long externalKeyId = null;
- if (auth.getExternalAuthId() != null && !auth.getExternalAuthId().trim().isEmpty()) {
- try {
- externalKeyId = Long.parseLong(auth.getExternalAuthId().trim());
- } catch (Exception ignored) {}
+ if (dormAuthService != null) {
+ dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_REVOKING, operationId, operator);
}
+ // 4. 事务边界控制 (Req 17: 业务事务内绝不发起远程 HTTP)
+ if (TransactionSynchronizationManager.isActualTransactionActive()) {
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() {
+ @Override
+ public void afterCommit() {
+ dispatchRevokeCall(authId, operationId, externalKeyId, operator);
+ }
+ });
+ return ReconciliationResult.revokeRequired(authId, operationId, false, true, "Revoke operation created; dispatching after transaction commit");
+ } else {
+ return dispatchRevokeCall(authId, operationId, externalKeyId, operator);
+ }
+ }
+
+ /**
+ * 派发远程撤销 HTTP 请求 (事务外执行)
+ */
+ public ReconciliationResult dispatchRevokeCall(Long authId, Long operationId, Long externalKeyId, String operator) {
try {
- DmsPasswordDeleteResponse resp = credentialGateway.revokeOnlinePassword(externalKeyId != null ? externalKeyId : 0L);
+ DmsPasswordDeleteResponse resp = credentialGateway.revokeOnlinePassword(externalKeyId);
String extOpIdStr = resp.getOperationId() != null ? String.valueOf(resp.getOperationId()) : null;
- operationService.bindExternalReference(operationId, extOpIdStr, null, null, "Orchestrator");
- operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, "Orchestrator");
+ if (operationService != null && operationId != null) {
+ operationService.bindExternalReference(operationId, extOpIdStr, null, null, operator);
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_ACCEPTED, null, null, operator);
+ }
log.info("DMS password delete accepted: localOpId={}, extOpId={}", operationId, extOpIdStr);
return ReconciliationResult.revokeRequired(authId, operationId, true, true, "Password delete accepted by DMS; awaiting poller");
} catch (Exception e) {
- log.error("DMS password delete failed for authId {}: {}", authId, e.getMessage());
- operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.getMessage()), "Orchestrator");
- dormAuthService.updateAuthStatus(authId, TrainDormAuth.AUTH_STATUS_ERROR, operationId, "Orchestrator");
-
+ log.error("DMS password delete failed for authId {}: {}", authId, sanitize(e.getMessage()));
+ if (operationService != null && operationId != null) {
+ operationService.transitStatus(operationId, TrainExternalOperation.STATUS_FAILED, "GATEWAY_ERROR", sanitize(e.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 failed: " + sanitize(e.getMessage()));
}
}
-
- private TrainDormAuth findStaleActiveAuthInHistory(Long currentAccId, List history) {
- if (history == null) return null;
+ private List findStaleActiveAuthsInHistory(Long currentAccId, List history) {
+ if (history == null) return Collections.emptyList();
+ List staleList = new ArrayList<>();
for (TrainAccommodation acc : history) {
- if (!acc.getAccommodationId().equals(currentAccId)) {
- TrainDormAuth oldAuth = dormAuthMapper.selectTrainDormAuthByAccommodationId(acc.getAccommodationId());
- if (oldAuth != null && TrainDormAuth.AUTH_STATUS_ACTIVE.equals(oldAuth.getAuthStatus())) {
- return oldAuth;
+ if (acc != null && acc.getAccommodationId() != null && !acc.getAccommodationId().equals(currentAccId)) {
+ TrainDormAuth oldAuth = dormAuthMapper != null ? dormAuthMapper.selectTrainDormAuthByAccommodationId(acc.getAccommodationId()) : null;
+ if (oldAuth != null && !TrainDormAuth.AUTH_STATUS_REVOKED.equals(oldAuth.getAuthStatus())) {
+ staleList.add(oldAuth);
}
}
}
- return null;
- }
-
- private Date deriveDefaultValidUntil(Date start) {
- Calendar cal = Calendar.getInstance();
- cal.setTime(start);
- cal.add(Calendar.DAY_OF_YEAR, 30); // 默认 30 天
- return cal.getTime();
+ return staleList;
}
private String sanitize(String str) {
if (str == null) return null;
- return str.replaceAll("(?i)(password|token|secret)=[^&\\s]+", "$1=[PROTECTED]");
+ 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("(?
+ * 终态准则:
+ * 1. ALL_SETTLED: 所有相关授权均已确认进入终态 (REVOKED) 或明确无需任何操作 (NO_ACTION);
+ * 2. IN_PROGRESS: 存在已向外部派发但仍在异步处理/轮询中的授权操作;
+ * 3. PARTIAL_FAILURE: 部分授权成功/已处理,部分授权出现不可恢复异常;
+ * 4. 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_PARTIAL_FAILURE = "PARTIAL_FAILURE";
+ public static final String OUTCOME_ALL_FAILED = "ALL_FAILED";
+
+ private Long enrollmentId;
+ private List items = new ArrayList<>();
+ private int total;
+ private int dispatched;
+ private int noAction;
+ private int failed;
+ private String outcome;
+
+ public BatchReconciliationResult() {
+ }
+
+ public BatchReconciliationResult(Long enrollmentId) {
+ this.enrollmentId = enrollmentId;
+ }
+
+ public void addItem(ReconciliationItemVO item) {
+ if (this.items == null) {
+ this.items = new ArrayList<>();
+ }
+ this.items.add(item);
+ }
+
+ public void computeOutcome() {
+ this.total = this.items != null ? this.items.size() : 0;
+ if (this.total == 0) {
+ this.outcome = OUTCOME_ALL_SETTLED;
+ return;
+ }
+
+ if (this.failed > 0) {
+ if (this.failed == this.total) {
+ this.outcome = OUTCOME_ALL_FAILED;
+ } else {
+ this.outcome = OUTCOME_PARTIAL_FAILURE;
+ }
+ } else if (this.dispatched > 0) {
+ this.outcome = OUTCOME_IN_PROGRESS;
+ } else {
+ this.outcome = OUTCOME_ALL_SETTLED;
+ }
+ }
+
+ public Long getEnrollmentId() {
+ return enrollmentId;
+ }
+
+ public void setEnrollmentId(Long enrollmentId) {
+ this.enrollmentId = enrollmentId;
+ }
+
+ public List getItems() {
+ return items;
+ }
+
+ public void setItems(List items) {
+ this.items = items;
+ }
+
+ public int getTotal() {
+ return total;
+ }
+
+ public void setTotal(int total) {
+ this.total = total;
+ }
+
+ public int getDispatched() {
+ return dispatched;
+ }
+
+ public void setDispatched(int dispatched) {
+ this.dispatched = dispatched;
+ }
+
+ public int getNoAction() {
+ return noAction;
+ }
+
+ public void setNoAction(int noAction) {
+ this.noAction = noAction;
+ }
+
+ public int getFailed() {
+ return failed;
+ }
+
+ public void setFailed(int failed) {
+ this.failed = failed;
+ }
+
+ public String getOutcome() {
+ return outcome;
+ }
+
+ public void setOutcome(String outcome) {
+ this.outcome = outcome;
+ }
+
+ @Override
+ public String toString() {
+ return "BatchReconciliationResult{" +
+ "enrollmentId=" + enrollmentId +
+ ", total=" + total +
+ ", dispatched=" + dispatched +
+ ", noAction=" + noAction +
+ ", failed=" + failed +
+ ", 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 1f33fc665..7e5cd34a2 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
@@ -22,5 +22,10 @@ public enum ReconciliationDecision {
/**
* 等待前置业务条件 (未报到、未分房、无门锁映射等)
*/
- WAITING_FOR_PREREQUISITE
+ WAITING_FOR_PREREQUISITE,
+
+ /**
+ * 满足发放条件,等待管理员手动触发凭据下发 (Package A / Option B)
+ */
+ AWAITING_MANUAL_ISSUE
}
diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationItemVO.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationItemVO.java
new file mode 100644
index 000000000..cd605b972
--- /dev/null
+++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/integration/dms/orchestration/ReconciliationItemVO.java
@@ -0,0 +1,78 @@
+package com.ruoyi.train.integration.dms.orchestration;
+
+import java.io.Serializable;
+
+/**
+ * 批量门禁对账单项执行结果
+ */
+public class ReconciliationItemVO implements Serializable {
+ private static final long serialVersionUID = 1L;
+
+ private Long authId;
+ private Long accommodationId;
+ private String action;
+ private String status;
+ private String message;
+
+ public ReconciliationItemVO() {
+ }
+
+ public ReconciliationItemVO(Long authId, Long accommodationId, String action, String status, String message) {
+ this.authId = authId;
+ this.accommodationId = accommodationId;
+ this.action = action;
+ this.status = status;
+ this.message = message;
+ }
+
+ public Long getAuthId() {
+ return authId;
+ }
+
+ public void setAuthId(Long authId) {
+ this.authId = authId;
+ }
+
+ public Long getAccommodationId() {
+ return accommodationId;
+ }
+
+ public void setAccommodationId(Long accommodationId) {
+ this.accommodationId = accommodationId;
+ }
+
+ public String getAction() {
+ return action;
+ }
+
+ public void setAction(String action) {
+ this.action = action;
+ }
+
+ public String getStatus() {
+ return status;
+ }
+
+ public void setStatus(String status) {
+ this.status = status;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ @Override
+ public String toString() {
+ return "ReconciliationItemVO{" +
+ "authId=" + authId +
+ ", accommodationId=" + accommodationId +
+ ", action='" + action + '\'' +
+ ", status='" + status + '\'' +
+ ", message='" + message + '\'' +
+ '}';
+ }
+}
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 cd677728c..77799f7be 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
@@ -35,6 +35,45 @@ public class ReconciliationResult implements Serializable {
return res;
}
+ public static ReconciliationResult failure(String reason) {
+ ReconciliationResult res = new ReconciliationResult();
+ res.setDecision(ReconciliationDecision.WAITING_FOR_PREREQUISITE);
+ res.setReason(reason);
+ res.setSuccess(false);
+ return res;
+ }
+
+ public static ReconciliationResult awaitingManualIssue(Long doorAuthId, String reason) {
+ ReconciliationResult res = new ReconciliationResult();
+ res.setDecision(ReconciliationDecision.AWAITING_MANUAL_ISSUE);
+ res.setDoorAuthId(doorAuthId);
+ res.setReason(reason);
+ res.setExternalInvoked(false);
+ res.setSuccess(true);
+ return res;
+ }
+
+ public static ReconciliationResult revoking(String reason) {
+ ReconciliationResult res = new ReconciliationResult();
+ res.setDecision(ReconciliationDecision.REVOKE_REQUIRED);
+ res.setReason(reason);
+ res.setSuccess(true);
+ return res;
+ }
+
+ public static ReconciliationResult batch(BatchReconciliationResult batchResult) {
+ ReconciliationResult res = new ReconciliationResult();
+ res.setBatchResult(batchResult);
+ if (batchResult != null) {
+ res.setEnrollmentId(batchResult.getEnrollmentId());
+ res.setDecision(ReconciliationDecision.REVOKE_REQUIRED);
+ res.setReason("Batch reconciliation outcome: " + batchResult.getOutcome());
+ res.setSuccess(!BatchReconciliationResult.OUTCOME_ALL_FAILED.equals(batchResult.getOutcome()));
+ res.setExternalInvoked(batchResult.getDispatched() > 0);
+ }
+ return res;
+ }
+
public static ReconciliationResult grantRequired(Long doorAuthId, Long operationId, boolean externalInvoked, boolean success, String reason) {
ReconciliationResult res = new ReconciliationResult();
res.setDecision(ReconciliationDecision.GRANT_REQUIRED);
@@ -57,6 +96,40 @@ public class ReconciliationResult implements Serializable {
return res;
}
+ private BatchReconciliationResult batchResult;
+
+ public BatchReconciliationResult getBatchResult() {
+ return batchResult;
+ }
+
+ public void setBatchResult(BatchReconciliationResult batchResult) {
+ this.batchResult = batchResult;
+ }
+
+ public String getOutcome() {
+ return batchResult != null ? batchResult.getOutcome() : null;
+ }
+
+ public java.util.List getItems() {
+ return batchResult != null ? batchResult.getItems() : null;
+ }
+
+ public int getTotal() {
+ return batchResult != null ? batchResult.getTotal() : 0;
+ }
+
+ public int getDispatched() {
+ return batchResult != null ? batchResult.getDispatched() : 0;
+ }
+
+ public int getNoAction() {
+ return batchResult != null ? batchResult.getNoAction() : 0;
+ }
+
+ public int getFailed() {
+ return batchResult != null ? batchResult.getFailed() : 0;
+ }
+
public Long getEnrollmentId() {
return enrollmentId;
}
diff --git a/build_ui/src/api/train/access.js b/build_ui/src/api/train/access.js
index ea1e9a33f..468560e1d 100644
--- a/build_ui/src/api/train/access.js
+++ b/build_ui/src/api/train/access.js
@@ -66,3 +66,19 @@ export function manualReconcile(enrollmentId) {
method: 'post'
})
}
+
+// 管理员显式下发门禁凭据 (单次返回明文凭据)
+export function issueAuth(authId) {
+ return request({
+ url: '/train/access/auth/' + authId + '/issue',
+ method: 'post'
+ })
+}
+
+// 管理员显式注销门禁授权
+export function revokeAuth(authId) {
+ return request({
+ url: '/train/access/auth/' + authId + '/revoke',
+ method: 'post'
+ })
+}
diff --git a/build_ui/src/router/index.js b/build_ui/src/router/index.js
index ef887b6c2..308b61aab 100644
--- a/build_ui/src/router/index.js
+++ b/build_ui/src/router/index.js
@@ -107,14 +107,14 @@ export const dynamicRoutes = [
component: Layout,
hidden: true,
redirect: '/train/auth',
- permissions: ['system:auth:list', 'train:accommodation:list'],
+ permissions: ['train:access:query', 'system:auth:list', 'train:accommodation:list'],
children: [
{
path: 'auth',
component: () => import('@/views/train/auth/index'),
name: 'DoorAuth',
meta: { title: '门禁授权', icon: 'lock' },
- permissions: ['system:auth:list']
+ permissions: ['train:access:query', 'system:auth:list']
},
{
path: 'accommodation',
diff --git a/build_ui/src/views/train/auth/index.vue b/build_ui/src/views/train/auth/index.vue
index 1f4acf50e..d91beeee7 100644
--- a/build_ui/src/views/train/auth/index.vue
+++ b/build_ui/src/views/train/auth/index.vue
@@ -67,6 +67,12 @@
+
+
+ {{ scope.row.studentPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') }}
+ 缺手机号
+
+
{{ scope.row.dormBuilding || '' }} {{ scope.row.dormRoom || '' }} ({{ scope.row.bedNo || '-' }}床)
@@ -108,7 +114,7 @@
-
+
手动对账
+
+
+
+ 下发门禁凭据
+
+
+ 下发门禁凭据
+