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 @@ + + +