修复门禁:安全化凭据下发与有效期来源

This commit is contained in:
wangzhibiao committed 2026-09-21 12:51:10 +08:00
1 parent a83a3a642e
commit 0d5df72d8e
10 files changed
+908 -142

No files matched your search

@@ -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)
@@ -0,0 +1,94 @@
package com.ruoyi.train.integration.dms.dto;
import java.io.Serializable;
/**
* 门禁凭据显式下发响应对象 (Wave 3B / Package A)
* <p>
* 安全红线:
* 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 + '\'' +
'}';
}
}
@@ -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)
* <p>
* 核心架构与安全准则:
* 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<TrainAccommodation> accHistory = accommodationMapper.selectAccommodationHistoryByEnrollmentId(enrollmentId);
TrainAccommodation activeAcc = accommodationMapper != null ? accommodationMapper.selectActiveAccommodationByEnrollmentId(enrollmentId) : null;
List<TrainAccommodation> 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<TrainAccommodation> 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<TrainAccommodation> 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<TrainDormAuth> staleAuths = findStaleActiveAuthsInHistory(accommodationId, accHistory);
if (!staleAuths.isEmpty()) {
log.info("New room access ACTIVE. Triggering revokes for {} stale accommodation auths", staleAuths.size());
for (TrainDormAuth oldAuth : staleAuths) {
try {
executeRevokeFlow(oldAuth, "TransferCompleted", "Orchestrator");
} catch (Exception e) {
log.error("Failed to revoke stale auth {}: {}", oldAuth.getAuthId(), e.getMessage());
}
}
return ReconciliationResult.revoking("Triggered revokes for " + staleAuths.size() + " stale accommodation auths");
}
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<TrainExternalResourceMapping> lockMappings = resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId);
List<TrainExternalResourceMapping> 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<TrainExternalResourceMapping> lockMappings = resourceMappingService != null ? resourceMappingService.getActiveLockMappings(VENDOR_DMS, dormId) : null;
List<TrainExternalResourceMapping> 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<TrainAccommodation> history) {
if (history == null) return null;
private List<TrainDormAuth> findStaleActiveAuthsInHistory(Long currentAccId, List<TrainAccommodation> history) {
if (history == null) return Collections.emptyList();
List<TrainDormAuth> 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("(?<!\\d)(1[3-9]\\d)\\d{4}(\\d{4})(?!\\d)", "$1****$2");
return sanitized;
}
}
@@ -0,0 +1,134 @@
package com.ruoyi.train.integration.dms.orchestration;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 批量门禁对账汇总结果 (Wave 3B / Package A)
* <p>
* 终态准则:
* 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<ReconciliationItemVO> 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<ReconciliationItemVO> getItems() {
return items;
}
public void setItems(List<ReconciliationItemVO> 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 +
'}';
}
}
@@ -22,5 +22,10 @@ public enum ReconciliationDecision {
/**
* 等待前置业务条件 (未报到、未分房、无门锁映射等)
*/
WAITING_FOR_PREREQUISITE
WAITING_FOR_PREREQUISITE,
/**
* 满足发放条件,等待管理员手动触发凭据下发 (Package A / Option B)
*/
AWAITING_MANUAL_ISSUE
}
@@ -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 + '\'' +
'}';
}
}
@@ -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<ReconciliationItemVO> 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;
}
+16
View File
@@ -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'
})
}
+2 -2
View File
@@ -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',
+55 -11
View File
@@ -67,6 +67,12 @@
</template>
</el-table-column>
<el-table-column label="学员学号" align="center" prop="studentId" width="120" />
<el-table-column label="手机号" align="center" width="125">
<template slot-scope="scope">
<span v-if="scope.row.studentPhone">{{ scope.row.studentPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') }}</span>
<el-tag v-else type="danger" size="mini">缺手机号</el-tag>
</template>
</el-table-column>
<el-table-column label="宿舍安排" align="center" min-width="130">
<template slot-scope="scope">
<span>{{ scope.row.dormBuilding || '' }} {{ scope.row.dormRoom || '' }} ({{ scope.row.bedNo || '-' }}床)</span>
@@ -108,7 +114,7 @@
</el-table-column>
<el-table-column label="重试次数" align="center" prop="retryCount" width="80" />
<el-table-column label="异常说明" align="center" prop="lastError" :show-overflow-tooltip="true" min-width="120" />
<el-table-column label="管理操作" align="center" width="160" class-name="small-padding fixed-width">
<el-table-column label="管理操作" align="center" width="280" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
v-hasPermi="['train:access:query']"
@@ -124,9 +130,31 @@
icon="el-icon-refresh"
@click="handleManualReconcile(scope.row)"
>手动对账</el-button>
<span v-if="scope.row.authStatus === 'PENDING_GRANT' || scope.row.authStatus === 'ERROR'">
<el-tooltip v-if="!scope.row.studentPhone" content="缺手机号,暂无法下发门禁" placement="top">
<span style="display: inline-block; margin-left: 10px;">
<el-button
v-hasPermi="['train:access:manage']"
size="mini"
type="text"
icon="el-icon-key"
disabled
>下发门禁凭据</el-button>
</span>
</el-tooltip>
<el-button
v-else
v-hasPermi="['train:access:manage']"
size="mini"
type="text"
icon="el-icon-key"
style="color: #409EFF;"
@click="handleIssue(scope.row)"
>下发门禁凭据</el-button>
</span>
<el-button
v-if="scope.row.authStatus === 'ACTIVE' || scope.row.authStatus === 'GRANTING'"
v-hasPermi="['system:auth:edit']"
v-if="scope.row.authStatus === 'ACTIVE' || scope.row.authStatus === 'GRANTING' || scope.row.authStatus === 'PENDING_REVOKE'"
v-hasPermi="['train:access:manage']"
size="mini"
type="text"
icon="el-icon-circle-close"
@@ -196,7 +224,7 @@
<!-- 一次性临时凭据展示对话框 (ADMIN_ONE_TIME_DISPLAY) -->
<el-dialog
title="一次性临时凭据展示 (ADMIN_ONE_TIME_DISPLAY)"
title="一次性门禁凭据展示 (ADMIN_ONE_TIME_DISPLAY)"
:visible.sync="oneTimeDialogOpen"
width="550px"
append-to-body
@@ -205,7 +233,7 @@
:before-close="handleCloseOneTimeDialog"
>
<el-alert
title="安全合规提示:该临时凭据仅在本次操作创建成功后由内存短暂展示一次!关闭弹窗或刷新页面后将永久清除,系统不持久化亦无法再次查询。"
title="凭据已提交下发,设备确认后生效。安全合规提示:该临时凭据仅在本次操作下发成功后由内存短暂展示一次!关闭弹窗或刷新页面后将永久清除,系统不持久化亦无法再次查询旧明文。"
type="warning"
:closable="false"
show-icon
@@ -230,8 +258,7 @@
</template>
<script>
import { listAuth, getOperationHistory, manualReconcile } from '@/api/train/access'
import { revokeAuth } from '@/api/train/auth'
import { listAuth, getOperationHistory, manualReconcile, issueAuth, revokeAuth } from '@/api/train/access'
export default {
name: 'Auth',
@@ -308,18 +335,35 @@ export default {
this.getList()
if (res.data && res.data.oneTimePlaintextCredential) {
this.oneTimeCredential = res.data.oneTimePlaintextCredential
this.maskedMode = true
this.maskedMode = false
this.oneTimeDialogOpen = true
}
}).catch(() => {})
},
handleIssue(row) {
if (!row.studentPhone) {
this.$modal.msgWarning('缺手机号,暂无法下发门禁')
return
}
this.$modal.confirm(`确认向学员【${row.studentName || row.studentId}】下发门禁凭据?`).then(() => {
return issueAuth(row.authId)
}).then(res => {
this.$modal.msgSuccess(res.msg || '凭据已提交下发,设备确认后生效。')
this.getList()
if (res.data && res.data.oneTimePlaintextCredential) {
this.oneTimeCredential = res.data.oneTimePlaintextCredential
this.maskedMode = false
this.oneTimeDialogOpen = true
}
}).catch(() => {})
},
handleRevoke(row) {
const studentDesc = row.studentId || `报名ID:${row.enrollmentId}`
const studentDesc = row.studentName ? `${row.studentName} (${row.studentId || row.enrollmentId})` : (row.studentId || `报名ID:${row.enrollmentId}`)
const dormDesc = `${row.dormBuilding || ''} ${row.dormRoom || ''}`
this.$modal.confirm(`确定要注销学员【${studentDesc}】在宿舍【${dormDesc}】的门禁授权(授权ID: ${row.authId})吗?注销后该学员将无法通行该门禁。`).then(() => {
return revokeAuth(row.authId)
}).then(() => {
this.$modal.msgSuccess('注销成功')
}).then(res => {
this.$modal.msgSuccess(res.msg || '注销操作已提交')
this.getList()
}).catch(() => {})
}