diff --git a/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ExternalResourceOperationMigrationContractTest.java b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ExternalResourceOperationMigrationContractTest.java new file mode 100644 index 000000000..e8bd1a5fa --- /dev/null +++ b/RuoYi-Vue/ruoyi-admin/src/test/java/com/ruoyi/web/controller/train/ExternalResourceOperationMigrationContractTest.java @@ -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")); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalOperation.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalOperation.java new file mode 100644 index 000000000..e35acb4c6 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalOperation.java @@ -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 + '\'' + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalResourceMapping.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalResourceMapping.java new file mode 100644 index 000000000..e4fd5c7f1 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/domain/TrainExternalResourceMapping.java @@ -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 + + '}'; + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalOperationMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalOperationMapper.java new file mode 100644 index 000000000..69bbbed24 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalOperationMapper.java @@ -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 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 selectPendingPollOperations( + @Param("vendorCode") String vendorCode, + @Param("maxPollTime") Date maxPollTime); + + /** + * 查询外部操作流水列表 + * + * @param query 查询条件 + * @return 操作列表 + */ + public List 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalResourceMappingMapper.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalResourceMappingMapper.java new file mode 100644 index 000000000..9f2b6d10c --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/mapper/TrainExternalResourceMappingMapper.java @@ -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 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 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java new file mode 100644 index 000000000..b0c1fc2cc --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalOperationService.java @@ -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 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 listPendingPollOperations(String vendorCode, Date maxPollTime); + + /** + * 基于前次失败/超时操作创建新的重试操作流水(保留历史审计,生成全新 Operation 记录) + * + * @param previousOperationId 前次操作流水ID + * @param newIdempotencyKey 新操作的幂等键 + * @param operator 操作人 + * @return 新建的重试操作记录 + */ + public TrainExternalOperation createRetryOperation(Long previousOperationId, String newIdempotencyKey, String operator); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalResourceMappingService.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalResourceMappingService.java new file mode 100644 index 000000000..2d8515d67 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/IExternalResourceMappingService.java @@ -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 getActiveLockMappings(String vendorCode, String dormId); + + /** + * 条件查询外部资源映射列表 + * + * @param query 查询条件 + * @return 映射列表 + */ + public List 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); +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java new file mode 100644 index 000000000..fa0e11275 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalOperationServiceImpl.java @@ -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 VALID_TYPES = new HashSet<>(Arrays.asList( + "GRANT", "REVOKE", "STATUS_QUERY", "REMOTE_UNLOCK" + )); + + private static final Set 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 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 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); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceImpl.java b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceImpl.java new file mode 100644 index 000000000..7970f1fc7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceImpl.java @@ -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 getActiveLockMappings(String vendorCode, String dormId) + { + if (StringUtils.isEmpty(vendorCode) || StringUtils.isEmpty(dormId)) + { + return Collections.emptyList(); + } + return mappingMapper.selectActiveLockMappings(vendorCode, dormId); + } + + @Override + public List 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 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() + "],禁止跨房间重复绑定"); + } + } + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalOperationMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalOperationMapper.xml new file mode 100644 index 000000000..e72f84d5a --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalOperationMapper.xml @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + + + INSERT INTO train_external_operation ( + 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, + remark, + create_time + ) VALUES ( + #{doorAuthId}, + #{businessType}, + #{vendorCode}, + #{operationType}, + #{operationStatus}, + #{idempotencyKey}, + #{externalOperationId}, + #{externalRequestType}, + #{externalHardwareId}, + #{externalKeyId}, + #{attemptCount}, + #{nextPollTime}, + #{requestedAt}, + #{completedAt}, + #{lastErrorCode}, + #{lastErrorMessage}, + #{createBy}, + #{remark}, + CURRENT_TIMESTAMP + ) + + + + UPDATE train_external_operation + + operation_status = #{operationStatus}, + attempt_count = #{attemptCount}, + next_poll_time = #{nextPollTime}, + requested_at = #{requestedAt}, + completed_at = #{completedAt}, + last_error_code = #{lastErrorCode}, + last_error_message = #{lastErrorMessage}, + update_by = #{updateBy}, + remark = #{remark}, + update_time = CURRENT_TIMESTAMP + + WHERE operation_id = #{operationId} + + + + 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} + + + diff --git a/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalResourceMappingMapper.xml b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalResourceMappingMapper.xml new file mode 100644 index 000000000..5c78b3044 --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/main/resources/mapper/train/TrainExternalResourceMappingMapper.xml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + + INSERT INTO train_external_resource_mapping ( + 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, + remark, + create_time + ) VALUES ( + #{dormId}, + #{vendorCode}, + #{localResourceType}, + #{externalResourceType}, + #{externalResourceId}, + #{externalParentId}, + #{externalHardwareId}, + #{externalProjectId}, + #{isPrimary}, + #{mappingStatus}, + #{lastSyncTime}, + #{createBy}, + #{remark}, + CURRENT_TIMESTAMP + ) + + + + UPDATE train_external_resource_mapping + + dorm_id = #{dormId}, + vendor_code = #{vendorCode}, + local_resource_type = #{localResourceType}, + external_resource_type = #{externalResourceType}, + external_resource_id = #{externalResourceId}, + external_parent_id = #{externalParentId}, + external_hardware_id = #{externalHardwareId}, + external_project_id = #{externalProjectId}, + is_primary = #{isPrimary}, + mapping_status = #{mappingStatus}, + last_sync_time = #{lastSyncTime}, + update_by = #{updateBy}, + remark = #{remark}, + update_time = CURRENT_TIMESTAMP + + WHERE mapping_id = #{mappingId} + + + + UPDATE train_external_resource_mapping + SET mapping_status = 'INACTIVE', + update_by = #{updateBy}, + update_time = CURRENT_TIMESTAMP + WHERE mapping_id = #{mappingId} + + + + 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' + + + diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalOperationServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalOperationServiceTest.java new file mode 100644 index 000000000..ffd57d86d --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalOperationServiceTest.java @@ -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 operationStore = new HashMap<>(); + private final Map 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 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 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 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 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 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); + } +} diff --git a/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceTest.java b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceTest.java new file mode 100644 index 000000000..3050d578f --- /dev/null +++ b/RuoYi-Vue/ruoyi-train/src/test/java/com/ruoyi/train/service/impl/ExternalResourceMappingServiceTest.java @@ -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 mappingStore = new HashMap<>(); + private final Map 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 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 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 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 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 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); + } +} diff --git a/RuoYi-Vue/sql/system/kingbase/008_external_resource_operation.sql b/RuoYi-Vue/sql/system/kingbase/008_external_resource_operation.sql new file mode 100644 index 000000000..afc2626ee --- /dev/null +++ b/RuoYi-Vue/sql/system/kingbase/008_external_resource_operation.sql @@ -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); diff --git a/docs/development/dms-r3/03_domain_mapping.md b/docs/development/dms-r3/03_domain_mapping.md index dcae04af0..5ab675af7 100644 --- a/docs/development/dms-r3/03_domain_mapping.md +++ b/docs/development/dms-r3/03_domain_mapping.md @@ -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` 已通过静态契约测试与幂等验证,生产库保持零变更。 diff --git a/docs/development/dms-r3/05_adapter_architecture.md b/docs/development/dms-r3/05_adapter_architecture.md index 6d2f16d63..e772f4156 100644 --- a/docs/development/dms-r3/05_adapter_architecture.md +++ b/docs/development/dms-r3/05_adapter_architecture.md @@ -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` 验证通过,生产库未执行。 diff --git a/docs/development/dms-r3/06_authorization_state_machine.md b/docs/development/dms-r3/06_authorization_state_machine.md index 5eca97952..60f05e6ce 100644 --- a/docs/development/dms-r3/06_authorization_state_machine.md +++ b/docs/development/dms-r3/06_authorization_state_machine.md @@ -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 调用。 diff --git a/docs/development/dms-r3/07_retry_idempotency_and_compensation.md b/docs/development/dms-r3/07_retry_idempotency_and_compensation.md index 7d92de435..5a4d537e9 100644 --- a/docs/development/dms-r3/07_retry_idempotency_and_compensation.md +++ b/docs/development/dms-r3/07_retry_idempotency_and_compensation.md @@ -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` 详细保留每次失败现场,满足段级安全审计与对账需求。 diff --git a/docs/development/dms-r3/09_database_mapping_proposal.md b/docs/development/dms-r3/09_database_mapping_proposal.md index 443ce2f6e..8133f7533 100644 --- a/docs/development/dms-r3/09_database_mapping_proposal.md +++ b/docs/development/dms-r3/09_database_mapping_proposal.md @@ -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`,未在生产数据库执行。 diff --git a/docs/development/dms-r3/11_testing_and_mock_plan.md b/docs/development/dms-r3/11_testing_and_mock_plan.md index 6228ca9f4..a55c1b4eb 100644 --- a/docs/development/dms-r3/11_testing_and_mock_plan.md +++ b/docs/development/dms-r3/11_testing_and_mock_plan.md @@ -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 实例执行双跑准入门禁。 diff --git a/docs/development/dms-r3/12_implementation_task_breakdown.md b/docs/development/dms-r3/12_implementation_task_breakdown.md index c854cfeae..5a1746dcf 100644 --- a/docs/development/dms-r3/12_implementation_task_breakdown.md +++ b/docs/development/dms-r3/12_implementation_task_breakdown.md @@ -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。 diff --git a/docs/development/dms-r3/14_decision_summary.md b/docs/development/dms-r3/14_decision_summary.md index 8768bb318..115318069 100644 --- a/docs/development/dms-r3/14_decision_summary.md +++ b/docs/development/dms-r3/14_decision_summary.md @@ -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)。