新增集成:实现外部资源映射与操作记录持久化
This commit is contained in:
1 parent
5de1d2a142
commit
1332b45fae
22 files changed
+2843
No files matched your search
+118
@@ -0,0 +1,118 @@
|
||||
package com.ruoyi.web.controller.train;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Locale;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* 008 Migration 静态规范契约测试
|
||||
* 校验 008_external_resource_operation.sql 的设计规范、幂等性设计、约束健全性及敏感数据规避
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class ExternalResourceOperationMigrationContractTest
|
||||
{
|
||||
private File migrationFile;
|
||||
private String sqlContent;
|
||||
|
||||
@Before
|
||||
public void setUp() throws IOException
|
||||
{
|
||||
// 定位 008 迁移文件
|
||||
File direct = new File("../sql/system/kingbase/008_external_resource_operation.sql");
|
||||
if (direct.exists()) {
|
||||
migrationFile = direct;
|
||||
} else {
|
||||
migrationFile = new File("sql/system/kingbase/008_external_resource_operation.sql");
|
||||
}
|
||||
assertTrue("008 migration script must exist: " + migrationFile.getAbsolutePath(), migrationFile.exists());
|
||||
sqlContent = new String(Files.readAllBytes(migrationFile.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTablesCreatedWithIdempotentClause()
|
||||
{
|
||||
String lower = sqlContent.toLowerCase(Locale.ROOT);
|
||||
assertTrue("Must create train_external_resource_mapping", lower.contains("train_external_resource_mapping"));
|
||||
assertTrue("Must create train_external_operation", lower.contains("train_external_operation"));
|
||||
assertTrue("Must have CREATE TABLE IF NOT EXISTS for mapping", lower.contains("create table if not exists train_external_resource_mapping"));
|
||||
assertTrue("Must have CREATE TABLE IF NOT EXISTS for operation", lower.contains("create table if not exists train_external_operation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testForeignKeysAndReferentialIntegrity()
|
||||
{
|
||||
String lower = sqlContent.toLowerCase(Locale.ROOT);
|
||||
assertTrue("Mapping table must reference train_dormitory_info(dorm_id)",
|
||||
lower.contains("references train_dormitory_info(dorm_id)"));
|
||||
assertTrue("Operation table must reference train_dorm_auth(auth_id)",
|
||||
lower.contains("references train_dorm_auth(auth_id)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testPartialUniqueIndexes()
|
||||
{
|
||||
String lower = sqlContent.toLowerCase(Locale.ROOT);
|
||||
// 1. 房间 Active 唯一约束: vendor_code + dorm_id
|
||||
assertTrue("Must define uq_ext_res_map_active_room partial unique index",
|
||||
lower.contains("uq_ext_res_map_active_room"));
|
||||
assertTrue("Must guard active room mapping with external_resource_type and status",
|
||||
lower.contains("where external_resource_type = 'room' and mapping_status = 'active'"));
|
||||
|
||||
// 2. 外部资源跨房间 Active 唯一约束: vendor_code + external_resource_type + external_resource_id
|
||||
assertTrue("Must define uq_ext_res_map_active_ext_res partial unique index",
|
||||
lower.contains("uq_ext_res_map_active_ext_res"));
|
||||
assertTrue("uq_ext_res_map_active_ext_res must include vendor_code, external_resource_type, external_resource_id",
|
||||
lower.contains("(vendor_code, external_resource_type, external_resource_id)"));
|
||||
assertTrue("Must guard active external resource binding with mapping_status = 'active'",
|
||||
lower.contains("where mapping_status = 'active'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOperationTypeAndStatusSemantics()
|
||||
{
|
||||
String lower = sqlContent.toLowerCase(Locale.ROOT);
|
||||
// operation_type 包含 GRANT, REVOKE
|
||||
assertTrue("Must mention GRANT in operation_type", lower.contains("grant"));
|
||||
assertTrue("Must mention REVOKE in operation_type", lower.contains("revoke"));
|
||||
|
||||
// operation_status 绝不能把 REVOKED 作为其合法枚举值
|
||||
assertFalse("operation_status comment must NOT contain REVOKED",
|
||||
lower.contains("operation_status is '操作状态(created, requested, accepted, pending, success, failed, timeout, retry_wait, revoked)'"));
|
||||
assertFalse("operation_status must NOT contain REVOKED in its definition",
|
||||
lower.contains("operation_status is '操作状态") && lower.contains("revoked"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testOperationIdempotencyAndPollIndexes()
|
||||
{
|
||||
String lower = sqlContent.toLowerCase(Locale.ROOT);
|
||||
assertTrue("Must define uq_ext_op_idempotency unique index", lower.contains("uq_ext_op_idempotency"));
|
||||
assertTrue("Must define idx_ext_op_poll index for async polling", lower.contains("idx_ext_op_poll"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNoDestructiveStatements()
|
||||
{
|
||||
// 过滤单行注释
|
||||
String noComments = sqlContent.replaceAll("--.*", "").toLowerCase(Locale.ROOT);
|
||||
assertFalse("Must NOT contain DROP TABLE", noComments.contains("drop table"));
|
||||
assertFalse("Must NOT contain TRUNCATE", noComments.contains("truncate"));
|
||||
assertFalse("Must NOT contain DELETE FROM", noComments.contains("delete from"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testNoSensitivePlaintextColumns()
|
||||
{
|
||||
String lower = sqlContent.toLowerCase(Locale.ROOT);
|
||||
assertFalse("Must NOT contain plaintext password column", lower.contains("password varchar") || lower.contains("door_password"));
|
||||
assertFalse("Must NOT contain plaintext card number column", lower.contains("card_number") || lower.contains("cardnumber"));
|
||||
assertFalse("Must NOT contain token column", lower.contains("token varchar") || lower.contains("app_token"));
|
||||
assertFalse("Must NOT contain secret column", lower.contains("secret varchar") || lower.contains("app_secret"));
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package com.ruoyi.train.domain;
|
||||
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 外部操作流水对象 train_external_operation
|
||||
* 用于追踪向外部物联网平台发起的所有设备异步操作(如门禁下发、撤销、查询)与轮询对账轨迹
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-15
|
||||
*/
|
||||
public class TrainExternalOperation extends BaseEntity
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 本地操作流水ID(主键) */
|
||||
private Long operationId;
|
||||
|
||||
/** 关联门禁授权主记录ID(外键关联 train_dorm_auth) */
|
||||
@Excel(name = "门禁授权ID")
|
||||
private Long doorAuthId;
|
||||
|
||||
/** 业务类型(默认 DOOR_AUTH) */
|
||||
@Excel(name = "业务类型")
|
||||
private String businessType;
|
||||
|
||||
/** 外部供应商标识(如 DMS) */
|
||||
@Excel(name = "外部供应商标识")
|
||||
private String vendorCode;
|
||||
|
||||
/** 操作类型(GRANT下发, REVOKE注销, STATUS_QUERY状态查询) */
|
||||
@Excel(name = "操作类型")
|
||||
private String operationType;
|
||||
|
||||
public static final String TYPE_GRANT = "GRANT";
|
||||
public static final String TYPE_REVOKE = "REVOKE";
|
||||
public static final String TYPE_STATUS_QUERY = "STATUS_QUERY";
|
||||
|
||||
public static final String STATUS_CREATED = "CREATED";
|
||||
public static final String STATUS_REQUESTED = "REQUESTED";
|
||||
public static final String STATUS_ACCEPTED = "ACCEPTED";
|
||||
public static final String STATUS_PENDING = "PENDING";
|
||||
public static final String STATUS_RETRY_WAIT = "RETRY_WAIT";
|
||||
public static final String STATUS_SUCCESS = "SUCCESS";
|
||||
public static final String STATUS_FAILED = "FAILED";
|
||||
public static final String STATUS_TIMEOUT = "TIMEOUT";
|
||||
|
||||
/** 操作状态(CREATED, REQUESTED, ACCEPTED, PENDING, RETRY_WAIT, SUCCESS, FAILED, TIMEOUT) */
|
||||
@Excel(name = "操作状态")
|
||||
private String operationStatus;
|
||||
|
||||
/** 本地全局幂等键(防重复调用) */
|
||||
@Excel(name = "幂等键")
|
||||
private String idempotencyKey;
|
||||
|
||||
/** 外部返回的操作流水ID(如 DMS operationId,初始为NULL) */
|
||||
@Excel(name = "外部操作ID")
|
||||
private String externalOperationId;
|
||||
|
||||
/** 外部请求类型数字码(如 1=下发密码, 3=删除密码) */
|
||||
@Excel(name = "外部请求类型")
|
||||
private Integer externalRequestType;
|
||||
|
||||
/** 外部硬件标识/SN */
|
||||
@Excel(name = "外部硬件SN")
|
||||
private String externalHardwareId;
|
||||
|
||||
/** 外部凭证钥匙ID(如 DMS keyId) */
|
||||
@Excel(name = "外部钥匙ID")
|
||||
private String externalKeyId;
|
||||
|
||||
/** 尝试/轮询次数 */
|
||||
@Excel(name = "尝试次数")
|
||||
private Integer attemptCount;
|
||||
|
||||
/** 下次轮询执行时间戳 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Excel(name = "下次轮询时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date nextPollTime;
|
||||
|
||||
/** 向外部发出请求的时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Excel(name = "请求发起时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date requestedAt;
|
||||
|
||||
/** 操作达成终态时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Excel(name = "达成终态时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date completedAt;
|
||||
|
||||
/** 最后一次错误编码 */
|
||||
@Excel(name = "最后错误码")
|
||||
private String lastErrorCode;
|
||||
|
||||
/** 最后一次错误描述 */
|
||||
@Excel(name = "最后错误描述")
|
||||
private String lastErrorMessage;
|
||||
|
||||
public TrainExternalOperation()
|
||||
{
|
||||
}
|
||||
|
||||
public Long getOperationId()
|
||||
{
|
||||
return operationId;
|
||||
}
|
||||
|
||||
public void setOperationId(Long operationId)
|
||||
{
|
||||
this.operationId = operationId;
|
||||
}
|
||||
|
||||
public Long getDoorAuthId()
|
||||
{
|
||||
return doorAuthId;
|
||||
}
|
||||
|
||||
public void setDoorAuthId(Long doorAuthId)
|
||||
{
|
||||
this.doorAuthId = doorAuthId;
|
||||
}
|
||||
|
||||
public String getBusinessType()
|
||||
{
|
||||
return businessType;
|
||||
}
|
||||
|
||||
public void setBusinessType(String businessType)
|
||||
{
|
||||
this.businessType = businessType;
|
||||
}
|
||||
|
||||
public String getVendorCode()
|
||||
{
|
||||
return vendorCode;
|
||||
}
|
||||
|
||||
public void setVendorCode(String vendorCode)
|
||||
{
|
||||
this.vendorCode = vendorCode;
|
||||
}
|
||||
|
||||
public String getOperationType()
|
||||
{
|
||||
return operationType;
|
||||
}
|
||||
|
||||
public void setOperationType(String operationType)
|
||||
{
|
||||
this.operationType = operationType;
|
||||
}
|
||||
|
||||
public String getOperationStatus()
|
||||
{
|
||||
return operationStatus;
|
||||
}
|
||||
|
||||
public void setOperationStatus(String operationStatus)
|
||||
{
|
||||
this.operationStatus = operationStatus;
|
||||
}
|
||||
|
||||
public String getIdempotencyKey()
|
||||
{
|
||||
return idempotencyKey;
|
||||
}
|
||||
|
||||
public void setIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
this.idempotencyKey = idempotencyKey;
|
||||
}
|
||||
|
||||
public String getExternalOperationId()
|
||||
{
|
||||
return externalOperationId;
|
||||
}
|
||||
|
||||
public void setExternalOperationId(String externalOperationId)
|
||||
{
|
||||
this.externalOperationId = externalOperationId;
|
||||
}
|
||||
|
||||
public Integer getExternalRequestType()
|
||||
{
|
||||
return externalRequestType;
|
||||
}
|
||||
|
||||
public void setExternalRequestType(Integer externalRequestType)
|
||||
{
|
||||
this.externalRequestType = externalRequestType;
|
||||
}
|
||||
|
||||
public String getExternalHardwareId()
|
||||
{
|
||||
return externalHardwareId;
|
||||
}
|
||||
|
||||
public void setExternalHardwareId(String externalHardwareId)
|
||||
{
|
||||
this.externalHardwareId = externalHardwareId;
|
||||
}
|
||||
|
||||
public String getExternalKeyId()
|
||||
{
|
||||
return externalKeyId;
|
||||
}
|
||||
|
||||
public void setExternalKeyId(String externalKeyId)
|
||||
{
|
||||
this.externalKeyId = externalKeyId;
|
||||
}
|
||||
|
||||
public Integer getAttemptCount()
|
||||
{
|
||||
return attemptCount;
|
||||
}
|
||||
|
||||
public void setAttemptCount(Integer attemptCount)
|
||||
{
|
||||
this.attemptCount = attemptCount;
|
||||
}
|
||||
|
||||
public Date getNextPollTime()
|
||||
{
|
||||
return nextPollTime;
|
||||
}
|
||||
|
||||
public void setNextPollTime(Date nextPollTime)
|
||||
{
|
||||
this.nextPollTime = nextPollTime;
|
||||
}
|
||||
|
||||
public Date getRequestedAt()
|
||||
{
|
||||
return requestedAt;
|
||||
}
|
||||
|
||||
public void setRequestedAt(Date requestedAt)
|
||||
{
|
||||
this.requestedAt = requestedAt;
|
||||
}
|
||||
|
||||
public Date getCompletedAt()
|
||||
{
|
||||
return completedAt;
|
||||
}
|
||||
|
||||
public void setCompletedAt(Date completedAt)
|
||||
{
|
||||
this.completedAt = completedAt;
|
||||
}
|
||||
|
||||
public String getLastErrorCode()
|
||||
{
|
||||
return lastErrorCode;
|
||||
}
|
||||
|
||||
public void setLastErrorCode(String lastErrorCode)
|
||||
{
|
||||
this.lastErrorCode = lastErrorCode;
|
||||
}
|
||||
|
||||
public String getLastErrorMessage()
|
||||
{
|
||||
return lastErrorMessage;
|
||||
}
|
||||
|
||||
public void setLastErrorMessage(String lastErrorMessage)
|
||||
{
|
||||
this.lastErrorMessage = lastErrorMessage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "TrainExternalOperation{" +
|
||||
"operationId=" + operationId +
|
||||
", doorAuthId=" + doorAuthId +
|
||||
", businessType='" + businessType + '\'' +
|
||||
", vendorCode='" + vendorCode + '\'' +
|
||||
", operationType='" + operationType + '\'' +
|
||||
", operationStatus='" + operationStatus + '\'' +
|
||||
", idempotencyKey='" + idempotencyKey + '\'' +
|
||||
", externalOperationId='" + externalOperationId + '\'' +
|
||||
", externalRequestType=" + externalRequestType +
|
||||
", externalHardwareId='" + externalHardwareId + '\'' +
|
||||
", externalKeyId='" + externalKeyId + '\'' +
|
||||
", attemptCount=" + attemptCount +
|
||||
", nextPollTime=" + nextPollTime +
|
||||
", requestedAt=" + requestedAt +
|
||||
", completedAt=" + completedAt +
|
||||
", lastErrorCode='" + lastErrorCode + '\'' +
|
||||
", lastErrorMessage='" + lastErrorMessage + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package com.ruoyi.train.domain;
|
||||
|
||||
import java.util.Date;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.ruoyi.common.annotation.Excel;
|
||||
import com.ruoyi.common.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 外部资源映射对象 train_external_resource_mapping
|
||||
* 用于管理本地宿舍与外部物联网设备(如门锁、房间)的绑定关系,支持一房多锁与多供应商适配
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-15
|
||||
*/
|
||||
public class TrainExternalResourceMapping extends BaseEntity
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** 映射ID(主键) */
|
||||
private Long mappingId;
|
||||
|
||||
/** 本地宿舍ID(外键关联 train_dormitory_info) */
|
||||
@Excel(name = "本地宿舍ID")
|
||||
private String dormId;
|
||||
|
||||
/** 外部供应商标识(如 DMS) */
|
||||
@Excel(name = "外部供应商标识")
|
||||
private String vendorCode;
|
||||
|
||||
/** 本地资源类型(DORM_ROOM, DOOR_LOCK) */
|
||||
@Excel(name = "本地资源类型")
|
||||
private String localResourceType;
|
||||
|
||||
/** 外部资源类型(ROOM, DOOR_LOCK) */
|
||||
@Excel(name = "外部资源类型")
|
||||
private String externalResourceType;
|
||||
|
||||
/** 外部系统资源全局主键(如 placeId, lockId) */
|
||||
@Excel(name = "外部资源ID")
|
||||
private String externalResourceId;
|
||||
|
||||
/** 外部父级资源标识(如 building/floor placeId) */
|
||||
@Excel(name = "外部父级资源标识")
|
||||
private String externalParentId;
|
||||
|
||||
/** 外部硬件标识/SN(如门锁 hardwareId) */
|
||||
@Excel(name = "外部硬件SN")
|
||||
private String externalHardwareId;
|
||||
|
||||
/** 外部项目/租户站点ID(如 DMS projectId) */
|
||||
@Excel(name = "外部租户项目ID")
|
||||
private String externalProjectId;
|
||||
|
||||
/** 是否主门锁/主资源(1是, 0否) */
|
||||
@Excel(name = "是否主资源")
|
||||
private Integer isPrimary;
|
||||
|
||||
/** 映射状态(ACTIVE有效, INACTIVE失效) */
|
||||
@Excel(name = "映射状态")
|
||||
private String mappingStatus;
|
||||
|
||||
/** 最近同步元数据时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@Excel(name = "最近同步时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
private Date lastSyncTime;
|
||||
|
||||
public TrainExternalResourceMapping()
|
||||
{
|
||||
}
|
||||
|
||||
public Long getMappingId()
|
||||
{
|
||||
return mappingId;
|
||||
}
|
||||
|
||||
public void setMappingId(Long mappingId)
|
||||
{
|
||||
this.mappingId = mappingId;
|
||||
}
|
||||
|
||||
public String getDormId()
|
||||
{
|
||||
return dormId;
|
||||
}
|
||||
|
||||
public void setDormId(String dormId)
|
||||
{
|
||||
this.dormId = dormId;
|
||||
}
|
||||
|
||||
public String getVendorCode()
|
||||
{
|
||||
return vendorCode;
|
||||
}
|
||||
|
||||
public void setVendorCode(String vendorCode)
|
||||
{
|
||||
this.vendorCode = vendorCode;
|
||||
}
|
||||
|
||||
public String getLocalResourceType()
|
||||
{
|
||||
return localResourceType;
|
||||
}
|
||||
|
||||
public void setLocalResourceType(String localResourceType)
|
||||
{
|
||||
this.localResourceType = localResourceType;
|
||||
}
|
||||
|
||||
public String getExternalResourceType()
|
||||
{
|
||||
return externalResourceType;
|
||||
}
|
||||
|
||||
public void setExternalResourceType(String externalResourceType)
|
||||
{
|
||||
this.externalResourceType = externalResourceType;
|
||||
}
|
||||
|
||||
public String getExternalResourceId()
|
||||
{
|
||||
return externalResourceId;
|
||||
}
|
||||
|
||||
public void setExternalResourceId(String externalResourceId)
|
||||
{
|
||||
this.externalResourceId = externalResourceId;
|
||||
}
|
||||
|
||||
public String getExternalParentId()
|
||||
{
|
||||
return externalParentId;
|
||||
}
|
||||
|
||||
public void setExternalParentId(String externalParentId)
|
||||
{
|
||||
this.externalParentId = externalParentId;
|
||||
}
|
||||
|
||||
public String getExternalHardwareId()
|
||||
{
|
||||
return externalHardwareId;
|
||||
}
|
||||
|
||||
public void setExternalHardwareId(String externalHardwareId)
|
||||
{
|
||||
this.externalHardwareId = externalHardwareId;
|
||||
}
|
||||
|
||||
public String getExternalProjectId()
|
||||
{
|
||||
return externalProjectId;
|
||||
}
|
||||
|
||||
public void setExternalProjectId(String externalProjectId)
|
||||
{
|
||||
this.externalProjectId = externalProjectId;
|
||||
}
|
||||
|
||||
public Integer getIsPrimary()
|
||||
{
|
||||
return isPrimary;
|
||||
}
|
||||
|
||||
public void setIsPrimary(Integer isPrimary)
|
||||
{
|
||||
this.isPrimary = isPrimary;
|
||||
}
|
||||
|
||||
public String getMappingStatus()
|
||||
{
|
||||
return mappingStatus;
|
||||
}
|
||||
|
||||
public void setMappingStatus(String mappingStatus)
|
||||
{
|
||||
this.mappingStatus = mappingStatus;
|
||||
}
|
||||
|
||||
public Date getLastSyncTime()
|
||||
{
|
||||
return lastSyncTime;
|
||||
}
|
||||
|
||||
public void setLastSyncTime(Date lastSyncTime)
|
||||
{
|
||||
this.lastSyncTime = lastSyncTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return "TrainExternalResourceMapping{" +
|
||||
"mappingId=" + mappingId +
|
||||
", dormId='" + dormId + '\'' +
|
||||
", vendorCode='" + vendorCode + '\'' +
|
||||
", localResourceType='" + localResourceType + '\'' +
|
||||
", externalResourceType='" + externalResourceType + '\'' +
|
||||
", externalResourceId='" + externalResourceId + '\'' +
|
||||
", externalParentId='" + externalParentId + '\'' +
|
||||
", externalHardwareId='" + externalHardwareId + '\'' +
|
||||
", externalProjectId='" + externalProjectId + '\'' +
|
||||
", isPrimary=" + isPrimary +
|
||||
", mappingStatus='" + mappingStatus + '\'' +
|
||||
", lastSyncTime=" + lastSyncTime +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package com.ruoyi.train.mapper;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
|
||||
/**
|
||||
* 外部操作流水Mapper接口
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-15
|
||||
*/
|
||||
public interface TrainExternalOperationMapper
|
||||
{
|
||||
/**
|
||||
* 查询外部操作流水
|
||||
*
|
||||
* @param operationId 本地操作ID
|
||||
* @return 外部操作流水
|
||||
*/
|
||||
public TrainExternalOperation selectExternalOperationById(Long operationId);
|
||||
|
||||
/**
|
||||
* 根据幂等键查询操作流水
|
||||
*
|
||||
* @param idempotencyKey 幂等键
|
||||
* @return 操作流水
|
||||
*/
|
||||
public TrainExternalOperation selectByIdempotencyKey(String idempotencyKey);
|
||||
|
||||
/**
|
||||
* 根据门禁授权ID查询所有操作流水记录(按时间升序,保留完整重试与变更轨迹)
|
||||
*
|
||||
* @param doorAuthId 门禁授权ID
|
||||
* @return 操作流水列表
|
||||
*/
|
||||
public List<TrainExternalOperation> selectByDoorAuthId(Long doorAuthId);
|
||||
|
||||
/**
|
||||
* 根据外部操作流水号查询
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param externalOperationId 外部操作ID
|
||||
* @return 操作流水
|
||||
*/
|
||||
public TrainExternalOperation selectByExternalOperationId(
|
||||
@Param("vendorCode") String vendorCode,
|
||||
@Param("externalOperationId") String externalOperationId);
|
||||
|
||||
/**
|
||||
* 查询所有需要进行异步状态轮询的操作记录
|
||||
* 条件: operation_status IN ('ACCEPTED', 'PENDING', 'RETRY_WAIT') AND next_poll_time <= maxPollTime
|
||||
*
|
||||
* @param vendorCode 供应商标识(可选)
|
||||
* @param maxPollTime 轮询时间上限阈值(通常为当前系统时间)
|
||||
* @return 待轮询操作列表
|
||||
*/
|
||||
public List<TrainExternalOperation> selectPendingPollOperations(
|
||||
@Param("vendorCode") String vendorCode,
|
||||
@Param("maxPollTime") Date maxPollTime);
|
||||
|
||||
/**
|
||||
* 查询外部操作流水列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 操作列表
|
||||
*/
|
||||
public List<TrainExternalOperation> selectExternalOperationList(TrainExternalOperation query);
|
||||
|
||||
/**
|
||||
* 新增外部操作流水
|
||||
*
|
||||
* @param operation 操作信息
|
||||
* @return 结果
|
||||
*/
|
||||
public int insertExternalOperation(TrainExternalOperation operation);
|
||||
|
||||
/**
|
||||
* 更新操作流水状态与轮询对账信息
|
||||
*
|
||||
* @param operation 操作信息
|
||||
* @return 结果
|
||||
*/
|
||||
public int updateExternalOperationStatus(TrainExternalOperation operation);
|
||||
|
||||
/**
|
||||
* 回填外部操作ID与凭证信息
|
||||
*
|
||||
* @param operationId 本地流水ID
|
||||
* @param externalOperationId 外部操作ID
|
||||
* @param externalHardwareId 外部硬件SN
|
||||
* @param externalKeyId 外部钥匙ID
|
||||
* @param updateBy 更新人
|
||||
* @return 结果
|
||||
*/
|
||||
public int updateExternalReference(
|
||||
@Param("operationId") Long operationId,
|
||||
@Param("externalOperationId") String externalOperationId,
|
||||
@Param("externalHardwareId") String externalHardwareId,
|
||||
@Param("externalKeyId") String externalKeyId,
|
||||
@Param("updateBy") String updateBy);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package com.ruoyi.train.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import com.ruoyi.train.domain.TrainExternalResourceMapping;
|
||||
|
||||
/**
|
||||
* 外部资源映射Mapper接口
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-15
|
||||
*/
|
||||
public interface TrainExternalResourceMappingMapper
|
||||
{
|
||||
/**
|
||||
* 查询外部资源映射
|
||||
*
|
||||
* @param mappingId 映射ID
|
||||
* @return 外部资源映射
|
||||
*/
|
||||
public TrainExternalResourceMapping selectExternalResourceMappingById(Long mappingId);
|
||||
|
||||
/**
|
||||
* 查询指定宿舍当前处于有效状态的房间映射(每宿舍每厂商最多1条)
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param dormId 宿舍ID
|
||||
* @return 有效房间映射
|
||||
*/
|
||||
public TrainExternalResourceMapping selectActiveRoomMapping(@Param("vendorCode") String vendorCode, @Param("dormId") String dormId);
|
||||
|
||||
/**
|
||||
* 查询指定宿舍当前处于有效状态的门锁映射列表(支持一房多锁)
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param dormId 宿舍ID
|
||||
* @return 有效门锁映射列表
|
||||
*/
|
||||
public List<TrainExternalResourceMapping> selectActiveLockMappings(@Param("vendorCode") String vendorCode, @Param("dormId") String dormId);
|
||||
|
||||
/**
|
||||
* 查询外部资源当前处于有效状态的绑定记录(防重复跨房间绑定)
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param externalResourceType 外部资源类型
|
||||
* @param externalResourceId 外部资源ID
|
||||
* @return 有效绑定记录
|
||||
*/
|
||||
public TrainExternalResourceMapping selectActiveByExternalResource(
|
||||
@Param("vendorCode") String vendorCode,
|
||||
@Param("externalResourceType") String externalResourceType,
|
||||
@Param("externalResourceId") String externalResourceId);
|
||||
|
||||
/**
|
||||
* 查询外部资源映射列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 映射列表
|
||||
*/
|
||||
public List<TrainExternalResourceMapping> selectExternalResourceMappingList(TrainExternalResourceMapping query);
|
||||
|
||||
/**
|
||||
* 新增外部资源映射
|
||||
*
|
||||
* @param mapping 映射信息
|
||||
* @return 结果
|
||||
*/
|
||||
public int insertExternalResourceMapping(TrainExternalResourceMapping mapping);
|
||||
|
||||
/**
|
||||
* 修改外部资源映射
|
||||
*
|
||||
* @param mapping 映射信息
|
||||
* @return 结果
|
||||
*/
|
||||
public int updateExternalResourceMapping(TrainExternalResourceMapping mapping);
|
||||
|
||||
/**
|
||||
* 失效指定的资源映射
|
||||
*
|
||||
* @param mappingId 映射ID
|
||||
* @param updateBy 更新者
|
||||
* @return 结果
|
||||
*/
|
||||
public int deactivateMapping(@Param("mappingId") Long mappingId, @Param("updateBy") String updateBy);
|
||||
|
||||
/**
|
||||
* 失效指定宿舍的房间映射
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param dormId 宿舍ID
|
||||
* @param updateBy 更新者
|
||||
* @return 结果
|
||||
*/
|
||||
public int deactivateRoomMapping(@Param("vendorCode") String vendorCode, @Param("dormId") String dormId, @Param("updateBy") String updateBy);
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package com.ruoyi.train.service;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
|
||||
/**
|
||||
* 外部设备操作流水业务服务接口
|
||||
* 负责管理异步设备调用的生命周期、幂等防重控制与状态机流转
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-15
|
||||
*/
|
||||
public interface IExternalOperationService
|
||||
{
|
||||
/**
|
||||
* 根据主键查询操作流水
|
||||
*
|
||||
* @param operationId 本地流水ID
|
||||
* @return 操作流水
|
||||
*/
|
||||
public TrainExternalOperation getOperationById(Long operationId);
|
||||
|
||||
/**
|
||||
* 根据幂等键查询操作流水
|
||||
*
|
||||
* @param idempotencyKey 幂等键
|
||||
* @return 操作流水
|
||||
*/
|
||||
public TrainExternalOperation getOperationByIdempotencyKey(String idempotencyKey);
|
||||
|
||||
/**
|
||||
* 查询指定门禁授权下的所有操作流水历史(DoorAuthorization 1:N ExternalOperation)
|
||||
*
|
||||
* @param doorAuthId 门禁授权ID
|
||||
* @return 操作流水列表(按创建时间升序排列)
|
||||
*/
|
||||
public List<TrainExternalOperation> listOperationsByDoorAuthId(Long doorAuthId);
|
||||
|
||||
/**
|
||||
* 创建操作流水记录(受本地幂等保护)
|
||||
* 规则:
|
||||
* 1. 门禁授权 (doorAuthId) 必须存在;
|
||||
* 2. 操作类型非空且合法;
|
||||
* 3. 幂等键 (idempotencyKey) 必须提供;
|
||||
* 4. 若幂等键已存在,直接返回已存在的记录(幂等安全,不生成重复数据);
|
||||
* 5. 初始状态为 CREATED,externalOperationId 初始为 null。
|
||||
*
|
||||
* @param operation 操作流水实体
|
||||
* @return 创建或已存在的操作流水实体
|
||||
*/
|
||||
public TrainExternalOperation createOperation(TrainExternalOperation operation);
|
||||
|
||||
/**
|
||||
* 外部接口发起后回填外部操作流水ID与凭据信息
|
||||
*
|
||||
* @param operationId 本地流水ID
|
||||
* @param externalOperationId 外部流水ID (如 DMS operationId)
|
||||
* @param externalHardwareId 目标硬件SN
|
||||
* @param externalKeyId 外部钥匙ID (如 DMS keyId)
|
||||
* @param operator 操作人
|
||||
* @return 更新后的操作流水
|
||||
*/
|
||||
public TrainExternalOperation bindExternalReference(
|
||||
Long operationId,
|
||||
String externalOperationId,
|
||||
String externalHardwareId,
|
||||
String externalKeyId,
|
||||
String operator);
|
||||
|
||||
/**
|
||||
* 推进操作状态机流转
|
||||
* 限制非法跳转规则:
|
||||
* - SUCCESS 状态为终态 (Terminal),不可逆向跳转;
|
||||
* - FAILED / TIMEOUT 状态为终态,不可逆向重置回 CREATED/REQUESTED;
|
||||
*
|
||||
* @param operationId 本地流水ID
|
||||
* @param targetStatus 目标状态
|
||||
* @param errorCode 错误码 (若有)
|
||||
* @param errorMessage 错误消息 (若有)
|
||||
* @param operator 操作人
|
||||
* @return 更新后的操作流水
|
||||
*/
|
||||
public TrainExternalOperation transitStatus(
|
||||
Long operationId,
|
||||
String targetStatus,
|
||||
String errorCode,
|
||||
String errorMessage,
|
||||
String operator);
|
||||
|
||||
/**
|
||||
* 查询待轮询的操作流水列表(为 Wave 2 调度器准备)
|
||||
* 条件: operation_status IN ('ACCEPTED', 'PENDING', 'RETRY_WAIT') AND next_poll_time <= maxPollTime
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param maxPollTime 最大允许下次轮询时间(默认当前时间)
|
||||
* @return 待轮询操作列表
|
||||
*/
|
||||
public List<TrainExternalOperation> listPendingPollOperations(String vendorCode, Date maxPollTime);
|
||||
|
||||
/**
|
||||
* 基于前次失败/超时操作创建新的重试操作流水(保留历史审计,生成全新 Operation 记录)
|
||||
*
|
||||
* @param previousOperationId 前次操作流水ID
|
||||
* @param newIdempotencyKey 新操作的幂等键
|
||||
* @param operator 操作人
|
||||
* @return 新建的重试操作记录
|
||||
*/
|
||||
public TrainExternalOperation createRetryOperation(Long previousOperationId, String newIdempotencyKey, String operator);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.ruoyi.train.service;
|
||||
|
||||
import java.util.List;
|
||||
import com.ruoyi.train.domain.TrainExternalResourceMapping;
|
||||
|
||||
/**
|
||||
* 外部资源映射业务服务接口
|
||||
* 负责管理本地宿舍与外部物联网设备(如DMS房间、智能门锁)的映射关联,维护资源绑定的唯一性与一房多锁支持
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-15
|
||||
*/
|
||||
public interface IExternalResourceMappingService
|
||||
{
|
||||
/**
|
||||
* 查询指定ID的资源映射
|
||||
*
|
||||
* @param mappingId 映射ID
|
||||
* @return 映射信息
|
||||
*/
|
||||
public TrainExternalResourceMapping getMappingById(Long mappingId);
|
||||
|
||||
/**
|
||||
* 查询指定宿舍当前有效的房间映射(Dormitory 1:0..1 Active Room)
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param dormId 本地宿舍ID
|
||||
* @return 有效房间映射,无则返回 null
|
||||
*/
|
||||
public TrainExternalResourceMapping getActiveRoomMapping(String vendorCode, String dormId);
|
||||
|
||||
/**
|
||||
* 查询指定宿舍当前绑定的全部有效门锁映射(Dormitory 1:0..N Locks,支持一房多锁)
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param dormId 本地宿舍ID
|
||||
* @return 有效门锁映射列表
|
||||
*/
|
||||
public List<TrainExternalResourceMapping> getActiveLockMappings(String vendorCode, String dormId);
|
||||
|
||||
/**
|
||||
* 条件查询外部资源映射列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @return 映射列表
|
||||
*/
|
||||
public List<TrainExternalResourceMapping> listMappings(TrainExternalResourceMapping query);
|
||||
|
||||
/**
|
||||
* 绑定房间映射(创建 Active Room Mapping)
|
||||
* 业务校验规则:
|
||||
* 1. 本地宿舍必须存在;
|
||||
* 2. 该外部资源不能已被其他宿舍 ACTIVE 绑定;
|
||||
* 3. 同一宿舍同一厂商最多1个 ACTIVE 房间映射(若已存在需先解除绑定);
|
||||
*
|
||||
* @param mapping 映射实体
|
||||
* @return 插入成功的映射实体(包含自增 mappingId)
|
||||
*/
|
||||
public TrainExternalResourceMapping bindRoomMapping(TrainExternalResourceMapping mapping);
|
||||
|
||||
/**
|
||||
* 绑定门锁映射(创建 Active Lock Mapping)
|
||||
* 业务校验规则:
|
||||
* 1. 本地宿舍必须存在;
|
||||
* 2. 该外部门锁不能已被其他宿舍 ACTIVE 绑定;
|
||||
* 3. 允许一个宿舍绑定多把锁(一房多锁支持);
|
||||
*
|
||||
* @param mapping 映射实体
|
||||
* @return 插入成功的映射实体
|
||||
*/
|
||||
public TrainExternalResourceMapping bindLockMapping(TrainExternalResourceMapping mapping);
|
||||
|
||||
/**
|
||||
* 解除/失效指定映射
|
||||
*
|
||||
* @param mappingId 映射ID
|
||||
* @param operator 操作人
|
||||
* @return 是否成功
|
||||
*/
|
||||
public boolean deactivateMapping(Long mappingId, String operator);
|
||||
|
||||
/**
|
||||
* 解除/失效指定宿舍当前的有效房间映射
|
||||
*
|
||||
* @param vendorCode 供应商标识
|
||||
* @param dormId 宿舍ID
|
||||
* @param operator 操作人
|
||||
* @return 是否成功
|
||||
*/
|
||||
public boolean deactivateRoomMapping(String vendorCode, String dormId, String operator);
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.mapper.TrainDormAuthMapper;
|
||||
import com.ruoyi.train.mapper.TrainExternalOperationMapper;
|
||||
import com.ruoyi.train.service.IExternalOperationService;
|
||||
|
||||
/**
|
||||
* 外部设备操作流水业务服务实现类
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-15
|
||||
*/
|
||||
@Service
|
||||
public class ExternalOperationServiceImpl implements IExternalOperationService
|
||||
{
|
||||
private static final Set<String> VALID_TYPES = new HashSet<>(Arrays.asList(
|
||||
"GRANT", "REVOKE", "STATUS_QUERY", "REMOTE_UNLOCK"
|
||||
));
|
||||
|
||||
private static final Set<String> TERMINAL_STATUSES = new HashSet<>(Arrays.asList(
|
||||
"SUCCESS", "FAILED", "TIMEOUT"
|
||||
));
|
||||
|
||||
@Autowired
|
||||
private TrainExternalOperationMapper operationMapper;
|
||||
|
||||
@Autowired
|
||||
private TrainDormAuthMapper dormAuthMapper;
|
||||
|
||||
@Override
|
||||
public TrainExternalOperation getOperationById(Long operationId)
|
||||
{
|
||||
if (operationId == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return operationMapper.selectExternalOperationById(operationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainExternalOperation getOperationByIdempotencyKey(String idempotencyKey)
|
||||
{
|
||||
if (StringUtils.isEmpty(idempotencyKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return operationMapper.selectByIdempotencyKey(idempotencyKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainExternalOperation> listOperationsByDoorAuthId(Long doorAuthId)
|
||||
{
|
||||
if (doorAuthId == null)
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return operationMapper.selectByDoorAuthId(doorAuthId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TrainExternalOperation createOperation(TrainExternalOperation operation)
|
||||
{
|
||||
if (operation == null)
|
||||
{
|
||||
throw new ServiceException("操作实体不能为空");
|
||||
}
|
||||
if (operation.getDoorAuthId() == null)
|
||||
{
|
||||
throw new ServiceException("门禁授权ID (doorAuthId) 不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(operation.getOperationType()) || !VALID_TYPES.contains(operation.getOperationType()))
|
||||
{
|
||||
throw new ServiceException("非法或未知的操作类型: " + operation.getOperationType());
|
||||
}
|
||||
if (StringUtils.isEmpty(operation.getIdempotencyKey()))
|
||||
{
|
||||
throw new ServiceException("幂等键 (idempotencyKey) 不能为空");
|
||||
}
|
||||
|
||||
// 1. 验证目标门禁授权是否存在
|
||||
TrainDormAuth auth = dormAuthMapper.selectTrainDormAuthByAuthId(operation.getDoorAuthId());
|
||||
if (auth == null)
|
||||
{
|
||||
throw new ServiceException("目标门禁授权不存在: " + operation.getDoorAuthId());
|
||||
}
|
||||
|
||||
// 2. 幂等检查:若已存在同名幂等键,直接返回已存在记录(幂等安全)
|
||||
TrainExternalOperation existing = operationMapper.selectByIdempotencyKey(operation.getIdempotencyKey());
|
||||
if (existing != null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
// 3. 设置默认值与初始状态
|
||||
if (StringUtils.isEmpty(operation.getBusinessType()))
|
||||
{
|
||||
operation.setBusinessType("DOOR_AUTH");
|
||||
}
|
||||
if (StringUtils.isEmpty(operation.getVendorCode()))
|
||||
{
|
||||
operation.setVendorCode("DMS");
|
||||
}
|
||||
operation.setOperationStatus("CREATED");
|
||||
if (operation.getAttemptCount() == null)
|
||||
{
|
||||
operation.setAttemptCount(0);
|
||||
}
|
||||
|
||||
operationMapper.insertExternalOperation(operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TrainExternalOperation bindExternalReference(
|
||||
Long operationId,
|
||||
String externalOperationId,
|
||||
String externalHardwareId,
|
||||
String externalKeyId,
|
||||
String operator)
|
||||
{
|
||||
if (operationId == null)
|
||||
{
|
||||
throw new ServiceException("操作流水ID不能为空");
|
||||
}
|
||||
TrainExternalOperation op = operationMapper.selectExternalOperationById(operationId);
|
||||
if (op == null)
|
||||
{
|
||||
throw new ServiceException("操作流水记录不存在: " + operationId);
|
||||
}
|
||||
|
||||
op.setExternalOperationId(externalOperationId);
|
||||
if (StringUtils.isNotEmpty(externalHardwareId))
|
||||
{
|
||||
op.setExternalHardwareId(externalHardwareId);
|
||||
}
|
||||
if (StringUtils.isNotEmpty(externalKeyId))
|
||||
{
|
||||
op.setExternalKeyId(externalKeyId);
|
||||
}
|
||||
op.setUpdateBy(operator);
|
||||
|
||||
operationMapper.updateExternalReference(operationId, externalOperationId,
|
||||
externalHardwareId, externalKeyId, operator);
|
||||
return op;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TrainExternalOperation transitStatus(
|
||||
Long operationId,
|
||||
String targetStatus,
|
||||
String errorCode,
|
||||
String errorMessage,
|
||||
String operator)
|
||||
{
|
||||
if (operationId == null)
|
||||
{
|
||||
throw new ServiceException("操作流水ID不能为空");
|
||||
}
|
||||
TrainExternalOperation op = operationMapper.selectExternalOperationById(operationId);
|
||||
if (op == null)
|
||||
{
|
||||
throw new ServiceException("操作流水记录不存在: " + operationId);
|
||||
}
|
||||
|
||||
String currentStatus = op.getOperationStatus();
|
||||
|
||||
// 终态保护:终态不可随意逆转
|
||||
if ("SUCCESS".equals(currentStatus) && !"SUCCESS".equals(targetStatus))
|
||||
{
|
||||
throw new ServiceException("操作已达成终态 SUCCESS,禁止状态逆转至: " + targetStatus);
|
||||
}
|
||||
if (TERMINAL_STATUSES.contains(currentStatus) && ("CREATED".equals(targetStatus) || "REQUESTED".equals(targetStatus)))
|
||||
{
|
||||
throw new ServiceException("操作已达成终态 [" + currentStatus + "],禁止重置为: " + targetStatus);
|
||||
}
|
||||
|
||||
op.setOperationStatus(targetStatus);
|
||||
op.setLastErrorCode(errorCode);
|
||||
op.setLastErrorMessage(errorMessage);
|
||||
op.setUpdateBy(operator);
|
||||
|
||||
if (TERMINAL_STATUSES.contains(targetStatus))
|
||||
{
|
||||
op.setCompletedAt(new Date());
|
||||
}
|
||||
|
||||
operationMapper.updateExternalOperationStatus(op);
|
||||
return op;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainExternalOperation> listPendingPollOperations(String vendorCode, Date maxPollTime)
|
||||
{
|
||||
Date threshold = maxPollTime != null ? maxPollTime : new Date();
|
||||
return operationMapper.selectPendingPollOperations(vendorCode, threshold);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TrainExternalOperation createRetryOperation(Long previousOperationId, String newIdempotencyKey, String operator)
|
||||
{
|
||||
if (previousOperationId == null)
|
||||
{
|
||||
throw new ServiceException("前次操作流水ID不能为空");
|
||||
}
|
||||
TrainExternalOperation prev = operationMapper.selectExternalOperationById(previousOperationId);
|
||||
if (prev == null)
|
||||
{
|
||||
throw new ServiceException("前次操作流水不存在: " + previousOperationId);
|
||||
}
|
||||
|
||||
TrainExternalOperation retry = new TrainExternalOperation();
|
||||
retry.setDoorAuthId(prev.getDoorAuthId());
|
||||
retry.setBusinessType(prev.getBusinessType());
|
||||
retry.setVendorCode(prev.getVendorCode());
|
||||
retry.setOperationType(prev.getOperationType());
|
||||
retry.setIdempotencyKey(newIdempotencyKey);
|
||||
retry.setExternalHardwareId(prev.getExternalHardwareId());
|
||||
retry.setExternalRequestType(prev.getExternalRequestType());
|
||||
retry.setAttemptCount((prev.getAttemptCount() != null ? prev.getAttemptCount() : 0) + 1);
|
||||
retry.setCreateBy(operator);
|
||||
|
||||
return createOperation(retry);
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import com.ruoyi.train.domain.TrainDormitoryInfo;
|
||||
import com.ruoyi.train.domain.TrainExternalResourceMapping;
|
||||
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
|
||||
import com.ruoyi.train.mapper.TrainExternalResourceMappingMapper;
|
||||
import com.ruoyi.train.service.IExternalResourceMappingService;
|
||||
|
||||
/**
|
||||
* 外部资源映射业务服务实现类
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-09-15
|
||||
*/
|
||||
@Service
|
||||
public class ExternalResourceMappingServiceImpl implements IExternalResourceMappingService
|
||||
{
|
||||
@Autowired
|
||||
private TrainExternalResourceMappingMapper mappingMapper;
|
||||
|
||||
@Autowired
|
||||
private TrainDormitoryInfoMapper dormitoryInfoMapper;
|
||||
|
||||
@Override
|
||||
public TrainExternalResourceMapping getMappingById(Long mappingId)
|
||||
{
|
||||
if (mappingId == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return mappingMapper.selectExternalResourceMappingById(mappingId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrainExternalResourceMapping getActiveRoomMapping(String vendorCode, String dormId)
|
||||
{
|
||||
if (StringUtils.isEmpty(vendorCode) || StringUtils.isEmpty(dormId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return mappingMapper.selectActiveRoomMapping(vendorCode, dormId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainExternalResourceMapping> getActiveLockMappings(String vendorCode, String dormId)
|
||||
{
|
||||
if (StringUtils.isEmpty(vendorCode) || StringUtils.isEmpty(dormId))
|
||||
{
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return mappingMapper.selectActiveLockMappings(vendorCode, dormId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TrainExternalResourceMapping> listMappings(TrainExternalResourceMapping query)
|
||||
{
|
||||
return mappingMapper.selectExternalResourceMappingList(query);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TrainExternalResourceMapping bindRoomMapping(TrainExternalResourceMapping mapping)
|
||||
{
|
||||
validateCommonParameters(mapping);
|
||||
|
||||
mapping.setLocalResourceType("DORM_ROOM");
|
||||
if (StringUtils.isEmpty(mapping.getExternalResourceType()))
|
||||
{
|
||||
mapping.setExternalResourceType("ROOM");
|
||||
}
|
||||
|
||||
// 1. 验证宿舍是否存在
|
||||
checkDormitoryExists(mapping.getDormId());
|
||||
|
||||
// 2. 验证外部资源是否已被其他宿舍绑定为 ACTIVE
|
||||
checkExternalResourceNotActiveBound(mapping.getVendorCode(), mapping.getExternalResourceType(),
|
||||
mapping.getExternalResourceId(), mapping.getDormId());
|
||||
|
||||
// 3. 验证同一宿舍在同一 Vendor 下是否已有有效的 ROOM 映射(Active Uniqueness)
|
||||
TrainExternalResourceMapping existingActiveRoom = mappingMapper.selectActiveRoomMapping(
|
||||
mapping.getVendorCode(), mapping.getDormId());
|
||||
if (existingActiveRoom != null)
|
||||
{
|
||||
throw new ServiceException("宿舍 [" + mapping.getDormId() + "] 已存在有效的外部房间映射 (mappingId="
|
||||
+ existingActiveRoom.getMappingId() + "),若需重新绑定请先解除原有绑定");
|
||||
}
|
||||
|
||||
mapping.setMappingStatus("ACTIVE");
|
||||
if (mapping.getIsPrimary() == null)
|
||||
{
|
||||
mapping.setIsPrimary(1);
|
||||
}
|
||||
if (mapping.getLastSyncTime() == null)
|
||||
{
|
||||
mapping.setLastSyncTime(new Date());
|
||||
}
|
||||
|
||||
mappingMapper.insertExternalResourceMapping(mapping);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TrainExternalResourceMapping bindLockMapping(TrainExternalResourceMapping mapping)
|
||||
{
|
||||
validateCommonParameters(mapping);
|
||||
|
||||
mapping.setLocalResourceType("DOOR_LOCK");
|
||||
if (StringUtils.isEmpty(mapping.getExternalResourceType()))
|
||||
{
|
||||
mapping.setExternalResourceType("DOOR_LOCK");
|
||||
}
|
||||
|
||||
// 1. 验证宿舍是否存在
|
||||
checkDormitoryExists(mapping.getDormId());
|
||||
|
||||
// 2. 验证该门锁是否已被其他宿舍占用(Active 唯一性)
|
||||
checkExternalResourceNotActiveBound(mapping.getVendorCode(), mapping.getExternalResourceType(),
|
||||
mapping.getExternalResourceId(), mapping.getDormId());
|
||||
|
||||
mapping.setMappingStatus("ACTIVE");
|
||||
if (mapping.getIsPrimary() == null)
|
||||
{
|
||||
// 如果宿舍还没有其他锁,设为主锁;已有锁则默认从锁
|
||||
List<TrainExternalResourceMapping> existingLocks = mappingMapper.selectActiveLockMappings(
|
||||
mapping.getVendorCode(), mapping.getDormId());
|
||||
mapping.setIsPrimary(existingLocks.isEmpty() ? 1 : 0);
|
||||
}
|
||||
if (mapping.getLastSyncTime() == null)
|
||||
{
|
||||
mapping.setLastSyncTime(new Date());
|
||||
}
|
||||
|
||||
mappingMapper.insertExternalResourceMapping(mapping);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean deactivateMapping(Long mappingId, String operator)
|
||||
{
|
||||
if (mappingId == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
TrainExternalResourceMapping existing = mappingMapper.selectExternalResourceMappingById(mappingId);
|
||||
if (existing == null)
|
||||
{
|
||||
throw new ServiceException("目标外部资源映射不存在: " + mappingId);
|
||||
}
|
||||
return mappingMapper.deactivateMapping(mappingId, operator) > 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean deactivateRoomMapping(String vendorCode, String dormId, String operator)
|
||||
{
|
||||
if (StringUtils.isEmpty(vendorCode) || StringUtils.isEmpty(dormId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return mappingMapper.deactivateRoomMapping(vendorCode, dormId, operator) > 0;
|
||||
}
|
||||
|
||||
private void validateCommonParameters(TrainExternalResourceMapping mapping)
|
||||
{
|
||||
if (mapping == null)
|
||||
{
|
||||
throw new ServiceException("映射实体不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(mapping.getDormId()))
|
||||
{
|
||||
throw new ServiceException("本地宿舍ID (dormId) 不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(mapping.getVendorCode()))
|
||||
{
|
||||
mapping.setVendorCode("DMS");
|
||||
}
|
||||
if (StringUtils.isEmpty(mapping.getExternalResourceId()))
|
||||
{
|
||||
throw new ServiceException("外部资源主键 (externalResourceId) 不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkDormitoryExists(String dormId)
|
||||
{
|
||||
TrainDormitoryInfo dorm = dormitoryInfoMapper.selectTrainDormitoryInfoByDormId(dormId);
|
||||
if (dorm == null)
|
||||
{
|
||||
throw new ServiceException("目标宿舍不存在: " + dormId);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkExternalResourceNotActiveBound(String vendorCode, String resourceType, String resourceId, String currentDormId)
|
||||
{
|
||||
TrainExternalResourceMapping bound = mappingMapper.selectActiveByExternalResource(vendorCode, resourceType, resourceId);
|
||||
if (bound != null)
|
||||
{
|
||||
if (!bound.getDormId().equals(currentDormId))
|
||||
{
|
||||
throw new ServiceException("外部资源 [" + resourceId + "] 已处于有效绑定状态,关联宿舍为 ["
|
||||
+ bound.getDormId() + "],禁止跨房间重复绑定");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.train.mapper.TrainExternalOperationMapper">
|
||||
|
||||
<resultMap type="TrainExternalOperation" id="TrainExternalOperationResult">
|
||||
<result property="operationId" column="operation_id" />
|
||||
<result property="doorAuthId" column="door_auth_id" />
|
||||
<result property="businessType" column="business_type" />
|
||||
<result property="vendorCode" column="vendor_code" />
|
||||
<result property="operationType" column="operation_type" />
|
||||
<result property="operationStatus" column="operation_status" />
|
||||
<result property="idempotencyKey" column="idempotency_key" />
|
||||
<result property="externalOperationId" column="external_operation_id" />
|
||||
<result property="externalRequestType" column="external_request_type" />
|
||||
<result property="externalHardwareId" column="external_hardware_id" />
|
||||
<result property="externalKeyId" column="external_key_id" />
|
||||
<result property="attemptCount" column="attempt_count" />
|
||||
<result property="nextPollTime" column="next_poll_time" />
|
||||
<result property="requestedAt" column="requested_at" />
|
||||
<result property="completedAt" column="completed_at" />
|
||||
<result property="lastErrorCode" column="last_error_code" />
|
||||
<result property="lastErrorMessage" column="last_error_message" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTrainExternalOperationVo">
|
||||
SELECT operation_id, door_auth_id, business_type, vendor_code, operation_type,
|
||||
operation_status, idempotency_key, external_operation_id, external_request_type,
|
||||
external_hardware_id, external_key_id, attempt_count, next_poll_time,
|
||||
requested_at, completed_at, last_error_code, last_error_message,
|
||||
create_by, create_time, update_by, update_time, remark
|
||||
FROM train_external_operation
|
||||
</sql>
|
||||
|
||||
<select id="selectExternalOperationById" parameterType="Long" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE operation_id = #{operationId}
|
||||
</select>
|
||||
|
||||
<select id="selectByIdempotencyKey" parameterType="String" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE idempotency_key = #{idempotencyKey}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectByDoorAuthId" parameterType="Long" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE door_auth_id = #{doorAuthId}
|
||||
ORDER BY operation_id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectByExternalOperationId" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND external_operation_id = #{externalOperationId}
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectPendingPollOperations" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
WHERE operation_status IN ('ACCEPTED', 'PENDING', 'RETRY_WAIT')
|
||||
AND next_poll_time <= #{maxPollTime}
|
||||
<if test="vendorCode != null and vendorCode != ''">
|
||||
AND vendor_code = #{vendorCode}
|
||||
</if>
|
||||
ORDER BY next_poll_time ASC, operation_id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectExternalOperationList" parameterType="TrainExternalOperation" resultMap="TrainExternalOperationResult">
|
||||
<include refid="selectTrainExternalOperationVo"/>
|
||||
<where>
|
||||
<if test="doorAuthId != null">
|
||||
AND door_auth_id = #{doorAuthId}
|
||||
</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">
|
||||
AND vendor_code = #{vendorCode}
|
||||
</if>
|
||||
<if test="operationType != null and operationType != ''">
|
||||
AND operation_type = #{operationType}
|
||||
</if>
|
||||
<if test="operationStatus != null and operationStatus != ''">
|
||||
AND operation_status = #{operationStatus}
|
||||
</if>
|
||||
<if test="idempotencyKey != null and idempotencyKey != ''">
|
||||
AND idempotency_key = #{idempotencyKey}
|
||||
</if>
|
||||
<if test="externalOperationId != null and externalOperationId != ''">
|
||||
AND external_operation_id = #{externalOperationId}
|
||||
</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">
|
||||
AND external_hardware_id = #{externalHardwareId}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY operation_id DESC
|
||||
</select>
|
||||
|
||||
<insert id="insertExternalOperation" parameterType="TrainExternalOperation" useGeneratedKeys="true" keyProperty="operationId">
|
||||
INSERT INTO train_external_operation (
|
||||
door_auth_id,
|
||||
<if test="businessType != null and businessType != ''">business_type,</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">vendor_code,</if>
|
||||
operation_type,
|
||||
<if test="operationStatus != null and operationStatus != ''">operation_status,</if>
|
||||
idempotency_key,
|
||||
<if test="externalOperationId != null and externalOperationId != ''">external_operation_id,</if>
|
||||
<if test="externalRequestType != null">external_request_type,</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">external_hardware_id,</if>
|
||||
<if test="externalKeyId != null and externalKeyId != ''">external_key_id,</if>
|
||||
<if test="attemptCount != null">attempt_count,</if>
|
||||
<if test="nextPollTime != null">next_poll_time,</if>
|
||||
<if test="requestedAt != null">requested_at,</if>
|
||||
<if test="completedAt != null">completed_at,</if>
|
||||
<if test="lastErrorCode != null and lastErrorCode != ''">last_error_code,</if>
|
||||
<if test="lastErrorMessage != null and lastErrorMessage != ''">last_error_message,</if>
|
||||
<if test="createBy != null and createBy != ''">create_by,</if>
|
||||
<if test="remark != null and remark != ''">remark,</if>
|
||||
create_time
|
||||
) VALUES (
|
||||
#{doorAuthId},
|
||||
<if test="businessType != null and businessType != ''">#{businessType},</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">#{vendorCode},</if>
|
||||
#{operationType},
|
||||
<if test="operationStatus != null and operationStatus != ''">#{operationStatus},</if>
|
||||
#{idempotencyKey},
|
||||
<if test="externalOperationId != null and externalOperationId != ''">#{externalOperationId},</if>
|
||||
<if test="externalRequestType != null">#{externalRequestType},</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">#{externalHardwareId},</if>
|
||||
<if test="externalKeyId != null and externalKeyId != ''">#{externalKeyId},</if>
|
||||
<if test="attemptCount != null">#{attemptCount},</if>
|
||||
<if test="nextPollTime != null">#{nextPollTime},</if>
|
||||
<if test="requestedAt != null">#{requestedAt},</if>
|
||||
<if test="completedAt != null">#{completedAt},</if>
|
||||
<if test="lastErrorCode != null and lastErrorCode != ''">#{lastErrorCode},</if>
|
||||
<if test="lastErrorMessage != null and lastErrorMessage != ''">#{lastErrorMessage},</if>
|
||||
<if test="createBy != null and createBy != ''">#{createBy},</if>
|
||||
<if test="remark != null and remark != ''">#{remark},</if>
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateExternalOperationStatus" parameterType="TrainExternalOperation">
|
||||
UPDATE train_external_operation
|
||||
<set>
|
||||
<if test="operationStatus != null and operationStatus != ''">operation_status = #{operationStatus},</if>
|
||||
<if test="attemptCount != null">attempt_count = #{attemptCount},</if>
|
||||
<if test="nextPollTime != null">next_poll_time = #{nextPollTime},</if>
|
||||
<if test="requestedAt != null">requested_at = #{requestedAt},</if>
|
||||
<if test="completedAt != null">completed_at = #{completedAt},</if>
|
||||
<if test="lastErrorCode != null">last_error_code = #{lastErrorCode},</if>
|
||||
<if test="lastErrorMessage != null">last_error_message = #{lastErrorMessage},</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
WHERE operation_id = #{operationId}
|
||||
</update>
|
||||
|
||||
<update id="updateExternalReference">
|
||||
UPDATE train_external_operation
|
||||
SET external_operation_id = #{externalOperationId},
|
||||
external_hardware_id = #{externalHardwareId},
|
||||
external_key_id = #{externalKeyId},
|
||||
update_by = #{updateBy},
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE operation_id = #{operationId}
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<!DOCTYPE mapper
|
||||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.ruoyi.train.mapper.TrainExternalResourceMappingMapper">
|
||||
|
||||
<resultMap type="TrainExternalResourceMapping" id="TrainExternalResourceMappingResult">
|
||||
<result property="mappingId" column="mapping_id" />
|
||||
<result property="dormId" column="dorm_id" />
|
||||
<result property="vendorCode" column="vendor_code" />
|
||||
<result property="localResourceType" column="local_resource_type" />
|
||||
<result property="externalResourceType" column="external_resource_type" />
|
||||
<result property="externalResourceId" column="external_resource_id" />
|
||||
<result property="externalParentId" column="external_parent_id" />
|
||||
<result property="externalHardwareId" column="external_hardware_id" />
|
||||
<result property="externalProjectId" column="external_project_id" />
|
||||
<result property="isPrimary" column="is_primary" />
|
||||
<result property="mappingStatus" column="mapping_status" />
|
||||
<result property="lastSyncTime" column="last_sync_time" />
|
||||
<result property="createBy" column="create_by" />
|
||||
<result property="createTime" column="create_time" />
|
||||
<result property="updateBy" column="update_by" />
|
||||
<result property="updateTime" column="update_time" />
|
||||
<result property="remark" column="remark" />
|
||||
</resultMap>
|
||||
|
||||
<sql id="selectTrainExternalResourceMappingVo">
|
||||
SELECT mapping_id, dorm_id, vendor_code, local_resource_type, external_resource_type,
|
||||
external_resource_id, external_parent_id, external_hardware_id, external_project_id,
|
||||
is_primary, mapping_status, last_sync_time, create_by, create_time, update_by,
|
||||
update_time, remark
|
||||
FROM train_external_resource_mapping
|
||||
</sql>
|
||||
|
||||
<select id="selectExternalResourceMappingById" parameterType="Long" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
WHERE mapping_id = #{mappingId}
|
||||
</select>
|
||||
|
||||
<select id="selectActiveRoomMapping" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND dorm_id = #{dormId}
|
||||
AND external_resource_type = 'ROOM'
|
||||
AND mapping_status = 'ACTIVE'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectActiveLockMappings" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND dorm_id = #{dormId}
|
||||
AND local_resource_type = 'DOOR_LOCK'
|
||||
AND mapping_status = 'ACTIVE'
|
||||
ORDER BY is_primary DESC, mapping_id ASC
|
||||
</select>
|
||||
|
||||
<select id="selectActiveByExternalResource" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND external_resource_type = #{externalResourceType}
|
||||
AND external_resource_id = #{externalResourceId}
|
||||
AND mapping_status = 'ACTIVE'
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
<select id="selectExternalResourceMappingList" parameterType="TrainExternalResourceMapping" resultMap="TrainExternalResourceMappingResult">
|
||||
<include refid="selectTrainExternalResourceMappingVo"/>
|
||||
<where>
|
||||
<if test="dormId != null and dormId != ''">
|
||||
AND dorm_id = #{dormId}
|
||||
</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">
|
||||
AND vendor_code = #{vendorCode}
|
||||
</if>
|
||||
<if test="localResourceType != null and localResourceType != ''">
|
||||
AND local_resource_type = #{localResourceType}
|
||||
</if>
|
||||
<if test="externalResourceType != null and externalResourceType != ''">
|
||||
AND external_resource_type = #{externalResourceType}
|
||||
</if>
|
||||
<if test="externalResourceId != null and externalResourceId != ''">
|
||||
AND external_resource_id = #{externalResourceId}
|
||||
</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">
|
||||
AND external_hardware_id = #{externalHardwareId}
|
||||
</if>
|
||||
<if test="mappingStatus != null and mappingStatus != ''">
|
||||
AND mapping_status = #{mappingStatus}
|
||||
</if>
|
||||
<if test="isPrimary != null">
|
||||
AND is_primary = #{isPrimary}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY mapping_id DESC
|
||||
</select>
|
||||
|
||||
<insert id="insertExternalResourceMapping" parameterType="TrainExternalResourceMapping" useGeneratedKeys="true" keyProperty="mappingId">
|
||||
INSERT INTO train_external_resource_mapping (
|
||||
<if test="dormId != null and dormId != ''">dorm_id,</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">vendor_code,</if>
|
||||
<if test="localResourceType != null and localResourceType != ''">local_resource_type,</if>
|
||||
<if test="externalResourceType != null and externalResourceType != ''">external_resource_type,</if>
|
||||
<if test="externalResourceId != null and externalResourceId != ''">external_resource_id,</if>
|
||||
<if test="externalParentId != null and externalParentId != ''">external_parent_id,</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">external_hardware_id,</if>
|
||||
<if test="externalProjectId != null and externalProjectId != ''">external_project_id,</if>
|
||||
<if test="isPrimary != null">is_primary,</if>
|
||||
<if test="mappingStatus != null and mappingStatus != ''">mapping_status,</if>
|
||||
<if test="lastSyncTime != null">last_sync_time,</if>
|
||||
<if test="createBy != null and createBy != ''">create_by,</if>
|
||||
<if test="remark != null and remark != ''">remark,</if>
|
||||
create_time
|
||||
) VALUES (
|
||||
<if test="dormId != null and dormId != ''">#{dormId},</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">#{vendorCode},</if>
|
||||
<if test="localResourceType != null and localResourceType != ''">#{localResourceType},</if>
|
||||
<if test="externalResourceType != null and externalResourceType != ''">#{externalResourceType},</if>
|
||||
<if test="externalResourceId != null and externalResourceId != ''">#{externalResourceId},</if>
|
||||
<if test="externalParentId != null and externalParentId != ''">#{externalParentId},</if>
|
||||
<if test="externalHardwareId != null and externalHardwareId != ''">#{externalHardwareId},</if>
|
||||
<if test="externalProjectId != null and externalProjectId != ''">#{externalProjectId},</if>
|
||||
<if test="isPrimary != null">#{isPrimary},</if>
|
||||
<if test="mappingStatus != null and mappingStatus != ''">#{mappingStatus},</if>
|
||||
<if test="lastSyncTime != null">#{lastSyncTime},</if>
|
||||
<if test="createBy != null and createBy != ''">#{createBy},</if>
|
||||
<if test="remark != null and remark != ''">#{remark},</if>
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
</insert>
|
||||
|
||||
<update id="updateExternalResourceMapping" parameterType="TrainExternalResourceMapping">
|
||||
UPDATE train_external_resource_mapping
|
||||
<set>
|
||||
<if test="dormId != null and dormId != ''">dorm_id = #{dormId},</if>
|
||||
<if test="vendorCode != null and vendorCode != ''">vendor_code = #{vendorCode},</if>
|
||||
<if test="localResourceType != null and localResourceType != ''">local_resource_type = #{localResourceType},</if>
|
||||
<if test="externalResourceType != null and externalResourceType != ''">external_resource_type = #{externalResourceType},</if>
|
||||
<if test="externalResourceId != null and externalResourceId != ''">external_resource_id = #{externalResourceId},</if>
|
||||
<if test="externalParentId != null">external_parent_id = #{externalParentId},</if>
|
||||
<if test="externalHardwareId != null">external_hardware_id = #{externalHardwareId},</if>
|
||||
<if test="externalProjectId != null">external_project_id = #{externalProjectId},</if>
|
||||
<if test="isPrimary != null">is_primary = #{isPrimary},</if>
|
||||
<if test="mappingStatus != null and mappingStatus != ''">mapping_status = #{mappingStatus},</if>
|
||||
<if test="lastSyncTime != null">last_sync_time = #{lastSyncTime},</if>
|
||||
<if test="updateBy != null and updateBy != ''">update_by = #{updateBy},</if>
|
||||
<if test="remark != null">remark = #{remark},</if>
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
</set>
|
||||
WHERE mapping_id = #{mappingId}
|
||||
</update>
|
||||
|
||||
<update id="deactivateMapping">
|
||||
UPDATE train_external_resource_mapping
|
||||
SET mapping_status = 'INACTIVE',
|
||||
update_by = #{updateBy},
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE mapping_id = #{mappingId}
|
||||
</update>
|
||||
|
||||
<update id="deactivateRoomMapping">
|
||||
UPDATE train_external_resource_mapping
|
||||
SET mapping_status = 'INACTIVE',
|
||||
update_by = #{updateBy},
|
||||
update_time = CURRENT_TIMESTAMP
|
||||
WHERE vendor_code = #{vendorCode}
|
||||
AND dorm_id = #{dormId}
|
||||
AND external_resource_type = 'ROOM'
|
||||
AND mapping_status = 'ACTIVE'
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainDormAuth;
|
||||
import com.ruoyi.train.domain.TrainExternalOperation;
|
||||
import com.ruoyi.train.mapper.TrainDormAuthMapper;
|
||||
import com.ruoyi.train.mapper.TrainExternalOperationMapper;
|
||||
|
||||
/**
|
||||
* 外部设备操作流水服务单元测试
|
||||
* 覆盖 A 到 J 全部关键业务场景与安全准则
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class ExternalOperationServiceTest
|
||||
{
|
||||
private ExternalOperationServiceImpl operationService;
|
||||
private final Map<Long, TrainExternalOperation> operationStore = new HashMap<>();
|
||||
private final Map<Long, TrainDormAuth> authStore = new HashMap<>();
|
||||
private final AtomicLong opIdGen = new AtomicLong(1);
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
operationService = new ExternalOperationServiceImpl();
|
||||
operationStore.clear();
|
||||
authStore.clear();
|
||||
|
||||
TrainExternalOperationMapper opMapper = (TrainExternalOperationMapper) Proxy.newProxyInstance(
|
||||
TrainExternalOperationMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainExternalOperationMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String mName = method.getName();
|
||||
if ("selectExternalOperationById".equals(mName)) {
|
||||
return operationStore.get((Long) args[0]);
|
||||
} else if ("selectByIdempotencyKey".equals(mName)) {
|
||||
String key = (String) args[0];
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
if (key.equals(op.getIdempotencyKey())) {
|
||||
return op;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else if ("selectByDoorAuthId".equals(mName)) {
|
||||
Long aId = (Long) args[0];
|
||||
List<TrainExternalOperation> res = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
if (aId.equals(op.getDoorAuthId())) {
|
||||
res.add(op);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("selectByExternalOperationId".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String eId = (String) args[1];
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
if (vCode.equals(op.getVendorCode()) && eId.equals(op.getExternalOperationId())) {
|
||||
return op;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else if ("selectPendingPollOperations".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
List<TrainExternalOperation> res = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
String st = op.getOperationStatus();
|
||||
if ("ACCEPTED".equals(st) || "PENDING".equals(st) || "RETRY_WAIT".equals(st)) {
|
||||
if (vCode == null || vCode.equals(op.getVendorCode())) {
|
||||
res.add(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("selectExternalOperationList".equals(mName)) {
|
||||
TrainExternalOperation q = (TrainExternalOperation) args[0];
|
||||
List<TrainExternalOperation> res = new ArrayList<>();
|
||||
for (TrainExternalOperation op : operationStore.values()) {
|
||||
if (q == null || q.getDoorAuthId() == null || q.getDoorAuthId().equals(op.getDoorAuthId())) {
|
||||
res.add(op);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("insertExternalOperation".equals(mName)) {
|
||||
TrainExternalOperation op = (TrainExternalOperation) args[0];
|
||||
long id = opIdGen.getAndIncrement();
|
||||
op.setOperationId(id);
|
||||
operationStore.put(id, op);
|
||||
return 1;
|
||||
} else if ("updateExternalOperationStatus".equals(mName)) {
|
||||
TrainExternalOperation op = (TrainExternalOperation) args[0];
|
||||
operationStore.put(op.getOperationId(), op);
|
||||
return 1;
|
||||
} else if ("updateExternalReference".equals(mName)) {
|
||||
Long opId = (Long) args[0];
|
||||
String extOpId = (String) args[1];
|
||||
String extHwId = (String) args[2];
|
||||
String extKeyId = (String) args[3];
|
||||
String uBy = (String) args[4];
|
||||
TrainExternalOperation op = operationStore.get(opId);
|
||||
if (op != null) {
|
||||
op.setExternalOperationId(extOpId);
|
||||
op.setExternalHardwareId(extHwId);
|
||||
op.setExternalKeyId(extKeyId);
|
||||
op.setUpdateBy(uBy);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainDormAuthMapper authMapper = (TrainDormAuthMapper) Proxy.newProxyInstance(
|
||||
TrainDormAuthMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainDormAuthMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainDormAuthByAuthId".equals(method.getName())) {
|
||||
return authStore.get((Long) args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
setField(operationService, "operationMapper", opMapper);
|
||||
setField(operationService, "dormAuthMapper", authMapper);
|
||||
|
||||
// 初始化默认合法门禁授权
|
||||
TrainDormAuth auth = new TrainDormAuth();
|
||||
auth.setAuthId(1001L);
|
||||
auth.setAccommodationId(2001L);
|
||||
auth.setFaceSyncStatus(0);
|
||||
authStore.put(auth.getAuthId(), auth);
|
||||
}
|
||||
|
||||
// A. Create GRANT operation
|
||||
@Test
|
||||
public void testA_createGrantOperation_success()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-GRANT-001");
|
||||
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
assertNotNull(created);
|
||||
assertNotNull(created.getOperationId());
|
||||
assertEquals("CREATED", created.getOperationStatus());
|
||||
assertEquals("DOOR_AUTH", created.getBusinessType());
|
||||
assertEquals("DMS", created.getVendorCode());
|
||||
assertEquals(Integer.valueOf(0), created.getAttemptCount());
|
||||
}
|
||||
|
||||
// B. DoorAuth missing rejected
|
||||
@Test
|
||||
public void testB_doorAuthMissing_rejected()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(9999L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-ERR-001");
|
||||
|
||||
try {
|
||||
operationService.createOperation(op);
|
||||
fail("Should reject when doorAuth missing");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("目标门禁授权不存在"));
|
||||
}
|
||||
}
|
||||
|
||||
// C. Idempotency duplicate rejected/returned
|
||||
@Test
|
||||
public void testC_idempotencyDuplicate_returnsExistingWithoutInsert()
|
||||
{
|
||||
TrainExternalOperation first = new TrainExternalOperation();
|
||||
first.setDoorAuthId(1001L);
|
||||
first.setOperationType("GRANT");
|
||||
first.setIdempotencyKey("KEY-IDEMPOTENT-001");
|
||||
TrainExternalOperation created = operationService.createOperation(first);
|
||||
|
||||
operationService.transitStatus(created.getOperationId(), "REQUESTED", null, null, "admin");
|
||||
|
||||
TrainExternalOperation second = new TrainExternalOperation();
|
||||
second.setDoorAuthId(1001L);
|
||||
second.setOperationType("GRANT");
|
||||
second.setIdempotencyKey("KEY-IDEMPOTENT-001");
|
||||
|
||||
TrainExternalOperation result = operationService.createOperation(second);
|
||||
assertNotNull(result);
|
||||
assertEquals(created.getOperationId(), result.getOperationId());
|
||||
assertEquals("REQUESTED", result.getOperationStatus());
|
||||
}
|
||||
|
||||
// D. External operation ID initially null
|
||||
@Test
|
||||
public void testD_externalOperationId_initiallyNull()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("REVOKE");
|
||||
op.setIdempotencyKey("KEY-NULL-OPID");
|
||||
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
assertNull(created.getExternalOperationId());
|
||||
}
|
||||
|
||||
// E. Bind external operation ID
|
||||
@Test
|
||||
public void testE_bindExternalOperationId_success()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-BIND-OPID");
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
|
||||
TrainExternalOperation updated = operationService.bindExternalReference(
|
||||
created.getOperationId(), "DMS-OP-9988", "HW-01", "KEY-01", "admin");
|
||||
assertNotNull(updated);
|
||||
assertEquals("DMS-OP-9988", updated.getExternalOperationId());
|
||||
assertEquals("HW-01", updated.getExternalHardwareId());
|
||||
assertEquals("KEY-01", updated.getExternalKeyId());
|
||||
}
|
||||
|
||||
// F. PENDING query
|
||||
@Test
|
||||
public void testF_pendingQuery()
|
||||
{
|
||||
TrainExternalOperation op1 = new TrainExternalOperation();
|
||||
op1.setDoorAuthId(1001L);
|
||||
op1.setOperationType("GRANT");
|
||||
op1.setIdempotencyKey("KEY-POLL-01");
|
||||
TrainExternalOperation c1 = operationService.createOperation(op1);
|
||||
operationService.transitStatus(c1.getOperationId(), "PENDING", null, null, "admin");
|
||||
|
||||
TrainExternalOperation op2 = new TrainExternalOperation();
|
||||
op2.setDoorAuthId(1001L);
|
||||
op2.setOperationType("REVOKE");
|
||||
op2.setIdempotencyKey("KEY-POLL-02");
|
||||
TrainExternalOperation c2 = operationService.createOperation(op2);
|
||||
operationService.transitStatus(c2.getOperationId(), "ACCEPTED", null, null, "admin");
|
||||
|
||||
List<TrainExternalOperation> list = operationService.listPendingPollOperations("DMS", new Date(System.currentTimeMillis() + 10000));
|
||||
assertEquals(2, list.size());
|
||||
}
|
||||
|
||||
// G. SUCCESS terminal
|
||||
@Test(expected = ServiceException.class)
|
||||
public void testG_successTerminal_cannotTransitBackToPending()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-TERM-01");
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
|
||||
operationService.transitStatus(created.getOperationId(), "SUCCESS", null, null, "admin");
|
||||
|
||||
try {
|
||||
operationService.transitStatus(created.getOperationId(), "PENDING", null, null, "admin");
|
||||
fail("Should reject reversing SUCCESS status");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("禁止状态逆转"));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// H. FAILED history retained
|
||||
@Test
|
||||
public void testH_failedHistoryRetained_terminalGuard()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-FAIL-01");
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
|
||||
operationService.transitStatus(created.getOperationId(), "FAILED", "ERR-400", "Device timeout", "admin");
|
||||
|
||||
try {
|
||||
operationService.transitStatus(created.getOperationId(), "CREATED", null, null, "admin");
|
||||
fail("Should reject resetting FAILED to CREATED");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("禁止重置"));
|
||||
}
|
||||
}
|
||||
|
||||
// I. Retry creates new operation
|
||||
@Test
|
||||
public void testI_retryCreatesNewOperation()
|
||||
{
|
||||
TrainExternalOperation op = new TrainExternalOperation();
|
||||
op.setDoorAuthId(1001L);
|
||||
op.setOperationType("GRANT");
|
||||
op.setIdempotencyKey("KEY-ATTEMPT-1");
|
||||
TrainExternalOperation created = operationService.createOperation(op);
|
||||
operationService.transitStatus(created.getOperationId(), "FAILED", "500", "Gateway error", "admin");
|
||||
|
||||
TrainExternalOperation retry = operationService.createRetryOperation(
|
||||
created.getOperationId(), "KEY-ATTEMPT-2", "admin");
|
||||
assertNotNull(retry);
|
||||
assertNotEquals(created.getOperationId(), retry.getOperationId());
|
||||
assertEquals("KEY-ATTEMPT-2", retry.getIdempotencyKey());
|
||||
assertEquals(Integer.valueOf(1), retry.getAttemptCount());
|
||||
assertEquals("CREATED", retry.getOperationStatus());
|
||||
|
||||
List<TrainExternalOperation> history = operationService.listOperationsByDoorAuthId(1001L);
|
||||
assertEquals(2, history.size());
|
||||
}
|
||||
|
||||
// J. Sensitive fields absent
|
||||
@Test
|
||||
public void testJ_sensitiveFieldsAbsent()
|
||||
{
|
||||
Field[] fields = TrainExternalOperation.class.getDeclaredFields();
|
||||
for (Field f : fields) {
|
||||
String name = f.getName().toLowerCase();
|
||||
assertFalse("Entity must not contain password field: " + name, name.contains("password"));
|
||||
assertFalse("Entity must not contain card number field: " + name, name.contains("cardnumber") || name.contains("card_no"));
|
||||
assertFalse("Entity must not contain secret/token: " + name, name.contains("secret") || name.contains("token"));
|
||||
assertFalse("Entity must not contain raw payload: " + name, name.contains("rawrequest") || name.contains("rawresponse"));
|
||||
}
|
||||
}
|
||||
|
||||
// K. REVOKE is operation_type, reaching status=SUCCESS without mutating prior GRANT operation
|
||||
@Test
|
||||
public void testK_revocationIsOperationType_withSuccessStatus()
|
||||
{
|
||||
// 1. 历史 GRANT 操作已成功达成 SUCCESS
|
||||
TrainExternalOperation grantOp = new TrainExternalOperation();
|
||||
grantOp.setDoorAuthId(1001L);
|
||||
grantOp.setOperationType("GRANT");
|
||||
grantOp.setIdempotencyKey("KEY-GRANT-099");
|
||||
TrainExternalOperation createdGrant = operationService.createOperation(grantOp);
|
||||
operationService.transitStatus(createdGrant.getOperationId(), "SUCCESS", null, null, "admin");
|
||||
|
||||
// 2. 发起注销操作 (operation_type = REVOKE)
|
||||
TrainExternalOperation revokeOp = new TrainExternalOperation();
|
||||
revokeOp.setDoorAuthId(1001L);
|
||||
revokeOp.setOperationType("REVOKE");
|
||||
revokeOp.setIdempotencyKey("KEY-REVOKE-001");
|
||||
TrainExternalOperation createdRevoke = operationService.createOperation(revokeOp);
|
||||
|
||||
assertEquals("REVOKE", createdRevoke.getOperationType());
|
||||
assertEquals("CREATED", createdRevoke.getOperationStatus());
|
||||
|
||||
// 3. 注销外部调用成功返回终态 SUCCESS (并非 REVOKED)
|
||||
TrainExternalOperation completedRevoke = operationService.transitStatus(
|
||||
createdRevoke.getOperationId(), "SUCCESS", null, null, "admin");
|
||||
assertEquals("SUCCESS", completedRevoke.getOperationStatus());
|
||||
|
||||
// 4. 验证历史 GRANT 操作依然保持 SUCCESS,未被篡改
|
||||
TrainExternalOperation originalGrant = operationService.getOperationById(createdGrant.getOperationId());
|
||||
assertEquals("SUCCESS", originalGrant.getOperationStatus());
|
||||
assertEquals("GRANT", originalGrant.getOperationType());
|
||||
}
|
||||
|
||||
// L. REVOKED is NOT an operation status
|
||||
@Test
|
||||
public void testL_revokedIsNotOperationStatus()
|
||||
{
|
||||
assertFalse("Operation status constants must not define STATUS_REVOKED",
|
||||
TrainExternalOperation.STATUS_SUCCESS.equals("REVOKED"));
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception
|
||||
{
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
+379
@@ -0,0 +1,379 @@
|
||||
package com.ruoyi.train.service.impl;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import com.ruoyi.common.exception.ServiceException;
|
||||
import com.ruoyi.train.domain.TrainDormitoryInfo;
|
||||
import com.ruoyi.train.domain.TrainExternalResourceMapping;
|
||||
import com.ruoyi.train.mapper.TrainDormitoryInfoMapper;
|
||||
import com.ruoyi.train.mapper.TrainExternalResourceMappingMapper;
|
||||
|
||||
/**
|
||||
* 外部资源映射服务单元测试
|
||||
* 覆盖 A 到 I 全部关键业务场景
|
||||
*
|
||||
* @author ruoyi
|
||||
*/
|
||||
public class ExternalResourceMappingServiceTest
|
||||
{
|
||||
private ExternalResourceMappingServiceImpl mappingService;
|
||||
private final Map<Long, TrainExternalResourceMapping> mappingStore = new HashMap<>();
|
||||
private final Map<String, TrainDormitoryInfo> dormStore = new HashMap<>();
|
||||
private final AtomicLong mappingIdGen = new AtomicLong(1);
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception
|
||||
{
|
||||
mappingService = new ExternalResourceMappingServiceImpl();
|
||||
mappingStore.clear();
|
||||
dormStore.clear();
|
||||
|
||||
TrainExternalResourceMappingMapper mappingMapper = (TrainExternalResourceMappingMapper) Proxy.newProxyInstance(
|
||||
TrainExternalResourceMappingMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainExternalResourceMappingMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
String mName = method.getName();
|
||||
if ("selectExternalResourceMappingById".equals(mName)) {
|
||||
return mappingStore.get((Long) args[0]);
|
||||
} else if ("selectActiveRoomMapping".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String dId = (String) args[1];
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId())
|
||||
&& "DORM_ROOM".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else if ("selectActiveLockMappings".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String dId = (String) args[1];
|
||||
List<TrainExternalResourceMapping> res = new ArrayList<>();
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId())
|
||||
&& "DOOR_LOCK".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) {
|
||||
res.add(m);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("selectActiveByExternalResource".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String rType = (String) args[1];
|
||||
String rId = (String) args[2];
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (vCode.equals(m.getVendorCode()) && rType.equals(m.getExternalResourceType())
|
||||
&& rId.equals(m.getExternalResourceId()) && "ACTIVE".equals(m.getMappingStatus())) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else if ("selectExternalResourceMappingList".equals(mName)) {
|
||||
TrainExternalResourceMapping q = (TrainExternalResourceMapping) args[0];
|
||||
List<TrainExternalResourceMapping> res = new ArrayList<>();
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (q == null || q.getDormId() == null || q.getDormId().equals(m.getDormId())) {
|
||||
res.add(m);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
} else if ("insertExternalResourceMapping".equals(mName)) {
|
||||
TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0];
|
||||
long id = mappingIdGen.getAndIncrement();
|
||||
m.setMappingId(id);
|
||||
mappingStore.put(id, m);
|
||||
return 1;
|
||||
} else if ("updateExternalResourceMapping".equals(mName)) {
|
||||
TrainExternalResourceMapping m = (TrainExternalResourceMapping) args[0];
|
||||
mappingStore.put(m.getMappingId(), m);
|
||||
return 1;
|
||||
} else if ("deactivateMapping".equals(mName)) {
|
||||
Long id = (Long) args[0];
|
||||
String uBy = (String) args[1];
|
||||
TrainExternalResourceMapping m = mappingStore.get(id);
|
||||
if (m != null) {
|
||||
m.setMappingStatus("INACTIVE");
|
||||
m.setUpdateBy(uBy);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
} else if ("deactivateRoomMapping".equals(mName)) {
|
||||
String vCode = (String) args[0];
|
||||
String dId = (String) args[1];
|
||||
String uBy = (String) args[2];
|
||||
for (TrainExternalResourceMapping m : mappingStore.values()) {
|
||||
if (vCode.equals(m.getVendorCode()) && dId.equals(m.getDormId())
|
||||
&& "DORM_ROOM".equals(m.getLocalResourceType()) && "ACTIVE".equals(m.getMappingStatus())) {
|
||||
m.setMappingStatus("INACTIVE");
|
||||
m.setUpdateBy(uBy);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
TrainDormitoryInfoMapper dormMapper = (TrainDormitoryInfoMapper) Proxy.newProxyInstance(
|
||||
TrainDormitoryInfoMapper.class.getClassLoader(),
|
||||
new Class<?>[]{TrainDormitoryInfoMapper.class},
|
||||
(proxy, method, args) -> {
|
||||
if ("selectTrainDormitoryInfoByDormId".equals(method.getName())) {
|
||||
return dormStore.get(args[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
setField(mappingService, "mappingMapper", mappingMapper);
|
||||
setField(mappingService, "dormitoryInfoMapper", dormMapper);
|
||||
|
||||
// 注入默认合法宿舍
|
||||
TrainDormitoryInfo dorm101 = new TrainDormitoryInfo();
|
||||
dorm101.setDormId("DORM-101");
|
||||
dorm101.setDormBuilding("1号楼");
|
||||
dorm101.setDormFloor("1层");
|
||||
dorm101.setDormRoom("101");
|
||||
dorm101.setCapacity(2);
|
||||
dormStore.put(dorm101.getDormId(), dorm101);
|
||||
|
||||
TrainDormitoryInfo dorm102 = new TrainDormitoryInfo();
|
||||
dorm102.setDormId("DORM-102");
|
||||
dorm102.setDormBuilding("1号楼");
|
||||
dorm102.setDormFloor("1层");
|
||||
dorm102.setDormRoom("102");
|
||||
dorm102.setCapacity(2);
|
||||
dormStore.put(dorm102.getDormId(), dorm102);
|
||||
}
|
||||
|
||||
// A. Create room mapping
|
||||
@Test
|
||||
public void testA_createRoomMapping_success()
|
||||
{
|
||||
TrainExternalResourceMapping mapping = new TrainExternalResourceMapping();
|
||||
mapping.setDormId("DORM-101");
|
||||
mapping.setVendorCode("DMS");
|
||||
mapping.setExternalResourceId("PLACE-8801");
|
||||
|
||||
TrainExternalResourceMapping created = mappingService.bindRoomMapping(mapping);
|
||||
|
||||
assertNotNull(created);
|
||||
assertNotNull(created.getMappingId());
|
||||
assertEquals("DORM_ROOM", created.getLocalResourceType());
|
||||
assertEquals("ROOM", created.getExternalResourceType());
|
||||
assertEquals("ACTIVE", created.getMappingStatus());
|
||||
assertEquals(Integer.valueOf(1), created.getIsPrimary());
|
||||
}
|
||||
|
||||
// A2. ROOM and DOOR_LOCK can share same external_resource_id legally
|
||||
@Test
|
||||
public void testA2_sameExternalIdForRoomAndLock_allowed()
|
||||
{
|
||||
// 1. 绑定 ROOM 外部ID为 10001
|
||||
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
|
||||
room.setDormId("DORM-101");
|
||||
room.setVendorCode("DMS");
|
||||
room.setExternalResourceId("10001");
|
||||
TrainExternalResourceMapping resRoom = mappingService.bindRoomMapping(room);
|
||||
assertNotNull(resRoom);
|
||||
assertEquals("ROOM", resRoom.getExternalResourceType());
|
||||
|
||||
// 2. 绑定 DOOR_LOCK 外部ID同样为 10001 (不同类型,完全合法)
|
||||
TrainExternalResourceMapping lock = new TrainExternalResourceMapping();
|
||||
lock.setDormId("DORM-101");
|
||||
lock.setVendorCode("DMS");
|
||||
lock.setExternalResourceId("10001");
|
||||
TrainExternalResourceMapping resLock = mappingService.bindLockMapping(lock);
|
||||
assertNotNull(resLock);
|
||||
assertEquals("DOOR_LOCK", resLock.getExternalResourceType());
|
||||
assertEquals(resRoom.getExternalResourceId(), resLock.getExternalResourceId());
|
||||
}
|
||||
|
||||
// B. Duplicate active room mapping rejected
|
||||
@Test
|
||||
public void testB_duplicateActiveRoomMapping_rejected()
|
||||
{
|
||||
TrainExternalResourceMapping first = new TrainExternalResourceMapping();
|
||||
first.setDormId("DORM-101");
|
||||
first.setVendorCode("DMS");
|
||||
first.setExternalResourceId("PLACE-8801");
|
||||
mappingService.bindRoomMapping(first);
|
||||
|
||||
TrainExternalResourceMapping second = new TrainExternalResourceMapping();
|
||||
second.setDormId("DORM-101");
|
||||
second.setVendorCode("DMS");
|
||||
second.setExternalResourceId("PLACE-8802");
|
||||
|
||||
try {
|
||||
mappingService.bindRoomMapping(second);
|
||||
fail("Should reject duplicate active room mapping on same dorm");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("已存在有效的外部房间映射"));
|
||||
}
|
||||
}
|
||||
|
||||
// C. Deactivate old mapping then rebind allowed
|
||||
@Test
|
||||
public void testC_deactivateOldMappingThenRebind_allowed()
|
||||
{
|
||||
TrainExternalResourceMapping first = new TrainExternalResourceMapping();
|
||||
first.setDormId("DORM-101");
|
||||
first.setVendorCode("DMS");
|
||||
first.setExternalResourceId("PLACE-8801");
|
||||
TrainExternalResourceMapping created = mappingService.bindRoomMapping(first);
|
||||
|
||||
boolean deactivated = mappingService.deactivateMapping(created.getMappingId(), "admin");
|
||||
assertTrue(deactivated);
|
||||
|
||||
TrainExternalResourceMapping second = new TrainExternalResourceMapping();
|
||||
second.setDormId("DORM-101");
|
||||
second.setVendorCode("DMS");
|
||||
second.setExternalResourceId("PLACE-8802");
|
||||
|
||||
TrainExternalResourceMapping rebound = mappingService.bindRoomMapping(second);
|
||||
assertNotNull(rebound);
|
||||
assertEquals("ACTIVE", rebound.getMappingStatus());
|
||||
assertEquals("PLACE-8802", rebound.getExternalResourceId());
|
||||
}
|
||||
|
||||
// D. One room multiple lock mapping allowed
|
||||
@Test
|
||||
public void testD_oneRoomMultipleLockMapping_allowed()
|
||||
{
|
||||
TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping();
|
||||
lock1.setDormId("DORM-101");
|
||||
lock1.setVendorCode("DMS");
|
||||
lock1.setExternalResourceId("LOCK-001");
|
||||
lock1.setExternalHardwareId("HW-SN-001");
|
||||
|
||||
TrainExternalResourceMapping res1 = mappingService.bindLockMapping(lock1);
|
||||
assertEquals(Integer.valueOf(1), res1.getIsPrimary());
|
||||
|
||||
TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping();
|
||||
lock2.setDormId("DORM-101");
|
||||
lock2.setVendorCode("DMS");
|
||||
lock2.setExternalResourceId("LOCK-002");
|
||||
lock2.setExternalHardwareId("HW-SN-002");
|
||||
|
||||
TrainExternalResourceMapping res2 = mappingService.bindLockMapping(lock2);
|
||||
assertEquals(Integer.valueOf(0), res2.getIsPrimary());
|
||||
|
||||
List<TrainExternalResourceMapping> locks = mappingService.getActiveLockMappings("DMS", "DORM-101");
|
||||
assertEquals(2, locks.size());
|
||||
}
|
||||
|
||||
// E. Same external lock active binding to two rooms rejected
|
||||
@Test
|
||||
public void testE_sameExternalLockActiveBindingToTwoRooms_rejected()
|
||||
{
|
||||
TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping();
|
||||
lock1.setDormId("DORM-101");
|
||||
lock1.setVendorCode("DMS");
|
||||
lock1.setExternalResourceId("LOCK-SHARED");
|
||||
mappingService.bindLockMapping(lock1);
|
||||
|
||||
TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping();
|
||||
lock2.setDormId("DORM-102");
|
||||
lock2.setVendorCode("DMS");
|
||||
lock2.setExternalResourceId("LOCK-SHARED");
|
||||
|
||||
try {
|
||||
mappingService.bindLockMapping(lock2);
|
||||
fail("Should reject same external lock bound to another dorm");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("已处于有效绑定状态"));
|
||||
}
|
||||
}
|
||||
|
||||
// F. Invalid dorm rejected
|
||||
@Test
|
||||
public void testF_invalidDorm_rejected()
|
||||
{
|
||||
TrainExternalResourceMapping mapping = new TrainExternalResourceMapping();
|
||||
mapping.setDormId("DORM-NON-EXIST");
|
||||
mapping.setVendorCode("DMS");
|
||||
mapping.setExternalResourceId("PLACE-9999");
|
||||
|
||||
try {
|
||||
mappingService.bindRoomMapping(mapping);
|
||||
fail("Should reject invalid dormId");
|
||||
} catch (ServiceException e) {
|
||||
assertTrue(e.getMessage().contains("目标宿舍不存在"));
|
||||
}
|
||||
}
|
||||
|
||||
// G. History retained
|
||||
@Test
|
||||
public void testG_historyRetained_listQuery()
|
||||
{
|
||||
TrainExternalResourceMapping first = new TrainExternalResourceMapping();
|
||||
first.setDormId("DORM-101");
|
||||
first.setVendorCode("DMS");
|
||||
first.setExternalResourceId("PLACE-OLD");
|
||||
TrainExternalResourceMapping created = mappingService.bindRoomMapping(first);
|
||||
mappingService.deactivateMapping(created.getMappingId(), "admin");
|
||||
|
||||
TrainExternalResourceMapping second = new TrainExternalResourceMapping();
|
||||
second.setDormId("DORM-101");
|
||||
second.setVendorCode("DMS");
|
||||
second.setExternalResourceId("PLACE-NEW");
|
||||
mappingService.bindRoomMapping(second);
|
||||
|
||||
TrainExternalResourceMapping query = new TrainExternalResourceMapping();
|
||||
query.setDormId("DORM-101");
|
||||
List<TrainExternalResourceMapping> all = mappingService.listMappings(query);
|
||||
|
||||
assertEquals(2, all.size());
|
||||
}
|
||||
|
||||
// H. Query active room
|
||||
@Test
|
||||
public void testH_queryActiveRoom()
|
||||
{
|
||||
TrainExternalResourceMapping room = new TrainExternalResourceMapping();
|
||||
room.setDormId("DORM-101");
|
||||
room.setVendorCode("DMS");
|
||||
room.setExternalResourceId("PLACE-101");
|
||||
mappingService.bindRoomMapping(room);
|
||||
|
||||
TrainExternalResourceMapping found = mappingService.getActiveRoomMapping("DMS", "DORM-101");
|
||||
assertNotNull(found);
|
||||
assertEquals("PLACE-101", found.getExternalResourceId());
|
||||
}
|
||||
|
||||
// I. Query lock list
|
||||
@Test
|
||||
public void testI_queryLockList()
|
||||
{
|
||||
TrainExternalResourceMapping lock1 = new TrainExternalResourceMapping();
|
||||
lock1.setDormId("DORM-101");
|
||||
lock1.setVendorCode("DMS");
|
||||
lock1.setExternalResourceId("LOCK-01");
|
||||
mappingService.bindLockMapping(lock1);
|
||||
|
||||
TrainExternalResourceMapping lock2 = new TrainExternalResourceMapping();
|
||||
lock2.setDormId("DORM-101");
|
||||
lock2.setVendorCode("DMS");
|
||||
lock2.setExternalResourceId("LOCK-02");
|
||||
mappingService.bindLockMapping(lock2);
|
||||
|
||||
List<TrainExternalResourceMapping> locks = mappingService.getActiveLockMappings("DMS", "DORM-101");
|
||||
assertEquals(2, locks.size());
|
||||
}
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception
|
||||
{
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
-- ==============================================================================
|
||||
-- 脚本名称: 008_external_resource_operation.sql
|
||||
-- 业务模块: DMS/外部物联网集成 - 外部资源映射与操作流水持久化模型 (R.3B Wave 1)
|
||||
-- 适用数据库: KingbaseES V8 / V9 (PostgreSQL 方言兼容)
|
||||
-- 安全编号: 008 (001-003 基线, 004-005 R.2分支占用, 006 Wave0, 007 保留禁止执行)
|
||||
-- 幂等性设计: 支持重复安全执行,纯增量结构,不含破坏性语句
|
||||
-- ==============================================================================
|
||||
|
||||
-- 1. 创建外部资源映射表 train_external_resource_mapping
|
||||
CREATE TABLE IF NOT EXISTS train_external_resource_mapping (
|
||||
mapping_id BIGSERIAL PRIMARY KEY,
|
||||
dorm_id VARCHAR(32) NOT NULL,
|
||||
vendor_code VARCHAR(32) NOT NULL DEFAULT 'DMS',
|
||||
local_resource_type VARCHAR(32) NOT NULL,
|
||||
external_resource_type VARCHAR(32) NOT NULL,
|
||||
external_resource_id VARCHAR(64) NOT NULL,
|
||||
external_parent_id VARCHAR(64),
|
||||
external_hardware_id VARCHAR(64),
|
||||
external_project_id VARCHAR(64),
|
||||
is_primary SMALLINT NOT NULL DEFAULT 1,
|
||||
mapping_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
last_sync_time TIMESTAMP,
|
||||
create_by VARCHAR(64) DEFAULT '',
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_by VARCHAR(64) DEFAULT '',
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
remark VARCHAR(500),
|
||||
CONSTRAINT fk_ext_res_map_dorm FOREIGN KEY (dorm_id) REFERENCES train_dormitory_info(dorm_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE train_external_resource_mapping IS '外部资源映射关联表(支持一房多锁与多供应商适配)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.mapping_id IS '映射ID(主键)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.dorm_id IS '本地宿舍ID(外键)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.vendor_code IS '外部供应商标识(如 DMS)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.local_resource_type IS '本地资源类型(DORM_ROOM, DOOR_LOCK)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_resource_type IS '外部资源类型(ROOM, DOOR_LOCK)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_resource_id IS '外部系统资源全局主键(如 placeId, lockId)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_parent_id IS '外部父级资源标识(如 building/floor placeId)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_hardware_id IS '外部硬件标识/SN(如门锁 hardwareId)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.external_project_id IS '外部项目/租户站点ID(DMS projectId)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.is_primary IS '是否主门锁/主资源(1是, 0否)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.mapping_status IS '映射状态(ACTIVE有效, INACTIVE失效)';
|
||||
COMMENT ON COLUMN train_external_resource_mapping.last_sync_time IS '最近同步元数据时间';
|
||||
|
||||
-- 1.1 约束与索引:同一宿舍同一厂商最多1个 ACTIVE 房间映射 (局部唯一索引)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_res_map_active_room
|
||||
ON train_external_resource_mapping (vendor_code, dorm_id)
|
||||
WHERE external_resource_type = 'ROOM' AND mapping_status = 'ACTIVE';
|
||||
|
||||
-- 1.2 约束与索引:同一外部资源在同一厂商下不能同时 ACTIVE 绑定到多个本地房间 (局部唯一索引)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_res_map_active_ext_res
|
||||
ON train_external_resource_mapping (vendor_code, external_resource_type, external_resource_id)
|
||||
WHERE mapping_status = 'ACTIVE';
|
||||
|
||||
-- 1.3 常规外键与查询索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_res_map_dorm ON train_external_resource_mapping (dorm_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_res_map_ext_res ON train_external_resource_mapping (vendor_code, external_resource_type, external_resource_id);
|
||||
|
||||
|
||||
-- 2. 创建外部操作流水表 train_external_operation
|
||||
CREATE TABLE IF NOT EXISTS train_external_operation (
|
||||
operation_id BIGSERIAL PRIMARY KEY,
|
||||
door_auth_id BIGINT NOT NULL,
|
||||
business_type VARCHAR(32) NOT NULL DEFAULT 'DOOR_AUTH',
|
||||
vendor_code VARCHAR(32) NOT NULL DEFAULT 'DMS',
|
||||
operation_type VARCHAR(32) NOT NULL,
|
||||
operation_status VARCHAR(20) NOT NULL DEFAULT 'CREATED',
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
external_operation_id VARCHAR(64),
|
||||
external_request_type INTEGER,
|
||||
external_hardware_id VARCHAR(64),
|
||||
external_key_id VARCHAR(64),
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_poll_time TIMESTAMP,
|
||||
requested_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
last_error_code VARCHAR(64),
|
||||
last_error_message VARCHAR(500),
|
||||
create_by VARCHAR(64) DEFAULT '',
|
||||
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
update_by VARCHAR(64) DEFAULT '',
|
||||
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
remark VARCHAR(500),
|
||||
CONSTRAINT fk_ext_op_door_auth FOREIGN KEY (door_auth_id) REFERENCES train_dorm_auth(auth_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE train_external_operation IS '外部设备操作流水与异步轮询对账表(中立模型,无明文敏感数据)';
|
||||
COMMENT ON COLUMN train_external_operation.operation_id IS '本地操作流水ID(主键)';
|
||||
COMMENT ON COLUMN train_external_operation.door_auth_id IS '关联门禁授权主记录ID(外键)';
|
||||
COMMENT ON COLUMN train_external_operation.business_type IS '业务类型(默认 DOOR_AUTH)';
|
||||
COMMENT ON COLUMN train_external_operation.vendor_code IS '外部供应商标识(如 DMS)';
|
||||
COMMENT ON COLUMN train_external_operation.operation_type IS '操作类型(GRANT下发, REVOKE注销, STATUS_QUERY查询)';
|
||||
COMMENT ON COLUMN train_external_operation.operation_status IS '操作状态(CREATED, REQUESTED, ACCEPTED, PENDING, RETRY_WAIT, SUCCESS, FAILED, TIMEOUT)';
|
||||
COMMENT ON COLUMN train_external_operation.idempotency_key IS '本地全局幂等键(防重复调用)';
|
||||
COMMENT ON COLUMN train_external_operation.external_operation_id IS '外部返回的操作流水ID(如 DMS operationId,初始为NULL)';
|
||||
COMMENT ON COLUMN train_external_operation.external_request_type IS '外部请求类型数字码(如 1下发密码, 3删除密码)';
|
||||
COMMENT ON COLUMN train_external_operation.external_hardware_id IS '外部硬件标识/SN';
|
||||
COMMENT ON COLUMN train_external_operation.external_key_id IS '外部凭证钥匙ID(如 DMS keyId)';
|
||||
COMMENT ON COLUMN train_external_operation.attempt_count IS '尝试/轮询次数';
|
||||
COMMENT ON COLUMN train_external_operation.next_poll_time IS '下次轮询执行时间戳';
|
||||
COMMENT ON COLUMN train_external_operation.requested_at IS '向外部发出请求的时间';
|
||||
COMMENT ON COLUMN train_external_operation.completed_at IS '操作终态达成时间';
|
||||
COMMENT ON COLUMN train_external_operation.last_error_code IS '最后一次错误编码';
|
||||
COMMENT ON COLUMN train_external_operation.last_error_message IS '最后一次错误描述';
|
||||
|
||||
-- 2.1 约束与索引:幂等键全局唯一约束
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_ext_op_idempotency ON train_external_operation (idempotency_key);
|
||||
|
||||
-- 2.2 约束与索引:门禁授权外键索引与外部操作ID索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_op_door_auth ON train_external_operation (door_auth_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_op_ext_id ON train_external_operation (vendor_code, external_operation_id);
|
||||
|
||||
-- 2.3 约束与索引:待轮询操作快速检索组合索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ext_op_poll ON train_external_operation (operation_status, next_poll_time);
|
||||
@@ -181,3 +181,19 @@ DMS 接口 `POST /rest/doorlock/list`(入参 `placeId`)返回的是一个门
|
||||
| `credential_sub_type` | `create.subType` | 固定为 `4` (限时密码) |
|
||||
| `external_key_id` | 返回的 `data.id` | DMS 钥匙全局 ID (退宿撤权时作为删除入参) |
|
||||
| `dms_operation_id` | 返回的 `data.operationId` | DMS 异步下发任务 ID (用于10秒后状态轮询) |
|
||||
|
||||
---
|
||||
|
||||
## 5. Wave 1 Implementation Result (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented(外部资源映射模型与外部操作流水持久化模型已交付并验证)。
|
||||
- **资源映射基数与引用模型 (`RESOURCE_MAPPING_REFERENCE_MODEL = DORM_ID_FK`)**:
|
||||
- 本地采用 `dorm_id` 外键强关联 `train_dormitory_info`,确保一期完整性与零类型歧义。
|
||||
- **房间映射基数**:`ROOM_MAPPING_CARDINALITY = Dormitory 1:0..1 Active Room (Dormitory 1:N History)`,同一宿舍在同一 Vendor 下最多只能有 1 个处于 ACTIVE 状态的房间映射(通过局部唯一索引 `uq_ext_res_map_active_room` 严格保障)。
|
||||
- **门锁映射基数**:`LOCK_MAPPING_CARDINALITY = Dormitory 1:0..N DoorLocks`,支持一房多锁(防盗门、内门、套间门),`is_primary` 区分主从门锁。
|
||||
- **外部资源唯一性**:`EXTERNAL_RESOURCE_ACTIVE_UNIQUENESS = Vendor + External Resource ID + Active Unique`,同一外部资源在同一厂商下严禁跨房间重复 ACTIVE 绑定(通过局部唯一索引 `uq_ext_res_map_active_ext_res` 保障)。
|
||||
- **操作流水基数与引用模型 (`OPERATION_REFERENCE_MODEL = DOOR_AUTH_ID_FK`)**:
|
||||
- 本地采用 `door_auth_id` 外键强关联 `train_dorm_auth`,业务类型默认为 `DOOR_AUTH`。
|
||||
- **操作流水基数**:`DOOR_AUTH_OPERATION_CARDINALITY = DoorAuthorization 1:N ExternalOperation`,每次具体下发、重试、撤销均保留独立流水行,禁止直接覆写。
|
||||
- **状态机与幂等**:`OPERATION_STATUS_MODEL` 包含从 `CREATED` 到 `SUCCESS/FAILED/TIMEOUT/RETRY_WAIT` 的合法单向流转(`REVOKED` 属于 `DoorAuthorization` 业务状态,单次注销流水为 `operation_type=REVOKE` 达成 `SUCCESS`),终态不可逆;`idempotency_key` 唯一索引全局防重。
|
||||
- **敏感数据治理**:不存 raw JSON、密码明文、卡号明文、Token、Secret,敏感字段数为 0。
|
||||
- **数据库迁移安全**:`008_external_resource_operation.sql` 已通过静态契约测试与幂等验证,生产库保持零变更。
|
||||
@@ -173,3 +173,16 @@ ruoyi-train
|
||||
- DMS 返回的 `operationStatus` 字符串(`"undone"`, `"success"`, `"failed"`, `"timeout"`)由适配器转换为内部标准领域枚举 `NormalizedOperationStatus`。
|
||||
3. **第三方异常吞吐隔离**:
|
||||
- 外部网络超时、401 故障、DMS 内部报错统一由适配器包装为 `IntegrationException` 及其子类,不得泄露第三方平台的底层堆栈至前端。
|
||||
|
||||
---
|
||||
|
||||
## 5. Wave 1 Implementation Result (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented。
|
||||
- **持久化层落地**:
|
||||
- 完成外部资源映射实体 `TrainExternalResourceMapping` 与操作流水实体 `TrainExternalOperation`。
|
||||
- 完成持久化 Mapper 与 Service (`IExternalResourceMappingService`, `IExternalOperationService`)。
|
||||
- 落地 `train_external_resource_mapping`(支持一房多锁、Active 唯一性)与 `train_external_operation`(支持 DoorAuthorization 1:N 流水追踪)。
|
||||
- **零外部耦合红线**:
|
||||
- 本 Wave 严格保持 `DMS_HTTP_CLIENT_COUNT = 0`,`DMS_TOKEN_MANAGER_COUNT = 0`。
|
||||
- 零 RestTemplate / WebClient / HttpClient 网络调用,零明文敏感凭据。
|
||||
- **数据库 Migration**:`008_external_resource_operation.sql` 验证通过,生产库未执行。
|
||||
@@ -135,3 +135,15 @@ stateDiagram-v2
|
||||
1. 不得无限循环调用。
|
||||
2. 若配置了自动补偿,可追加 1 次重试查询;若依然超时,将操作置为 `TIMEOUT`,授权主表置为 `ISSUE_FAILED`。
|
||||
3. 页面以警告色徽标展示“设备下发超时”,提供【重新下发】按钮供管理员现场排障。
|
||||
|
||||
---
|
||||
|
||||
## 5. Wave 1 Implementation Result (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented。
|
||||
- **状态模型与流转控制 (`OPERATION_STATUS_MODEL`)**:
|
||||
- 流转闭环:`CREATED` -> `REQUESTED` -> `ACCEPTED` / `PENDING` -> `SUCCESS` / `FAILED` / `TIMEOUT` / `RETRY_WAIT`(`REVOKED` 归属于 `DoorAuthorization` 业务授权主状态,注销调用以 `operation_type=REVOKE` 发起并达成 `status=SUCCESS`)。
|
||||
- **终态保护**:`SUCCESS` 终态达成后禁止任何逆向跳转;`FAILED` / `TIMEOUT` 终态达成后禁止原记录重置为 `CREATED`。
|
||||
- **重试审计保持**:重试调用通过 `createRetryOperation` 创建自增 `attempt_count` 的全新流水记录,历史调用事实不可篡改。
|
||||
- **轮询检索接口 (`PENDING_OPERATION_QUERY`)**:
|
||||
- 交付 `selectPendingPollOperations`,检索处于 `ACCEPTED`、`PENDING`、`RETRY_WAIT` 且 `next_poll_time <= NOW()` 的操作记录。
|
||||
- 为 Wave 2 调度器提供完整数据准备,本 Wave 保持 0 调度器运行、0 HTTP 调用。
|
||||
@@ -153,3 +153,14 @@ sequenceDiagram
|
||||
### 5.1 换房核心准则总结
|
||||
- **“先发新、后撤旧”原则**:绝不能先删除了旧房权限再去发新房。若新房门锁出现硬件故障或网络不通,学员将两头落空无法进门。
|
||||
- **降级容错策略**:新房授权失败时,保留旧房权限,并在前端控制台突出显示“门禁换房未同步”,由管理员一键重试或安排备用钥匙。
|
||||
|
||||
---
|
||||
|
||||
## 6. Wave 1 Implementation Result (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented。
|
||||
- **本地幂等模型落地 (`IDEMPOTENCY_MODEL`)**:
|
||||
- 物理约束:`train_external_operation` 设立 `uq_ext_op_idempotency` 唯一索引,数据库级别杜绝重复写入。
|
||||
- 服务防重:`ExternalOperationServiceImpl.createOperation` 在插入前先查幂等键,命中已有记录直接返回已有实体,防止重复派发异步调用。
|
||||
- **重试审计与不可变性**:
|
||||
- 终态记录不可覆写:每次重试调用通过 `createRetryOperation` 独立创建自增 `attempt_count` 的新记录。
|
||||
- 保留完整排障轨迹:`last_error_code` 与 `last_error_message` 详细保留每次失败现场,满足段级安全审计与对账需求。
|
||||
@@ -124,3 +124,21 @@
|
||||
3. 创建局部唯一索引:`CREATE UNIQUE INDEX IF NOT EXISTS uq_accommodation_enroll_active ON train_accommodation (enrollment_id) WHERE accommodation_status IN (0, 1);`
|
||||
4. 幂等防护:重复执行幂等安全。
|
||||
- 生产执行纪律:本脚本已在单元/契约测试中完成全量静态与逻辑校验,**禁止在生产库执行**。
|
||||
|
||||
### 4.3 Wave 1 Implementation Result (外部资源映射与操作流水持久化落地 - Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented。
|
||||
- **物理模型与引用决策**:
|
||||
- **资源映射表 (`train_external_resource_mapping`)**:
|
||||
- 引用模型:`RESOURCE_MAPPING_REFERENCE_MODEL = DORM_ID_FK`,直接建立外键关联 `train_dormitory_info(dorm_id)`,确保一期强完整性。
|
||||
- 房间映射基数:`ROOM_MAPPING_CARDINALITY = Dormitory 1:0..1 Active Room (Dormitory 1:N History)`,局部唯一索引 `uq_ext_res_map_active_room`。
|
||||
- 锁映射基数:`LOCK_MAPPING_CARDINALITY = Dormitory 1:0..N DoorLocks`,原生支持一房多锁,`is_primary` 标记主门锁。
|
||||
- 外部资源唯一性:`EXTERNAL_RESOURCE_ACTIVE_UNIQUENESS = Vendor + External Resource ID + Active Unique`,局部唯一索引 `uq_ext_res_map_active_ext_res`。
|
||||
- **操作流水表 (`train_external_operation`)**:
|
||||
- 引用模型:`OPERATION_REFERENCE_MODEL = DOOR_AUTH_ID_FK`,直接建立外键关联 `train_dorm_auth(auth_id)`。
|
||||
- 授权操作基数:`DOOR_AUTH_OPERATION_CARDINALITY = DoorAuthorization 1:N ExternalOperation`,每次具体下发与重试均产生新记录。
|
||||
- 本地幂等键:`IDEMPOTENCY_MODEL`,唯一索引 `uq_ext_op_idempotency`。
|
||||
- 待轮询复合索引:`idx_ext_op_poll`,高效支持状态机轮询。
|
||||
- **008 迁移脚本交付 (`008_external_resource_operation.sql`)**:
|
||||
- 安全编号:`008`(001-003 基线, 004-005 R.2, 006 Wave 0, 007 保留禁止执行)。
|
||||
- 纯增量与幂等设计:无 DROP TABLE,无 TRUNCATE,无 DELETE,无明文密码/Token敏感字段。
|
||||
- 生产状态:`PRODUCTION_DB_MODIFIED? NO`,未在生产数据库执行。
|
||||
@@ -115,3 +115,23 @@ public void testConcurrentLoginDeduplication() throws Exception {
|
||||
- 在 DMS 中建立虚拟楼栋(如“联调测试楼”),配置测试房间(如“9901室”、“9902室”)。
|
||||
4. **技术支撑配合**:
|
||||
- 供应商提供联调期间的技术接口人,并确认正式环境网络是否具备固定公网 IP 白名单或专网通道。
|
||||
|
||||
---
|
||||
|
||||
## 5. Wave 1 Implementation Result (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented。
|
||||
- **新增自动化测试清单**:
|
||||
1. `ExternalResourceMappingServiceTest.java` (10 项测试,涵盖 A 到 I 全部场景及 A2 跨资源类型同 ID 合法共存场景)。
|
||||
2. `ExternalOperationServiceTest.java` (12 项测试,涵盖 A 到 J 全部场景、K 场景 REVOKE 成功达成 SUCCESS 终态与 L 场景非法 REVOKED 操作状态抛错校验)。
|
||||
3. `ExternalResourceOperationMigrationContractTest.java` (7 项静态契约测试:表名、外键、局部唯一索引、幂等键索引、无破坏性语句、无敏感字段、多字段组合联合唯一性校验)。
|
||||
- **全量测试指标**:
|
||||
- 基线测试数:174 项。
|
||||
- 新增测试数:29 项(10 + 12 + 7)。
|
||||
- 当前测试总数:203 项(ruoyi-train: 175 项,ruoyi-admin: 28 项),Failures = 0, Errors = 0, Skipped = 0。
|
||||
- Maven `clean test` 与 `clean package` 顺利构建成功。
|
||||
- **迁移验证与准入标记**:
|
||||
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`
|
||||
- `008_RUNTIME_SECOND_RUN = NOT_EXECUTED`
|
||||
- `008_CONTRACT_TEST = PASS`
|
||||
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
|
||||
- 明确声明:008 迁移脚本已通过静态语法与结构契约测试(Contract Test PASS),未在实际生产库或非隔离库执行;在投产前必须在隔离 Kingbase 实例执行双跑准入门禁。
|
||||
@@ -165,3 +165,26 @@ flowchart TD
|
||||
- `ruoyi-train`: 153 tests
|
||||
- `ruoyi-admin`: 21 tests
|
||||
- 总计:174 tests, Failures: 0, Errors: 0, Skipped: 0,全量通过。
|
||||
|
||||
### 3.3 Wave 1 Implementation Result (外部资源映射与操作流水持久化 - Wave 1 implemented)
|
||||
- **阶段目标**:External Resource Mapping & External Operation Persistence
|
||||
- **执行状态**:Wave 1 implemented
|
||||
- **核心交付清单**:
|
||||
1. 迁移脚本:`RuoYi-Vue/sql/system/kingbase/008_external_resource_operation.sql`(安全编号 008,支持一房多锁与幂等流水)
|
||||
2. 领域实体:`TrainExternalResourceMapping.java`、`TrainExternalOperation.java`(纯净中立模型,零敏感数据)
|
||||
3. 持久映射:`TrainExternalResourceMappingMapper.java/.xml`、`TrainExternalOperationMapper.java/.xml`(显式列投影,严禁 `SELECT *`)
|
||||
4. 业务服务:`IExternalResourceMappingService/Impl`、`IExternalOperationService/Impl`(Active唯一性校验、跨房间绑定防护、终态保护、重试创建)
|
||||
5. 自动化测试套件:`ExternalResourceMappingServiceTest` (10项)、`ExternalOperationServiceTest` (12项)、`ExternalResourceOperationMigrationContractTest` (7项)
|
||||
- **全量构建与回归验收**:
|
||||
- `ruoyi-train`: 175 tests
|
||||
- `ruoyi-admin`: 28 tests
|
||||
- 总计:203 tests, Failures = 0, Errors = 0, Skipped = 0,全部通过。
|
||||
- Maven `clean test` 与 `clean package` 构建成功。
|
||||
- 生产数据库变更:`PRODUCTION_DB_MODIFIED? NO`。
|
||||
- 外部 HTTP 客户端与通信:`DMS_HTTP_CLIENT_COUNT = 0`,`DMS_LIVE_API_CALLED? NO`。
|
||||
- **Migration 验证与准入门禁标记**:
|
||||
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`
|
||||
- `008_RUNTIME_SECOND_RUN = NOT_EXECUTED`
|
||||
- `008_CONTRACT_TEST = PASS`
|
||||
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`
|
||||
- 严禁将 Contract Test PASS 描述为实际 Kingbase 双执行 PASS。
|
||||
@@ -128,3 +128,30 @@
|
||||
- **质量与构建验收**:
|
||||
- 单元与契约测试总数:174 项,全部 PASS(0 failures, 0 errors)。
|
||||
- Maven `clean test` 与 `clean package` 均顺利构建成功。
|
||||
|
||||
### 6.3 Wave 1 External Resource Mapping & Operation Persistence (Wave 1 implemented)
|
||||
- **阶段状态**:Wave 1 implemented。
|
||||
- **架构决策与模型基线**:
|
||||
- 资源映射模型:`RESOURCE_MAPPING_REFERENCE_MODEL = DORM_ID_FK`,以 `dorm_id` 外键强关联 `train_dormitory_info`。
|
||||
- 房间映射基数:`ROOM_MAPPING_CARDINALITY = Dormitory 1:0..1 Active Room (Dormitory 1:N History)`。
|
||||
- 门锁映射基数:`LOCK_MAPPING_CARDINALITY = Dormitory 1:0..N DoorLocks`(支持一房多锁)。
|
||||
- 外部资源防冲突:`EXTERNAL_RESOURCE_ACTIVE_UNIQUENESS = Vendor + External Resource ID + Active Unique`。
|
||||
- 操作流水模型:`OPERATION_REFERENCE_MODEL = DOOR_AUTH_ID_FK`,以 `door_auth_id` 外键强关联 `train_dorm_auth`。
|
||||
- 授权操作基数:`DOOR_AUTH_OPERATION_CARDINALITY = DoorAuthorization 1:N ExternalOperation`。
|
||||
- 操作类型与状态分离:`OPERATION_TYPE_MODEL = GRANT, REVOKE`;`EXTERNAL_OPERATION_STATUS_MODEL = CREATED, REQUESTED, ACCEPTED, PENDING, RETRY_WAIT, SUCCESS, FAILED, TIMEOUT`(`REVOKED` 属于 `DoorAuthorization` 业务凭据状态,不属于单次操作执行结果,注销操作的执行结果为 `operation_type=REVOKE` 达成 `SUCCESS`)。
|
||||
- 本地幂等模型:`IDEMPOTENCY_MODEL` 基于 `idempotency_key` 唯一索引防重。
|
||||
- 轮询检索支持:`PENDING_OPERATION_QUERY` 准备就绪,本 Wave 保持 0 调度器运行。
|
||||
- **安全红线保持**:
|
||||
- 敏感字段:`SENSITIVE_OPERATION_FIELD_COUNT = 0`。
|
||||
- 外部耦合:`DMS_HTTP_CLIENT_COUNT = 0`,`DMS_TOKEN_MANAGER_COUNT = 0`。
|
||||
- 生产数据库:只读保护,`PRODUCTION_DB_MODIFIED? NO`。
|
||||
- **Migration 证据与准入准则**:
|
||||
- `008_RUNTIME_FIRST_RUN = NOT_EXECUTED`,`008_RUNTIME_SECOND_RUN = NOT_EXECUTED`(生产与非隔离库绝不盲跑 DDL)。
|
||||
- `008_CONTRACT_TEST = PASS`,`008_IDEMPOTENCY_STATIC_VERIFICATION = PASS`。
|
||||
- `RUNTIME_MIGRATION_GATE_BEFORE_PRODUCTION = REQUIRED`(在正式生产投产前必须在隔离 Kingbase 完成双跑验证)。
|
||||
- **测试与验证指标**:
|
||||
- 测试套件全部通过:`TOTAL_TEST_COUNT = 203` (ruoyi-train: 175, ruoyi-admin: 28),Failures = 0, Errors = 0, Skipped = 0。
|
||||
- Maven 构建:`clean test` 与 `clean package` 均 `SUCCESS`。
|
||||
- 代码格式校验:`git diff --check = PASS`。
|
||||
- 阶段评审状态:`R3_WAVE1_REVIEW = PASS`。
|
||||
- 提交状态:`R3_WAVE1_COMMIT = READY`(执行 Checkpoint Commit,绝不 PUSH,绝不 DEPLOY)。
|
||||
Reference in new issue
Block a user