新增集成:实现外部资源映射与操作记录持久化

This commit is contained in:
wangzhibiao committed 2026-09-16 10:22:50 +08:00
1 parent 5de1d2a142
commit 1332b45fae
22 files changed
+2843

No files matched your search

@@ -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"));
}
}
@@ -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 + '\'' +
'}';
}
}
@@ -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 +
'}';
}
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
@@ -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);
}
}
@@ -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() + "],禁止跨房间重复绑定");
}
}
}
}
@@ -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 &lt;= #{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>
@@ -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>
@@ -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);
}
}
@@ -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)。